Files
salvium-rs/test/integration-sync.test.js
T
Matt Hess eae8555364 Fix invalid point panic spam, add Bun FFI crypto backend
Replace .expect() with match on all point decompression in lib.rs —
  returns empty Vec instead of panicking on invalid Ed25519 points.
  During sync, generate_key_derivation is called for every tx on chain
  and most have invalid pubkeys; the old code threw hundreds of thousands
  of WASM exceptions (potential QuickJS crash vector). Now returns null
  with zero exceptions in the hot path.

  Also adds native Rust FFI backend (bun:ffi) as a third crypto code
  path for debugging sync issues alongside WASM and JS backends.
2026-02-11 05:14:12 +00:00

659 lines
26 KiB
JavaScript

#!/usr/bin/env bun
/**
* Integration Sync Test
*
* Tests wallet sync against a real Salvium daemon.
*
* Usage (Full Wallet):
* WALLET_SEED="your 25 word mnemonic" bun test/integration-sync.test.js
* MASTER_KEY="64-char-hex" bun test/integration-sync.test.js
*
* Usage (View-Only - CARROT):
* VIEW_BALANCE_SECRET="hex" ACCOUNT_SPEND_PUBKEY="hex" bun test/integration-sync.test.js
*
* Usage (View-Only - Legacy):
* VIEW_SECRET_KEY="hex" SPEND_PUBLIC_KEY="hex" bun test/integration-sync.test.js
*
* Options:
* DAEMON_URL - Daemon RPC URL (default: http://seed01.salvium.io:19081)
* START_HEIGHT - Block height to start sync from (default: 0)
* MAX_BLOCKS - Maximum blocks to sync (default: sync to chain tip)
* EXPECTED_BALANCE - Expected minimum balance to verify (optional)
* CRYPTO_BACKEND - Crypto backend: 'wasm' (default) or 'js'
*/
import { createDaemonRPC } from '../src/rpc/index.js';
import { mnemonicToSeed } from '../src/mnemonic.js';
import { deriveKeys, deriveCarrotKeys, deriveCarrotViewOnlyKeys } from '../src/carrot.js';
import { hexToBytes, bytesToHex, createAddress, generateCarrotSubaddress } from '../src/address.js';
import { NETWORK, ADDRESS_FORMAT, ADDRESS_TYPE } from '../src/constants.js';
import { MemoryStorage } from '../src/wallet-store.js';
import { WalletSync, SYNC_STATUS } from '../src/wallet-sync.js';
import { cnSubaddress, generateCNSubaddressMap, generateCarrotSubaddressMap, SUBADDRESS_LOOKAHEAD_MAJOR, SUBADDRESS_LOOKAHEAD_MINOR } from '../src/subaddress.js';
import { initCrypto, getCryptoBackend, getCurrentBackendType, setCryptoBackend } from '../src/crypto/index.js';
// ============================================================================
// Configuration
// ============================================================================
const DAEMON_URL = process.env.DAEMON_URL || 'http://seed01.salvium.io:19081';
const START_HEIGHT = parseInt(process.env.START_HEIGHT || '0', 10);
const MAX_BLOCKS = process.env.MAX_BLOCKS ? parseInt(process.env.MAX_BLOCKS, 10) : null; // null = sync to tip
const EXPECTED_BALANCE = process.env.EXPECTED_BALANCE ? BigInt(process.env.EXPECTED_BALANCE) : null;
// ============================================================================
// Get wallet keys
// ============================================================================
function getWalletKeys() {
// Option 1: From mnemonic seed (full wallet)
if (process.env.WALLET_SEED) {
console.log('Using wallet from mnemonic seed');
const mnemonic = process.env.WALLET_SEED.trim();
const result = mnemonicToSeed(mnemonic, { language: 'auto' });
if (!result.valid) {
console.error('Invalid mnemonic:', result.error);
process.exit(1);
}
return { ...deriveKeys(result.seed), isViewOnly: false };
}
// Option 2: From master key / raw seed hex (full wallet)
if (process.env.MASTER_KEY) {
console.log('Using wallet from master key (raw hex seed)');
const masterKey = process.env.MASTER_KEY.trim();
if (masterKey.length !== 64) {
console.error('MASTER_KEY must be 64 hex characters (32 bytes)');
process.exit(1);
}
return { ...deriveKeys(hexToBytes(masterKey)), isViewOnly: false };
}
// Option 3: View-only from view-balance secret (CARROT)
if (process.env.VIEW_BALANCE_SECRET) {
console.log('Using CARROT view-only wallet from view-balance secret');
if (!process.env.ACCOUNT_SPEND_PUBKEY) {
console.error('VIEW_BALANCE_SECRET requires ACCOUNT_SPEND_PUBKEY');
process.exit(1);
}
const viewBalanceSecret = hexToBytes(process.env.VIEW_BALANCE_SECRET.trim());
const accountSpendPubkey = hexToBytes(process.env.ACCOUNT_SPEND_PUBKEY.trim());
// For CARROT view-only, we need to derive the legacy CN keys differently
// We can't derive spendSecretKey, but we can still scan with viewIncomingKey
return {
viewSecretKey: null, // No legacy view key available
spendSecretKey: null,
spendPublicKey: accountSpendPubkey,
viewPublicKey: null,
viewBalanceSecret,
accountSpendPubkey,
isViewOnly: true,
isCarrotViewOnly: true
};
}
// Option 4: View-only from legacy hex keys
if (process.env.VIEW_SECRET_KEY) {
console.log('Using legacy view-only wallet from hex keys');
if (!process.env.SPEND_PUBLIC_KEY) {
console.error('VIEW_SECRET_KEY requires SPEND_PUBLIC_KEY');
process.exit(1);
}
return {
viewSecretKey: hexToBytes(process.env.VIEW_SECRET_KEY.trim()),
spendSecretKey: process.env.SPEND_SECRET_KEY ? hexToBytes(process.env.SPEND_SECRET_KEY.trim()) : null,
spendPublicKey: hexToBytes(process.env.SPEND_PUBLIC_KEY.trim()),
isViewOnly: !process.env.SPEND_SECRET_KEY
};
}
return null;
}
// ============================================================================
// Main test
// ============================================================================
async function runIntegrationTest() {
console.log('╔════════════════════════════════════════════════════════════╗');
console.log('║ Salvium Wallet Sync Integration Test ║');
console.log('╚════════════════════════════════════════════════════════════╝\n');
// Initialize crypto backend (WASM by default, CRYPTO_BACKEND=js|ffi to override)
const requestedBackend = process.env.CRYPTO_BACKEND || 'wasm';
if (requestedBackend === 'ffi') {
await setCryptoBackend('ffi');
} else if (requestedBackend === 'wasm') {
await initCrypto(); // Loads WASM, falls back to JS
}
console.log(`Crypto backend: ${getCurrentBackendType()}\n`);
// Get keys
const keys = getWalletKeys();
if (!keys) {
console.error('ERROR: No wallet keys provided.\n');
console.log('Usage:');
console.log(' WALLET_SEED="your 25 word mnemonic" bun test/integration-sync.test.js');
console.log(' VIEW_SECRET_KEY="hex" SPEND_PUBLIC_KEY="hex" bun test/integration-sync.test.js');
process.exit(1);
}
// Display CryptoNote keys
console.log('--- CryptoNote Keys ---');
console.log(`Spend secret key: ${keys.spendSecretKey ? bytesToHex(keys.spendSecretKey) : 'null'}`);
console.log(`Spend public key: ${keys.spendPublicKey ? bytesToHex(keys.spendPublicKey) : 'null'}`);
console.log(`View secret key: ${keys.viewSecretKey ? bytesToHex(keys.viewSecretKey) : 'null'}`);
console.log(`View public key: ${keys.viewPublicKey ? bytesToHex(keys.viewPublicKey) : 'null'}`);
// Derive and display CARROT keys
let carrotKeys = null;
if (keys.isCarrotViewOnly) {
// CARROT view-only: derive from view-balance secret
carrotKeys = deriveCarrotViewOnlyKeys(keys.viewBalanceSecret, keys.accountSpendPubkey);
console.log('\n--- CARROT View-Only Keys ---');
console.log(`View-balance secret: ${carrotKeys.viewBalanceSecret}`);
console.log(`Generate-image key: ${carrotKeys.generateImageKey}`);
console.log(`View-incoming key: ${carrotKeys.viewIncomingKey}`);
console.log(`Generate-address secret: ${carrotKeys.generateAddressSecret}`);
console.log('\n--- CARROT Account Pubkeys ---');
console.log(`Account spend pubkey (K_s): ${carrotKeys.accountSpendPubkey}`);
console.log(`Primary addr view (k_vi*G): ${carrotKeys.primaryAddressViewPubkey}`);
console.log(`Account view pubkey (k_vi*K_s): ${carrotKeys.accountViewPubkey}`);
} else if (keys.spendSecretKey) {
// Full wallet: derive from master secret (spend secret key)
carrotKeys = deriveCarrotKeys(keys.spendSecretKey);
console.log('\n--- CARROT Account Secrets ---');
console.log(`Master secret: ${carrotKeys.masterSecret}`);
console.log(`Prove-spend key: ${carrotKeys.proveSpendKey}`);
console.log(`View-balance secret: ${carrotKeys.viewBalanceSecret}`);
console.log(`Generate-image key: ${carrotKeys.generateImageKey}`);
console.log(`View-incoming key: ${carrotKeys.viewIncomingKey}`);
console.log(`Generate-address secret: ${carrotKeys.generateAddressSecret}`);
console.log('\n--- CARROT Account Pubkeys ---');
console.log(`Account spend pubkey (K_s): ${carrotKeys.accountSpendPubkey}`);
console.log(`Primary addr view (k_vi*G): ${carrotKeys.primaryAddressViewPubkey}`);
console.log(`Account view pubkey (k_vi*K_s): ${carrotKeys.accountViewPubkey}`);
}
// Generate Legacy (CryptoNote) address
const legacyAddress = createAddress({
network: NETWORK.MAINNET,
format: ADDRESS_FORMAT.LEGACY,
type: ADDRESS_TYPE.STANDARD,
spendPublicKey: keys.spendPublicKey,
viewPublicKey: keys.viewPublicKey
});
console.log('\n--- Legacy (CryptoNote) Addresses ---');
if (legacyAddress) {
console.log(`Main address: ${legacyAddress}`);
// Generate a few CN subaddresses
for (let minor = 1; minor <= 3; minor++) {
const subKeys = cnSubaddress(keys.spendPublicKey, keys.viewSecretKey, 0, minor);
const subAddr = createAddress({
network: NETWORK.MAINNET,
format: ADDRESS_FORMAT.LEGACY,
type: ADDRESS_TYPE.SUBADDRESS,
spendPublicKey: subKeys.spendPublicKey,
viewPublicKey: subKeys.viewPublicKey
});
console.log(`Subaddress [0,${minor}]: ${subAddr}`);
}
} else {
console.log('Warning: Could not generate legacy address');
}
// Generate CARROT addresses
if (carrotKeys) {
console.log('\n--- CARROT Addresses ---');
// Main address uses primaryAddressViewPubkey (k_vi * G)
const carrotMainAddress = createAddress({
network: NETWORK.MAINNET,
format: ADDRESS_FORMAT.CARROT,
type: ADDRESS_TYPE.STANDARD,
spendPublicKey: hexToBytes(carrotKeys.accountSpendPubkey),
viewPublicKey: hexToBytes(carrotKeys.primaryAddressViewPubkey)
});
if (carrotMainAddress) {
console.log(`Main address: ${carrotMainAddress}`);
// Generate a few CARROT subaddresses (use accountViewPubkey = k_vi * K_s)
for (let minor = 1; minor <= 3; minor++) {
const carrotSub = generateCarrotSubaddress({
network: NETWORK.MAINNET,
accountSpendPubkey: hexToBytes(carrotKeys.accountSpendPubkey),
accountViewPubkey: hexToBytes(carrotKeys.accountViewPubkey),
generateAddressSecret: hexToBytes(carrotKeys.generateAddressSecret),
major: 0,
minor
});
if (carrotSub && carrotSub.address) {
console.log(`Subaddress [0,${minor}]: ${carrotSub.address}`);
}
}
} else {
console.log('Warning: Could not generate CARROT address');
}
}
const walletType = keys.isViewOnly
? (keys.isCarrotViewOnly ? 'Yes (CARROT view-only)' : 'Yes (Legacy view-only)')
: 'No (full wallet)';
console.log(`\nWallet type: ${walletType}\n`);
// Connect to daemon
console.log(`Connecting to daemon: ${DAEMON_URL}`);
const daemon = createDaemonRPC({ url: DAEMON_URL, timeout: 30000 });
const info = await daemon.getInfo();
if (!info.success) {
console.error('ERROR: Failed to connect to daemon:', info.error?.message);
process.exit(1);
}
const daemonHeight = info.result.height;
console.log(`Daemon height: ${daemonHeight}`);
console.log(`Network: ${info.result.nettype || 'mainnet'}`);
console.log(`Status: ${info.result.status}\n`);
// Calculate sync range
const startHeight = START_HEIGHT;
const endHeight = MAX_BLOCKS ? Math.min(startHeight + MAX_BLOCKS, daemonHeight) : daemonHeight;
const blocksToSync = endHeight - startHeight;
console.log(`Sync range: ${startHeight} -> ${endHeight} (${blocksToSync} blocks)`);
if (blocksToSync > 10000) {
console.log(`Note: This may take a while...\n`);
} else {
console.log('');
}
// Create storage and sync engine
const storage = new MemoryStorage();
await storage.open();
await storage.setSyncHeight(startHeight);
// Generate subaddress maps (matching C++ wallet lookahead: 50 major x 200 minor)
console.log(`Generating subaddress maps (${SUBADDRESS_LOOKAHEAD_MAJOR} x ${SUBADDRESS_LOOKAHEAD_MINOR})...`);
const subaddressGenStart = Date.now();
// CN subaddresses
let cnSubaddresses = new Map();
if (keys.viewSecretKey && keys.spendPublicKey) {
cnSubaddresses = generateCNSubaddressMap(
keys.spendPublicKey,
keys.viewSecretKey,
SUBADDRESS_LOOKAHEAD_MAJOR,
SUBADDRESS_LOOKAHEAD_MINOR
);
console.log(` CN subaddresses: ${cnSubaddresses.size}`);
}
// CARROT subaddresses
let carrotSubaddresses = new Map();
if (carrotKeys) {
carrotSubaddresses = generateCarrotSubaddressMap(
hexToBytes(carrotKeys.accountSpendPubkey),
hexToBytes(carrotKeys.accountViewPubkey),
hexToBytes(carrotKeys.generateAddressSecret),
SUBADDRESS_LOOKAHEAD_MAJOR,
SUBADDRESS_LOOKAHEAD_MINOR
);
console.log(` CARROT subaddresses: ${carrotSubaddresses.size}`);
}
const subaddressGenTime = ((Date.now() - subaddressGenStart) / 1000).toFixed(2);
console.log(` Generated in ${subaddressGenTime}s\n`);
// Prepare CARROT keys if available
let carrotKeysForSync = null;
if (carrotKeys) {
carrotKeysForSync = {
viewIncomingKey: hexToBytes(carrotKeys.viewIncomingKey),
accountSpendPubkey: hexToBytes(carrotKeys.accountSpendPubkey),
generateImageKey: hexToBytes(carrotKeys.generateImageKey),
generateAddressSecret: hexToBytes(carrotKeys.generateAddressSecret), // Needed for subaddress key images
viewBalanceSecret: hexToBytes(carrotKeys.viewBalanceSecret) // Needed for internal (self-send) scanning + return map
};
}
const sync = new WalletSync({
storage,
daemon,
keys: {
viewSecretKey: keys.viewSecretKey,
spendPublicKey: keys.spendPublicKey,
spendSecretKey: keys.spendSecretKey
},
carrotKeys: carrotKeysForSync,
subaddresses: cnSubaddresses,
carrotSubaddresses: carrotSubaddresses,
batchSize: 100 // Will adapt automatically based on performance
});
// Track progress
let lastReportHeight = 0;
let lastReportTime = Date.now();
let blocksWithTxs = 0;
let outputsFound = 0;
let cnOutputs = 0;
let carrotOutputs = 0;
const REPORT_INTERVAL = 1000; // Report every 1000 blocks
sync.on('syncStart', (data) => {
console.log(`Sync started at height ${data.startHeight}`);
lastReportHeight = data.startHeight;
lastReportTime = Date.now();
});
sync.on('syncProgress', (data) => {
// Report every REPORT_INTERVAL blocks
if (data.currentHeight - lastReportHeight >= REPORT_INTERVAL) {
const now = Date.now();
const elapsed = (now - lastReportTime) / 1000;
const blocksProcessed = data.currentHeight - lastReportHeight;
const blocksPerSec = elapsed > 0 ? (blocksProcessed / elapsed).toFixed(1) : '?';
const percent = data.percentComplete.toFixed(1);
console.log(` Height ${data.currentHeight} (${percent}%) - ${blocksPerSec} blk/s - ${outputsFound} outputs (${cnOutputs} CN, ${carrotOutputs} CARROT)`);
lastReportHeight = data.currentHeight;
lastReportTime = now;
}
});
sync.on('newBlock', (block) => {
if (block.txCount > 0) {
blocksWithTxs++;
}
// Debug: log every 1000 blocks
if (block.height % 1000 === 0) {
console.log(` Block ${block.height} processed`);
}
});
sync.on('outputFound', (output) => {
outputsFound++;
if (output.isCarrot) {
carrotOutputs++;
console.log(` 🥕 CARROT output: ${output.amount} atomic units at height ${output.blockHeight}`);
} else {
cnOutputs++;
console.log(` 📜 CN output: ${output.amount} atomic units at height ${output.blockHeight}`);
}
});
// Track batch size adaptation
let lastBatchSize = null;
sync.on('batchComplete', (data) => {
// Only log when batch size changes
if (lastBatchSize !== data.batchSize) {
const direction = lastBatchSize === null ? 'initial' : (data.batchSize > lastBatchSize ? '↑' : '↓');
console.log(` [Batch] ${direction} size=${data.batchSize} (${data.elapsed}ms, ${data.blocksPerSec.toFixed(1)} blk/s)`);
lastBatchSize = data.batchSize;
}
});
// Debug: sample a few blocks to see transaction structure
let debugSampleCount = 0;
const MAX_DEBUG_SAMPLES = 3;
sync.on('debugTx', (data) => {
if (debugSampleCount < MAX_DEBUG_SAMPLES) {
debugSampleCount++;
console.log(`\n--- DEBUG TX #${debugSampleCount} (height ${data.height}) ---`);
console.log(`TX Hash: ${data.txHash}`);
console.log(`TX PubKey: ${data.txPubKey || 'null'}`);
console.log(`Output count: ${data.outputCount}`);
if (data.firstOutputKey) {
console.log(`First output key: ${data.firstOutputKey}`);
}
if (data.derivation) {
console.log(`Derivation: ${data.derivation}`);
}
if (data.expectedPubKey) {
console.log(`Expected pubkey[0]: ${data.expectedPubKey}`);
}
console.log(`---\n`);
}
});
sync.on('syncComplete', (data) => {
console.log(`Sync completed at height ${data.height}`);
});
sync.on('syncError', (error) => {
console.error(`Sync error: ${error.message}`);
});
// Run sync
console.log('Starting sync...\n');
const startTime = Date.now();
try {
await sync.start(startHeight);
} catch (error) {
console.error('Sync failed:', error.message);
}
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
// Get results
const outputs = await storage.getOutputs();
const transactions = await storage.getTransactions();
const syncHeight = await storage.getSyncHeight();
// Calculate balance with locked/unlocked split
// Salvium coinbase outputs have unlock_time = 60 (the bare constant CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW),
// NOT height + 60. The C++ wallet applies the 60-block confirmation window for coinbase regardless.
const CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW = 60;
const DEFAULT_UNLOCK_BLOCKS = 10;
let balance = 0n;
let unlockedBalance = 0n;
let unspentCount = 0;
let unlockedCount = 0;
for (const output of outputs) {
if (!output.isSpent) {
const amount = typeof output.amount === 'bigint' ? output.amount : BigInt(output.amount);
balance += amount;
unspentCount++;
// Check unlock status
const unlockTime = BigInt(output.unlockTime || 0);
const isCoinbase = output.txType === 'miner' || output.txType === 'protocol';
let isUnlocked = false;
if (isCoinbase) {
// Coinbase outputs always require CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW confirmations
isUnlocked = output.blockHeight != null &&
(syncHeight - output.blockHeight) >= CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW;
} else if (unlockTime === 0n) {
// Standard: unlocked after DEFAULT_UNLOCK_BLOCKS confirmations
isUnlocked = output.blockHeight != null &&
(syncHeight - output.blockHeight) >= DEFAULT_UNLOCK_BLOCKS;
} else if (unlockTime < 500000000n) {
// Unlock time is a block height
isUnlocked = syncHeight >= Number(unlockTime);
} else {
// Unlock time is a Unix timestamp
isUnlocked = Date.now() / 1000 >= Number(unlockTime);
}
if (isUnlocked) {
unlockedBalance += amount;
unlockedCount++;
}
}
}
const lockedBalance = balance - unlockedBalance;
// Report results
console.log('\n' + '='.repeat(60));
console.log('SYNC RESULTS');
console.log('='.repeat(60));
console.log(`Time elapsed: ${elapsed}s`);
console.log(`Blocks synced: ${syncHeight - startHeight}`);
console.log(`Blocks with txs: ${blocksWithTxs}`);
console.log(`Final sync height: ${syncHeight}`);
console.log('');
console.log(`Outputs found: ${outputs.length}`);
console.log(`Unspent outputs: ${unspentCount} (${unlockedCount} unlocked, ${unspentCount - unlockedCount} locked)`);
console.log(`Transactions: ${transactions.length}`);
console.log('');
console.log(`Total balance: ${balance} atomic (${(Number(balance) / 1e8).toFixed(8)} SAL)`);
console.log(`Unlocked balance: ${unlockedBalance} atomic (${(Number(unlockedBalance) / 1e8).toFixed(8)} SAL)`);
console.log(`Locked balance: ${lockedBalance} atomic (${(Number(lockedBalance) / 1e8).toFixed(8)} SAL)`);
// === DIAGNOSTIC: null keyImage outputs ===
const nullKiOutput = await storage.getOutput(null);
const nullKiUndefined = await storage.getOutput(undefined);
console.log(`\n--- KEY IMAGE DIAGNOSTIC ---`);
console.log(`Output stored under null key: ${nullKiOutput ? `YES h=${nullKiOutput.blockHeight} amt=${(Number(nullKiOutput.amount)/1e8).toFixed(8)}` : 'none'}`);
console.log(`Output stored under undefined key: ${nullKiUndefined ? `YES h=${nullKiUndefined.blockHeight} amt=${(Number(nullKiUndefined.amount)/1e8).toFixed(8)}` : 'none'}`);
let nullKiCount = 0;
let nullKiUnspentTotal = 0n;
for (const o of outputs) {
if (!o.keyImage) {
nullKiCount++;
if (!o.isSpent) {
nullKiUnspentTotal += BigInt(o.amount);
console.log(` null-ki UNSPENT: h=${o.blockHeight} amt=${(Number(o.amount)/1e8).toFixed(8)} carrot=${o.isCarrot} type=${o.txType} tx=${o.txHash?.slice(0,16)}`);
}
}
}
console.log(`Total outputs with null keyImage: ${nullKiCount}`);
console.log(`Unspent balance from null-ki outputs: ${(Number(nullKiUnspentTotal)/1e8).toFixed(8)} SAL`);
// === DIAGNOSTIC: all unspent outputs ===
const unspentOutputs = outputs.filter(o => !o.isSpent);
console.log(`\n--- ALL UNSPENT OUTPUTS (${unspentOutputs.length}) ---`);
// Sort by height
unspentOutputs.sort((a, b) => (a.blockHeight || 0) - (b.blockHeight || 0));
for (const o of unspentOutputs) {
const ki = o.keyImage ? o.keyImage.slice(0,16)+'...' : 'NULL';
console.log(` h=${o.blockHeight} amt=${(Number(o.amount)/1e8).toFixed(8)} ki=${ki} carrot=${o.isCarrot} type=${o.txType} sub=${o.subaddressIndex?.major},${o.subaddressIndex?.minor}`);
}
// === DIAGNOSTIC: check outgoing TX key images ===
console.log(`\n--- OUTGOING TX KEY IMAGE CHECK ---`);
const outgoingTxHashes = [
'1496ec38a7a73b2829de0a09caff9b15ba11325fe6e7af29cc65f9201902a482', // 12k send
'c550084f2be972803a4d3f885eee5f84f8e781e3d8b6bc2acd073926e8e4407f', // consolidation
'e5c9b8104747b07113726e720dfb02c1867986bf54fda925eb2bef1bc9bbe9b2', // consolidation
'1563a8c75ba3a002fb34574155aa8a51cc3643da01e879fac24a2b710017634c', // STAKE
];
for (const txHash of outgoingTxHashes) {
const tx = transactions.find(t => t.txHash === txHash);
if (tx) {
console.log(` TX ${txHash.slice(0,16)}: found in wallet, incoming=${tx.isIncoming} outgoing=${tx.isOutgoing} in=${(Number(tx.incomingAmount)/1e8).toFixed(4)} out=${(Number(tx.outgoingAmount)/1e8).toFixed(4)}`);
} else {
console.log(` TX ${txHash.slice(0,16)}: NOT found in wallet transactions`);
}
}
// List outputs
if (outputs.length > 0) {
console.log('\n--- Outputs ---');
for (const output of outputs.slice(0, 10)) {
const status = output.isSpent ? 'SPENT' : 'UNSPENT';
console.log(` Height ${output.blockHeight}: ${output.amount} (${status})`);
}
if (outputs.length > 10) {
console.log(` ... and ${outputs.length - 10} more`);
}
}
// Stake/Yield Analysis
const STAKE_LOCK_PERIOD = 21600; // 30*24*30 blocks on mainnet
// Find stake outputs (outputs with unlock_time = height + ~21600)
const stakeOutputs = [];
for (const output of outputs) {
const unlockTime = Number(output.unlockTime || 0);
if (unlockTime > 0) {
const lockDuration = unlockTime - output.blockHeight;
if (Math.abs(lockDuration - STAKE_LOCK_PERIOD) <= 100) {
stakeOutputs.push({
...output,
unlockHeight: unlockTime
});
}
}
}
// Find protocol tx outputs (yields/returns)
const protocolOutputs = outputs.filter(o => {
const tx = transactions.find(t => t.txHash === o.txHash);
return tx && (tx.isProtocolTx || tx.txType === 'protocol');
});
// Group outputs by height for matching
const outputsByHeight = new Map();
for (const output of outputs) {
if (!outputsByHeight.has(output.blockHeight)) {
outputsByHeight.set(output.blockHeight, []);
}
outputsByHeight.get(output.blockHeight).push(output);
}
if (stakeOutputs.length > 0 || protocolOutputs.length > 0) {
console.log('\n--- Stake/Yield Analysis ---');
console.log(`Stakes found: ${stakeOutputs.length}`);
console.log(`Protocol outputs: ${protocolOutputs.length}`);
let totalStaked = 0n;
let totalYield = 0n;
for (let i = 0; i < stakeOutputs.length; i++) {
const stake = stakeOutputs[i];
const stakeAmt = Number(stake.amount) / 1e8;
// Find return at unlock height
let returnOutput = null;
for (let h = stake.unlockHeight; h <= stake.unlockHeight + 5; h++) {
const candidates = outputsByHeight.get(h) || [];
for (const c of candidates) {
const tx = transactions.find(t => t.txHash === c.txHash);
if (tx && (tx.isProtocolTx || tx.txType === 'protocol') && c.amount >= stake.amount) {
returnOutput = c;
break;
}
}
if (returnOutput) break;
}
totalStaked += stake.amount;
const yieldAmt = returnOutput ? returnOutput.amount - stake.amount : 0n;
totalYield += yieldAmt;
const returnInfo = returnOutput
? `Return: ${(Number(returnOutput.amount) / 1e8).toFixed(4)} SAL, Yield: ${(Number(yieldAmt) / 1e8).toFixed(4)} SAL`
: 'No return found';
console.log(` Stake #${i + 1}: ${stakeAmt.toFixed(4)} SAL @ block ${stake.blockHeight} -> unlock ${stake.unlockHeight} | ${returnInfo}`);
}
if (stakeOutputs.length > 0) {
console.log(`\nTotal staked: ${(Number(totalStaked) / 1e8).toFixed(8)} SAL`);
console.log(`Total yield: ${(Number(totalYield) / 1e8).toFixed(8)} SAL`);
}
}
// Verify expected balance
if (EXPECTED_BALANCE !== null) {
console.log('\n--- Verification ---');
if (balance >= EXPECTED_BALANCE) {
console.log(`✓ Balance ${balance} >= expected ${EXPECTED_BALANCE}`);
} else {
console.log(`✗ Balance ${balance} < expected ${EXPECTED_BALANCE}`);
process.exit(1);
}
}
// Cleanup
await storage.close();
console.log('\n✓ Integration test completed successfully!');
}
// Run
runIntegrationTest().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});