#!/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) */ 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'; // ============================================================================ // 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'); // 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 }; } 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 let balance = 0n; let unspentCount = 0; for (const output of outputs) { if (!output.isSpent) { balance += output.amount; unspentCount++; } } // 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}`); console.log(`Transactions: ${transactions.length}`); console.log(`Balance: ${balance} atomic units`); console.log(`Balance (SAL): ${Number(balance) / 1e8} SAL`); // 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); });