diff --git a/test/all.js b/test/all.js index 470c561..418ba62 100644 --- a/test/all.js +++ b/test/all.js @@ -40,6 +40,13 @@ const tests = [ { name: 'Transaction Builder Tests', file: 'transaction-builder.test.js' }, { name: 'Wallet Class Tests', file: 'wallet-class.test.js' }, { name: 'Transaction Parser Tests', file: 'transaction-parser.test.js' }, + { name: 'Wallet Storage Tests', file: 'wallet-store.test.js' }, + { name: 'Wallet Sync Tests', file: 'wallet-sync.test.js' }, + { name: 'Query System Tests', file: 'query.test.js' }, + { name: 'Connection Manager Tests', file: 'connection-manager.test.js' }, + { name: 'Offline Signing Tests', file: 'offline.test.js' }, + { name: 'Multisig Tests', file: 'multisig.test.js' }, + { name: 'Persistent Wallet Tests', file: 'persistent-wallet.test.js' }, ]; if (runIntegration) { diff --git a/test/connection-manager.test.js b/test/connection-manager.test.js new file mode 100644 index 0000000..a5c0a39 --- /dev/null +++ b/test/connection-manager.test.js @@ -0,0 +1,419 @@ +#!/usr/bin/env bun +/** + * Connection Manager Tests + * + * Tests for connection-manager.js: + * - ConnectionInfo class + * - ConnectionManager class + * - Connection state tracking + */ + +import { + ConnectionManager, + ConnectionInfo, + CONNECTION_STATE, + createDaemonConnectionManager, + createWalletConnectionManager +} from '../src/connection-manager.js'; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (e) { + console.log(` ✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } +} + +async function testAsync(name, fn) { + try { + await fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (e) { + console.log(` ✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message || 'Assertion failed'); +} + +function assertEqual(actual, expected, message) { + if (actual !== expected) { + throw new Error(message || `Expected ${expected}, got ${actual}`); + } +} + +console.log('=== Connection Manager Tests ===\n'); + +// ============================================================================ +// Constants Tests +// ============================================================================ + +console.log('--- Constants ---'); + +test('CONNECTION_STATE has correct values', () => { + assertEqual(CONNECTION_STATE.DISCONNECTED, 'disconnected'); + assertEqual(CONNECTION_STATE.CONNECTING, 'connecting'); + assertEqual(CONNECTION_STATE.CONNECTED, 'connected'); + assertEqual(CONNECTION_STATE.FAILED, 'failed'); +}); + +// ============================================================================ +// ConnectionInfo Tests +// ============================================================================ + +console.log('\n--- ConnectionInfo ---'); + +test('creates with uri', () => { + const info = new ConnectionInfo({ uri: 'http://localhost:19081' }); + assertEqual(info.uri, 'http://localhost:19081'); + assertEqual(info.state, CONNECTION_STATE.DISCONNECTED); +}); + +test('creates with url (alias for uri)', () => { + const info = new ConnectionInfo({ url: 'http://localhost:19081' }); + assertEqual(info.uri, 'http://localhost:19081'); +}); + +test('creates with all options', () => { + const info = new ConnectionInfo({ + uri: 'http://seed01.salvium.io:19081', + username: 'user', + password: 'pass', + priority: 5, + timeout: 60000, + retries: 5, + retryDelay: 2000 + }); + + assertEqual(info.uri, 'http://seed01.salvium.io:19081'); + assertEqual(info.username, 'user'); + assertEqual(info.password, 'pass'); + assertEqual(info.priority, 5); + assertEqual(info.timeout, 60000); + assertEqual(info.retries, 5); + assertEqual(info.retryDelay, 2000); +}); + +test('defaults priority to 1', () => { + const info = new ConnectionInfo({ uri: 'http://localhost:19081' }); + assertEqual(info.priority, 1); +}); + +test('defaults timeout to 30000', () => { + const info = new ConnectionInfo({ uri: 'http://localhost:19081' }); + assertEqual(info.timeout, 30000); +}); + +test('defaults retries to 3', () => { + const info = new ConnectionInfo({ uri: 'http://localhost:19081' }); + assertEqual(info.retries, 3); +}); + +test('markFailed updates state and failCount', () => { + const info = new ConnectionInfo({ uri: 'http://localhost:19081' }); + assertEqual(info.failCount, 0); + assertEqual(info.state, CONNECTION_STATE.DISCONNECTED); + + info.markFailed(new Error('Connection refused')); + + assertEqual(info.state, CONNECTION_STATE.FAILED); + assertEqual(info.failCount, 1); + assert(info.lastError !== null); +}); + +test('markFailed increments failCount', () => { + const info = new ConnectionInfo({ uri: 'http://localhost:19081' }); + + info.markFailed(new Error('Error 1')); + info.markFailed(new Error('Error 2')); + info.markFailed(new Error('Error 3')); + + assertEqual(info.failCount, 3); +}); + +test('markSuccess updates state and resets failCount', () => { + const info = new ConnectionInfo({ uri: 'http://localhost:19081' }); + info.markFailed(new Error('test')); + assertEqual(info.failCount, 1); + + info.markSuccess(100); + + assertEqual(info.state, CONNECTION_STATE.CONNECTED); + assertEqual(info.failCount, 0); + assertEqual(info.lastError, null); +}); + +test('markSuccess tracks response time', () => { + const info = new ConnectionInfo({ uri: 'http://localhost:19081' }); + + info.markSuccess(100); + assertEqual(info.responseTime, 100); + + // Should use exponential moving average + info.markSuccess(200); + assert(info.responseTime > 100 && info.responseTime < 200); +}); + +test('reset clears failure state', () => { + const info = new ConnectionInfo({ uri: 'http://localhost:19081' }); + info.markFailed(new Error('test')); + info.markFailed(new Error('test2')); + + info.reset(); + + assertEqual(info.state, CONNECTION_STATE.DISCONNECTED); + assertEqual(info.failCount, 0); +}); + +test('toRpcOptions returns correct object', () => { + const info = new ConnectionInfo({ + uri: 'http://localhost:19081', + username: 'user', + password: 'pass', + timeout: 5000 + }); + + const opts = info.toRpcOptions(); + + assertEqual(opts.url, 'http://localhost:19081'); + assertEqual(opts.username, 'user'); + assertEqual(opts.password, 'pass'); + assertEqual(opts.timeout, 5000); +}); + +// ============================================================================ +// ConnectionManager Tests +// ============================================================================ + +console.log('\n--- ConnectionManager ---'); + +test('creates with array of connection configs', () => { + const manager = new ConnectionManager({ + connections: [ + { uri: 'http://seed01.salvium.io:19081' }, + { uri: 'http://seed02.salvium.io:19081' }, + { uri: 'http://seed03.salvium.io:19081' } + ] + }); + + assertEqual(manager.connections.length, 3); +}); + +test('creates with ConnectionInfo objects', () => { + const manager = new ConnectionManager({ + connections: [ + new ConnectionInfo({ uri: 'http://seed01.salvium.io:19081', priority: 1 }), + new ConnectionInfo({ uri: 'http://seed02.salvium.io:19081', priority: 2 }) + ] + }); + + assertEqual(manager.connections.length, 2); +}); + +test('sorts connections by priority (lower first)', () => { + const manager = new ConnectionManager({ + connections: [ + { uri: 'http://low:19081', priority: 10 }, + { uri: 'http://high:19081', priority: 1 }, + { uri: 'http://med:19081', priority: 5 } + ] + }); + + // Lower priority = higher priority (sorted first) + assertEqual(manager.connections[0].uri, 'http://high:19081'); + assertEqual(manager.connections[1].uri, 'http://med:19081'); + assertEqual(manager.connections[2].uri, 'http://low:19081'); +}); + +test('addConnection adds new connection', () => { + const manager = new ConnectionManager({ + connections: [{ uri: 'http://seed01.salvium.io:19081' }] + }); + + manager.addConnection({ uri: 'http://seed02.salvium.io:19081' }); + + assertEqual(manager.connections.length, 2); +}); + +test('removeConnection removes by URI', () => { + const manager = new ConnectionManager({ + connections: [ + { uri: 'http://seed01.salvium.io:19081' }, + { uri: 'http://seed02.salvium.io:19081' }, + { uri: 'http://seed03.salvium.io:19081' } + ] + }); + + manager.removeConnection('http://seed02.salvium.io:19081'); + + assertEqual(manager.connections.length, 2); + assert(!manager.connections.some(c => c.uri === 'http://seed02.salvium.io:19081')); +}); + +test('getConnections returns all connections', () => { + const manager = new ConnectionManager({ + connections: [ + { uri: 'http://seed01.salvium.io:19081' }, + { uri: 'http://seed02.salvium.io:19081' } + ] + }); + + const connections = manager.getConnections(); + assertEqual(connections.length, 2); +}); + +test('getCurrentConnection returns first connection initially', () => { + const manager = new ConnectionManager({ + connections: [ + { uri: 'http://seed01.salvium.io:19081', priority: 1 }, + { uri: 'http://seed02.salvium.io:19081', priority: 2 } + ] + }); + + const current = manager.getCurrentConnection(); + assertEqual(current.uri, 'http://seed01.salvium.io:19081'); +}); + +test('switchTo changes active connection', () => { + const manager = new ConnectionManager({ + connections: [ + { uri: 'http://seed01.salvium.io:19081' }, + { uri: 'http://seed02.salvium.io:19081' } + ] + }); + + manager.switchTo('http://seed02.salvium.io:19081'); + const current = manager.getCurrentConnection(); + + assertEqual(current.uri, 'http://seed02.salvium.io:19081'); +}); + +test('getBestConnection returns connected connection', () => { + const manager = new ConnectionManager({ + connections: [ + { uri: 'http://seed01.salvium.io:19081' }, + { uri: 'http://seed02.salvium.io:19081' } + ] + }); + + // Mark first as failed, second as connected + manager.connections[0].markFailed(new Error('test')); + manager.connections[1].markSuccess(100); + + const best = manager.getBestConnection(); + assertEqual(best.uri, 'http://seed02.salvium.io:19081'); +}); + +// ============================================================================ +// Event System Tests +// ============================================================================ + +console.log('\n--- Events ---'); + +test('on adds listener', () => { + const manager = new ConnectionManager({ + connections: [{ uri: 'http://localhost:19081' }] + }); + let called = false; + + manager.on('test', () => { called = true; }); + manager._emit('test'); + + assert(called); +}); + +test('off removes listener', () => { + const manager = new ConnectionManager({ + connections: [{ uri: 'http://localhost:19081' }] + }); + let count = 0; + const handler = () => { count++; }; + + manager.on('test', handler); + manager._emit('test'); + assertEqual(count, 1); + + manager.off('test', handler); + manager._emit('test'); + assertEqual(count, 1); +}); + +// ============================================================================ +// Factory Function Tests +// ============================================================================ + +console.log('\n--- Factory Functions ---'); + +test('createDaemonConnectionManager creates manager for daemon', () => { + // First arg is connections array, second is options + const manager = createDaemonConnectionManager([ + { uri: 'http://localhost:19081' } + ]); + + assert(manager instanceof ConnectionManager); + assertEqual(manager.proxyType, 'daemon'); +}); + +test('createWalletConnectionManager creates manager for wallet', () => { + // First arg is connections array, second is options + const manager = createWalletConnectionManager([ + { uri: 'http://localhost:19083' } + ]); + + assert(manager instanceof ConnectionManager); + assertEqual(manager.proxyType, 'wallet'); +}); + +// ============================================================================ +// Configuration Tests +// ============================================================================ + +console.log('\n--- Configuration ---'); + +test('autoSwitch defaults to true', () => { + const manager = new ConnectionManager({ + connections: [{ uri: 'http://localhost:19081' }] + }); + assertEqual(manager.autoSwitch, true); +}); + +test('checkPeriod defaults to 30000', () => { + const manager = new ConnectionManager({ + connections: [{ uri: 'http://localhost:19081' }] + }); + assertEqual(manager.checkPeriod, 30000); +}); + +test('proxyType defaults to daemon', () => { + const manager = new ConnectionManager({ + connections: [{ uri: 'http://localhost:19081' }] + }); + assertEqual(manager.proxyType, 'daemon'); +}); + +// ============================================================================ +// Summary +// ============================================================================ + +console.log('\n--- Summary ---'); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); + +if (failed > 0) { + console.log('\n✗ Some tests failed!'); + process.exit(1); +} else { + console.log('\n✓ All connection manager tests passed!'); + process.exit(0); +} diff --git a/test/multisig.test.js b/test/multisig.test.js new file mode 100644 index 0000000..6c95194 --- /dev/null +++ b/test/multisig.test.js @@ -0,0 +1,606 @@ +#!/usr/bin/env bun +/** + * Multisig Tests + * + * Tests for multisig.js: + * - Constants + * - KEX rounds calculation + * - KexMessage serialization + * - MultisigSigner + * - MultisigAccount + * - MultisigTxSet + * - MultisigPartialSig + * - MultisigWallet + * - Helper functions + */ + +import { + MULTISIG_MAX_SIGNERS, + MULTISIG_MIN_THRESHOLD, + MULTISIG_NONCE_COMPONENTS, + MULTISIG_MSG_TYPE, + kexRoundsRequired, + getMultisigBlindedSecretKey, + computeDHSecret, + generateMultisigNonces, + KexMessage, + MultisigSigner, + MultisigAccount, + MultisigTxSet, + MultisigPartialSig, + MultisigWallet, + createMultisigWallet, + isMultisig +} from '../src/multisig.js'; + +import { bytesToHex } from '../src/address.js'; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (e) { + console.log(` ✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message || 'Assertion failed'); +} + +function assertEqual(actual, expected, message) { + if (actual !== expected) { + throw new Error(message || `Expected ${expected}, got ${actual}`); + } +} + +// Helper to create test keys +function createTestKeys() { + return { + spendSecretKey: new Uint8Array(32).fill(0x11), + viewSecretKey: new Uint8Array(32).fill(0x22) + }; +} + +console.log('=== Multisig Tests ===\n'); + +// ============================================================================ +// Constants Tests +// ============================================================================ + +console.log('--- Constants ---'); + +test('MULTISIG_MAX_SIGNERS is 16', () => { + assertEqual(MULTISIG_MAX_SIGNERS, 16); +}); + +test('MULTISIG_MIN_THRESHOLD is 2', () => { + assertEqual(MULTISIG_MIN_THRESHOLD, 2); +}); + +test('MULTISIG_NONCE_COMPONENTS is 2', () => { + assertEqual(MULTISIG_NONCE_COMPONENTS, 2); +}); + +test('MULTISIG_MSG_TYPE has correct values', () => { + assertEqual(MULTISIG_MSG_TYPE.KEX_INIT, 'kex_init'); + assertEqual(MULTISIG_MSG_TYPE.KEX_ROUND, 'kex_round'); + assertEqual(MULTISIG_MSG_TYPE.KEX_VERIFY, 'kex_verify'); + assertEqual(MULTISIG_MSG_TYPE.TX_SET, 'tx_set'); + assertEqual(MULTISIG_MSG_TYPE.PARTIAL_SIG, 'partial_sig'); + assertEqual(MULTISIG_MSG_TYPE.FINAL_TX, 'final_tx'); +}); + +// ============================================================================ +// KEX Rounds Required Tests +// ============================================================================ + +console.log('\n--- KEX Rounds Required ---'); + +test('kexRoundsRequired returns N for any M-of-N', () => { + // According to implementation, kexRoundsRequired returns signers count + assertEqual(kexRoundsRequired(2, 2), 2); + assertEqual(kexRoundsRequired(2, 3), 3); + assertEqual(kexRoundsRequired(3, 3), 3); + assertEqual(kexRoundsRequired(2, 4), 4); + assertEqual(kexRoundsRequired(3, 4), 4); + assertEqual(kexRoundsRequired(4, 4), 4); +}); + +test('kexRoundsRequired handles edge cases', () => { + // 5-of-5 + assertEqual(kexRoundsRequired(5, 5), 5); + // 2-of-10 + assertEqual(kexRoundsRequired(2, 10), 10); +}); + +// ============================================================================ +// KexMessage Tests +// ============================================================================ + +console.log('\n--- KexMessage ---'); + +test('creates KexMessage with default values', () => { + const msg = new KexMessage(); + + assertEqual(msg.round, 0); + assertEqual(msg.signerIndex, 0); + assertEqual(msg.publicKey, null); + assertEqual(msg.commonPubkey, null); + assertEqual(msg.dhPubkeys.length, 0); + assertEqual(msg.signature, null); +}); + +test('KexMessage serialize/deserialize round-trips', () => { + const msg = new KexMessage(); + msg.round = 1; + msg.signerIndex = 0; + msg.publicKey = new Uint8Array(32).fill(0xaa); + msg.commonPubkey = new Uint8Array(32).fill(0xbb); + msg.dhPubkeys = [ + new Uint8Array(32).fill(0xcc), + new Uint8Array(32).fill(0xdd) + ]; + + const serialized = msg.serialize(); + const restored = KexMessage.deserialize(serialized); + + assertEqual(restored.round, msg.round); + assertEqual(restored.signerIndex, msg.signerIndex); + assertEqual(bytesToHex(restored.publicKey), bytesToHex(msg.publicKey)); + assertEqual(bytesToHex(restored.commonPubkey), bytesToHex(msg.commonPubkey)); + assertEqual(restored.dhPubkeys.length, 2); +}); + +test('KexMessage toString/fromString round-trips', () => { + const msg = new KexMessage(); + msg.round = 2; + msg.signerIndex = 1; + msg.publicKey = new Uint8Array(32).fill(0x11); + msg.commonPubkey = new Uint8Array(32).fill(0x22); + + const str = msg.toString(); + const restored = KexMessage.fromString(str); + + assertEqual(restored.round, msg.round); + assertEqual(restored.signerIndex, msg.signerIndex); +}); + +// ============================================================================ +// MultisigSigner Tests +// ============================================================================ + +console.log('\n--- MultisigSigner ---'); + +test('creates MultisigSigner with defaults', () => { + const signer = new MultisigSigner(); + + assertEqual(signer.index, 0); + assertEqual(signer.publicSpendKey, null); + assertEqual(signer.publicViewKey, null); + assertEqual(signer.label, ''); +}); + +test('creates MultisigSigner with config', () => { + const signer = new MultisigSigner({ + index: 1, + publicSpendKey: new Uint8Array(32).fill(0xaa), + publicViewKey: new Uint8Array(32).fill(0xbb), + label: 'Signer 1' + }); + + assertEqual(signer.index, 1); + assert(signer.publicSpendKey instanceof Uint8Array); + assert(signer.publicViewKey instanceof Uint8Array); + assertEqual(signer.label, 'Signer 1'); +}); + +// ============================================================================ +// MultisigAccount Tests +// ============================================================================ + +console.log('\n--- MultisigAccount ---'); + +test('creates MultisigAccount with threshold and signerCount', () => { + const keys = createTestKeys(); + const account = new MultisigAccount({ + threshold: 2, + signerCount: 3, + spendSecretKey: keys.spendSecretKey, + viewSecretKey: keys.viewSecretKey + }); + + assertEqual(account.threshold, 2); + assertEqual(account.signerCount, 3); + assertEqual(account.kexRound, 0); + assertEqual(account.kexComplete, false); +}); + +test('MultisigAccount validates threshold minimum', () => { + const keys = createTestKeys(); + let threw = false; + try { + new MultisigAccount({ + threshold: 1, // Below minimum + signerCount: 2, + spendSecretKey: keys.spendSecretKey, + viewSecretKey: keys.viewSecretKey + }); + } catch (e) { + threw = true; + assert(e.message.includes('threshold') || e.message.includes('2')); + } + assert(threw, 'Should throw for threshold < 2'); +}); + +test('MultisigAccount validates threshold <= signerCount', () => { + const keys = createTestKeys(); + let threw = false; + try { + new MultisigAccount({ + threshold: 5, + signerCount: 3, // Less than threshold + spendSecretKey: keys.spendSecretKey, + viewSecretKey: keys.viewSecretKey + }); + } catch (e) { + threw = true; + assert(e.message.includes('Threshold') || e.message.includes('exceed')); + } + assert(threw, 'Should throw for threshold > signerCount'); +}); + +test('MultisigAccount validates max signers', () => { + const keys = createTestKeys(); + let threw = false; + try { + new MultisigAccount({ + threshold: 2, + signerCount: 20, // Exceeds max + spendSecretKey: keys.spendSecretKey, + viewSecretKey: keys.viewSecretKey + }); + } catch (e) { + threw = true; + assert(e.message.includes('16') || e.message.includes('max')); + } + assert(threw, 'Should throw for signerCount > max'); +}); + +test('MultisigAccount initializeKex requires keys', () => { + const account = new MultisigAccount({ + threshold: 2, + signerCount: 2 + }); + + let threw = false; + try { + account.initializeKex(); + } catch (e) { + threw = true; + assert(e.message.includes('key') || e.message.includes('Base')); + } + assert(threw, 'Should throw without keys'); +}); + +test('MultisigAccount initializeKex returns KexMessage', () => { + const keys = createTestKeys(); + const account = new MultisigAccount({ + threshold: 2, + signerCount: 2, + spendSecretKey: keys.spendSecretKey, + viewSecretKey: keys.viewSecretKey + }); + + const msg = account.initializeKex(); + + assert(msg instanceof KexMessage, 'Should return KexMessage'); + assertEqual(msg.round, 1); + assert(msg.publicKey !== null); + assert(msg.commonPubkey !== null); + assertEqual(account.kexRound, 1); +}); + +test('MultisigAccount isKexComplete returns false initially', () => { + const keys = createTestKeys(); + const account = new MultisigAccount({ + threshold: 2, + signerCount: 2, + spendSecretKey: keys.spendSecretKey, + viewSecretKey: keys.viewSecretKey + }); + + assertEqual(account.isKexComplete(), false); +}); + +// ============================================================================ +// MultisigTxSet Tests +// ============================================================================ + +console.log('\n--- MultisigTxSet ---'); + +test('creates empty MultisigTxSet', () => { + const txSet = new MultisigTxSet(); + + assertEqual(txSet.txs.length, 0); + assertEqual(txSet.signingAttempts.length, 0); + assertEqual(txSet.keyImages.length, 0); +}); + +test('addTransaction adds to txs array', () => { + const txSet = new MultisigTxSet(); + + txSet.addTransaction({ inputs: [], outputs: [], fee: 1000n }); + txSet.addTransaction({ inputs: [], outputs: [], fee: 2000n }); + + assertEqual(txSet.txs.length, 2); +}); + +test('MultisigTxSet serialize/deserialize round-trips', () => { + const txSet = new MultisigTxSet(); + txSet.addTransaction({ + inputs: [{ amount: 1000 }], + outputs: [{ amount: 900 }] + }); + txSet.keyImages.push(new Uint8Array(32).fill(0xaa)); + + const serialized = txSet.serialize(); + const restored = MultisigTxSet.deserialize(serialized); + + assertEqual(restored.txs.length, 1); + assertEqual(restored.keyImages.length, 1); +}); + +test('MultisigTxSet toString/fromString round-trips', () => { + const txSet = new MultisigTxSet(); + txSet.addTransaction({ value: 123 }); + + const str = txSet.toString(); + const restored = MultisigTxSet.fromString(str); + + assertEqual(restored.txs.length, 1); + assertEqual(restored.txs[0].value, 123); +}); + +// ============================================================================ +// MultisigPartialSig Tests +// ============================================================================ + +console.log('\n--- MultisigPartialSig ---'); + +test('creates MultisigPartialSig with defaults', () => { + const sig = new MultisigPartialSig(); + + assertEqual(sig.signerIndex, 0); + assertEqual(sig.txIndex, 0); + assertEqual(sig.responses.length, 0); + assertEqual(sig.pubNonces.length, 0); +}); + +test('MultisigPartialSig serialize/deserialize round-trips', () => { + const sig = new MultisigPartialSig(); + sig.signerIndex = 1; + sig.txIndex = 0; + sig.responses = [new Uint8Array(32).fill(0x11)]; + sig.pubNonces = [[new Uint8Array(32).fill(0x22), new Uint8Array(32).fill(0x33)]]; + + const serialized = sig.serialize(); + const restored = MultisigPartialSig.deserialize(serialized); + + assertEqual(restored.signerIndex, 1); + assertEqual(restored.txIndex, 0); + assertEqual(restored.responses.length, 1); + assertEqual(restored.pubNonces.length, 1); +}); + +test('MultisigPartialSig toString/fromString round-trips', () => { + const sig = new MultisigPartialSig(); + sig.signerIndex = 2; + sig.txIndex = 1; + + const str = sig.toString(); + const restored = MultisigPartialSig.fromString(str); + + assertEqual(restored.signerIndex, 2); + assertEqual(restored.txIndex, 1); +}); + +// ============================================================================ +// Helper Functions Tests +// ============================================================================ + +console.log('\n--- Helper Functions ---'); + +test('getMultisigBlindedSecretKey returns 32-byte key', () => { + const secretKey = new Uint8Array(32).fill(0xab); + const blinded = getMultisigBlindedSecretKey(secretKey); + + assertEqual(blinded.length, 32); + assert(blinded instanceof Uint8Array); +}); + +test('getMultisigBlindedSecretKey is deterministic', () => { + const secretKey = new Uint8Array(32).fill(0xcd); + const blinded1 = getMultisigBlindedSecretKey(secretKey); + const blinded2 = getMultisigBlindedSecretKey(secretKey); + + assertEqual(bytesToHex(blinded1), bytesToHex(blinded2)); +}); + +test('getMultisigBlindedSecretKey differs for different keys', () => { + const key1 = new Uint8Array(32).fill(0x11); + const key2 = new Uint8Array(32).fill(0x22); + + const blinded1 = getMultisigBlindedSecretKey(key1); + const blinded2 = getMultisigBlindedSecretKey(key2); + + assert(bytesToHex(blinded1) !== bytesToHex(blinded2)); +}); + +test('computeDHSecret accepts key parameters', () => { + // Note: computeDHSecret may return null for invalid curve points + // Test that function accepts parameters without throwing + const secretKey = new Uint8Array(32).fill(0x01); + const publicKey = new Uint8Array(32).fill(0x02); + + // This may return null for arbitrary bytes that aren't valid points + // Just verify it doesn't throw + const result = computeDHSecret(secretKey, publicKey); + + // Result can be null for invalid points or Uint8Array for valid + assert(result === null || result instanceof Uint8Array, + 'Should return null or Uint8Array'); +}); + +test('generateMultisigNonces creates correct number', () => { + const nonces = generateMultisigNonces(3); + + assertEqual(nonces.length, 3); + // Each nonce should be a pair [alpha1, alpha2] + assertEqual(nonces[0].length, 2); + assertEqual(nonces[1].length, 2); + assertEqual(nonces[2].length, 2); +}); + +test('generateMultisigNonces creates unique values', () => { + const nonces = generateMultisigNonces(2); + + const hex1 = bytesToHex(nonces[0][0]); + const hex2 = bytesToHex(nonces[0][1]); + const hex3 = bytesToHex(nonces[1][0]); + const hex4 = bytesToHex(nonces[1][1]); + + // All should be different + const unique = new Set([hex1, hex2, hex3, hex4]); + assertEqual(unique.size, 4); +}); + +// ============================================================================ +// isMultisig Tests +// ============================================================================ + +console.log('\n--- isMultisig ---'); + +test('isMultisig returns false for regular object', () => { + assertEqual(isMultisig({}), false); + assertEqual(isMultisig(null), false); + assertEqual(isMultisig({ balance: 100 }), false); +}); + +test('isMultisig returns true for MultisigWallet', () => { + const keys = createTestKeys(); + const wallet = new MultisigWallet({ + threshold: 2, + signerCount: 2, + spendSecretKey: keys.spendSecretKey, + viewSecretKey: keys.viewSecretKey + }); + + assertEqual(isMultisig(wallet), true); +}); + +test('isMultisig returns true for object with isMultisig flag', () => { + assertEqual(isMultisig({ isMultisig: true }), true); + assertEqual(isMultisig({ isMultisig: false }), false); +}); + +// ============================================================================ +// MultisigWallet Tests +// ============================================================================ + +console.log('\n--- MultisigWallet ---'); + +test('creates MultisigWallet with config', () => { + const keys = createTestKeys(); + const wallet = new MultisigWallet({ + threshold: 2, + signerCount: 3, + spendSecretKey: keys.spendSecretKey, + viewSecretKey: keys.viewSecretKey + }); + + assertEqual(wallet.getThreshold(), 2); + assertEqual(wallet.getSignerCount(), 3); + assertEqual(wallet.isReady(), false); +}); + +test('MultisigWallet getFirstKexMessage returns string', () => { + const keys = createTestKeys(); + const wallet = new MultisigWallet({ + threshold: 2, + signerCount: 2, + spendSecretKey: keys.spendSecretKey, + viewSecretKey: keys.viewSecretKey + }); + + const msg = wallet.getFirstKexMessage(); + + assertEqual(typeof msg, 'string'); + assert(msg.length > 0); +}); + +test('MultisigWallet isReady returns false before KEX complete', () => { + const keys = createTestKeys(); + const wallet = new MultisigWallet({ + threshold: 2, + signerCount: 2, + spendSecretKey: keys.spendSecretKey, + viewSecretKey: keys.viewSecretKey + }); + + wallet.getFirstKexMessage(); + assertEqual(wallet.isReady(), false); +}); + +// ============================================================================ +// createMultisigWallet Tests +// ============================================================================ + +console.log('\n--- createMultisigWallet ---'); + +test('createMultisigWallet returns MultisigWallet', () => { + const keys = createTestKeys(); + const wallet = createMultisigWallet({ + threshold: 2, + signerCount: 2, + spendSecretKey: keys.spendSecretKey, + viewSecretKey: keys.viewSecretKey + }); + + assert(wallet instanceof MultisigWallet); +}); + +test('createMultisigWallet validates parameters', () => { + let threw = false; + try { + createMultisigWallet({ + threshold: 5, + signerCount: 3 // Invalid: threshold > signerCount + }); + } catch (e) { + threw = true; + } + assert(threw, 'Should throw for invalid parameters'); +}); + +// ============================================================================ +// Summary +// ============================================================================ + +console.log('\n--- Summary ---'); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); + +if (failed > 0) { + console.log('\n✗ Some tests failed!'); + process.exit(1); +} else { + console.log('\n✓ All multisig tests passed!'); + process.exit(0); +} diff --git a/test/offline.test.js b/test/offline.test.js new file mode 100644 index 0000000..f90939f --- /dev/null +++ b/test/offline.test.js @@ -0,0 +1,546 @@ +#!/usr/bin/env bun +/** + * Offline Signing Tests + * + * Tests for offline.js: + * - Unsigned transaction creation and parsing + * - Signed transaction creation and parsing + * - Key image export/import + * - Output export/import + */ + +import { + UNSIGNED_TX_VERSION, + SIGNED_TX_VERSION, + createUnsignedTx, + parseUnsignedTx, + createSignedTx, + parseSignedTx, + exportUnsignedTx, + importUnsignedTx, + exportSignedTx, + importSignedTx, + exportKeyImages, + importKeyImages, + exportOutputs, + importOutputs, + verifyUnsignedTx, + summarizeUnsignedTx +} from '../src/offline.js'; + +import { bytesToHex, hexToBytes } from '../src/address.js'; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (e) { + console.log(` ✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message || 'Assertion failed'); +} + +function assertEqual(actual, expected, message) { + if (actual !== expected) { + throw new Error(message || `Expected ${expected}, got ${actual}`); + } +} + +// Helper to create valid test data +function createTestPublicKey() { + return new Uint8Array(32).fill(0xab); +} + +function createTestTxData() { + return { + version: 2, + unlockTime: 10, // Use non-zero number (0 is falsy, gets replaced with 0n default) + fee: 100000000n, + inputs: [ + { + amount: 1000000000n, + outputIndex: 0, + txHash: 'ab'.repeat(32), + publicKey: createTestPublicKey(), + ring: [ + { publicKey: new Uint8Array(32).fill(1), commitment: new Uint8Array(32).fill(2), globalIndex: 100n }, + { publicKey: new Uint8Array(32).fill(3), commitment: new Uint8Array(32).fill(4), globalIndex: 200n } + ], + realOutputIndex: 0, + commitment: new Uint8Array(32).fill(5), + mask: new Uint8Array(32).fill(6) + } + ], + outputs: [ + { + amount: 900000000n, + publicKey: new Uint8Array(32).fill(0xcd), + viewTag: 0x42, + commitment: new Uint8Array(32).fill(7) + } + ], + extra: new Uint8Array([1, 2, 3, 4]), + txSecretKey: new Uint8Array(32).fill(0xef) + }; +} + +console.log('=== Offline Signing Tests ===\n'); + +// ============================================================================ +// Constants Tests +// ============================================================================ + +console.log('--- Constants ---'); + +test('UNSIGNED_TX_VERSION is defined', () => { + assertEqual(UNSIGNED_TX_VERSION, 1); +}); + +test('SIGNED_TX_VERSION is defined', () => { + assertEqual(SIGNED_TX_VERSION, 1); +}); + +// ============================================================================ +// Unsigned Transaction Tests +// ============================================================================ + +console.log('\n--- Unsigned Transactions ---'); + +test('createUnsignedTx creates valid structure', () => { + const txData = createTestTxData(); + const utx = createUnsignedTx(txData); + + assertEqual(utx.version, UNSIGNED_TX_VERSION); + assert(utx.created > 0, 'Should have created timestamp'); + assert(utx.tx !== undefined, 'Should have tx data'); + assert(utx.tx.inputs.length === 1, 'Should have 1 input'); + assert(utx.tx.outputs.length === 1, 'Should have 1 output'); +}); + +test('createUnsignedTx converts BigInt to string', () => { + const txData = createTestTxData(); + const utx = createUnsignedTx(txData); + + // BigInts should be converted to strings for JSON serialization + assertEqual(typeof utx.tx.fee, 'string'); + assertEqual(utx.tx.fee, '100000000'); +}); + +test('createUnsignedTx converts Uint8Array to hex', () => { + const txData = createTestTxData(); + const utx = createUnsignedTx(txData); + + // Uint8Arrays should be converted to hex strings + assertEqual(typeof utx.tx.inputs[0].publicKey, 'string'); + assertEqual(typeof utx.tx.outputs[0].publicKey, 'string'); +}); + +test('parseUnsignedTx restores BigInt values', () => { + const txData = createTestTxData(); + const utx = createUnsignedTx(txData); + const parsed = parseUnsignedTx(utx); + + // BigInts should be restored + assertEqual(typeof parsed.tx.fee, 'bigint'); + assertEqual(parsed.tx.fee, 100000000n); + assertEqual(typeof parsed.tx.inputs[0].amount, 'bigint'); +}); + +test('parseUnsignedTx restores Uint8Array values', () => { + const txData = createTestTxData(); + const utx = createUnsignedTx(txData); + const parsed = parseUnsignedTx(utx); + + // Uint8Arrays should be restored + assert(parsed.tx.inputs[0].publicKey instanceof Uint8Array); + assert(parsed.tx.outputs[0].publicKey instanceof Uint8Array); +}); + +test('exportUnsignedTx returns JSON string', () => { + const txData = createTestTxData(); + const exported = exportUnsignedTx(txData); + + assertEqual(typeof exported, 'string'); + // Should be valid JSON + const parsed = JSON.parse(exported); + assert(parsed.version !== undefined); +}); + +test('importUnsignedTx round-trips correctly', () => { + const txData = createTestTxData(); + const exported = exportUnsignedTx(txData); + const imported = importUnsignedTx(exported); + + assertEqual(imported.version, UNSIGNED_TX_VERSION); + assertEqual(imported.tx.fee, 100000000n); + assertEqual(imported.tx.inputs.length, 1); + assertEqual(imported.tx.outputs.length, 1); +}); + +test('verifyUnsignedTx returns valid for proper tx', () => { + const txData = createTestTxData(); + const utx = createUnsignedTx(txData); + const parsed = parseUnsignedTx(utx); + const result = verifyUnsignedTx(parsed); + + assertEqual(result.valid, true); + assertEqual(result.errors.length, 0); +}); + +test('verifyUnsignedTx detects missing inputs', () => { + const txData = createTestTxData(); + txData.inputs = []; + const utx = createUnsignedTx(txData); + const parsed = parseUnsignedTx(utx); + const result = verifyUnsignedTx(parsed); + + assertEqual(result.valid, false); + assert(result.errors.some(e => e.includes('input'))); +}); + +test('verifyUnsignedTx detects missing outputs', () => { + const txData = createTestTxData(); + txData.outputs = []; + const utx = createUnsignedTx(txData); + const parsed = parseUnsignedTx(utx); + const result = verifyUnsignedTx(parsed); + + assertEqual(result.valid, false); + assert(result.errors.some(e => e.includes('output'))); +}); + +test('summarizeUnsignedTx returns summary', () => { + const txData = createTestTxData(); + const utx = createUnsignedTx(txData); + const parsed = parseUnsignedTx(utx); + const summary = summarizeUnsignedTx(parsed); + + assertEqual(summary.inputCount, 1); + assertEqual(summary.outputCount, 1); + assertEqual(summary.fee, 100000000n); + assert(summary.totalIn !== undefined); + assert(summary.totalOut !== undefined); + assert(summary.ringSize === 2); +}); + +// ============================================================================ +// Signed Transaction Tests +// ============================================================================ + +console.log('\n--- Signed Transactions ---'); + +test('createSignedTx requires valid transaction', () => { + // createSignedTx expects a transaction object that can be serialized + // For testing, we'll verify it handles the expected structure + let threw = false; + try { + createSignedTx(null); + } catch (e) { + threw = true; + } + assert(threw, 'Should throw for null transaction'); +}); + +test('parseSignedTx validates version', () => { + let threw = false; + try { + parseSignedTx({ version: 999 }); + } catch (e) { + threw = true; + assert(e.message.includes('version'), 'Error should mention version'); + } + assert(threw, 'Should throw for invalid version'); +}); + +test('exportSignedTx returns string', () => { + const signedTx = { + version: SIGNED_TX_VERSION, + created: Date.now(), + txHash: 'ab'.repeat(32), + txBlob: 'deadbeef', + metadata: {} + }; + + const exported = exportSignedTx(signedTx); + assertEqual(typeof exported, 'string'); +}); + +test('importSignedTx round-trips correctly', () => { + const original = { + version: SIGNED_TX_VERSION, + created: Date.now(), + txHash: 'ef'.repeat(32), + txBlob: 'cafebabe', + metadata: { fee: '1000' } + }; + + const exported = exportSignedTx(original); + const imported = importSignedTx(exported); + + assertEqual(imported.version, original.version); + assertEqual(imported.txHash, original.txHash); +}); + +// ============================================================================ +// Key Image Export/Import Tests +// ============================================================================ + +console.log('\n--- Key Image Export/Import ---'); + +test('exportKeyImages creates valid export', () => { + const outputs = [ + { + keyImage: new Uint8Array(32).fill(0xaa), + txHash: 'ab'.repeat(32), + outputIndex: 0, + amount: 1000000000n + }, + { + keyImage: new Uint8Array(32).fill(0xbb), + txHash: 'cd'.repeat(32), + outputIndex: 1, + amount: 2000000000n + } + ]; + + const exported = exportKeyImages(outputs); + + assertEqual(exported.version, 1); + assert(exported.created > 0); + assertEqual(exported.keyImages.length, 2); + assertEqual(typeof exported.keyImages[0].keyImage, 'string'); // Hex encoded +}); + +test('importKeyImages parses export', () => { + const outputs = [ + { + keyImage: new Uint8Array(32).fill(0xcc), + txHash: 'ef'.repeat(32), + outputIndex: 0, + amount: 5000000000n + } + ]; + + const exported = exportKeyImages(outputs); + const imported = importKeyImages(exported); + + assertEqual(imported.length, 1); + assert(imported[0].keyImage instanceof Uint8Array); + assertEqual(imported[0].keyImage.length, 32); + assertEqual(imported[0].amount, 5000000000n); +}); + +test('key images round-trip correctly', () => { + const original = [ + { + keyImage: new Uint8Array(32).fill(0x11), + txHash: '22'.repeat(32), + outputIndex: 0, + amount: 100n + }, + { + keyImage: new Uint8Array(32).fill(0x33), + txHash: '44'.repeat(32), + outputIndex: 1, + amount: 200n + } + ]; + + const exported = exportKeyImages(original); + const imported = importKeyImages(exported); + + assertEqual(imported.length, 2); + assertEqual(bytesToHex(imported[0].keyImage), bytesToHex(original[0].keyImage)); + assertEqual(bytesToHex(imported[1].keyImage), bytesToHex(original[1].keyImage)); + assertEqual(imported[0].amount, 100n); + assertEqual(imported[1].amount, 200n); +}); + +// ============================================================================ +// Output Export/Import Tests +// ============================================================================ + +console.log('\n--- Output Export/Import ---'); + +test('exportOutputs creates valid export', () => { + const outputs = [ + { + txHash: 'ab'.repeat(32), + outputIndex: 0, + globalIndex: 12345n, + amount: 1000000000n, + publicKey: new Uint8Array(32).fill(0xaa), + keyImage: new Uint8Array(32).fill(0xbb), + commitment: new Uint8Array(32).fill(0xcc), + mask: new Uint8Array(32).fill(0xdd), + blockHeight: 100000, + assetType: 'SAL' + } + ]; + + const exported = exportOutputs(outputs); + + assertEqual(exported.version, 1); + assert(exported.created > 0); + assertEqual(exported.outputs.length, 1); + assertEqual(typeof exported.outputs[0].publicKey, 'string'); // Hex encoded + assertEqual(exported.outputs[0].amount, '1000000000'); // String +}); + +test('importOutputs parses export', () => { + const original = [ + { + txHash: 'cd'.repeat(32), + outputIndex: 1, + globalIndex: 99999n, + amount: 2000000000n, + publicKey: new Uint8Array(32).fill(0xee), + blockHeight: 50000, + assetType: 'SAL' + } + ]; + + const exported = exportOutputs(original); + const imported = importOutputs(exported); + + assertEqual(imported.length, 1); + assert(imported[0].publicKey instanceof Uint8Array); + assertEqual(imported[0].amount, 2000000000n); + assertEqual(imported[0].globalIndex, 99999n); +}); + +test('outputs round-trip preserves BigInt amounts', () => { + const original = [ + { + txHash: '11'.repeat(32), + outputIndex: 0, + globalIndex: 1n, + amount: 123456789012345n, + publicKey: new Uint8Array(32).fill(0x11), + blockHeight: 1000 + }, + { + txHash: '22'.repeat(32), + outputIndex: 0, + globalIndex: 2n, + amount: 987654321098765n, + publicKey: new Uint8Array(32).fill(0x22), + blockHeight: 2000 + } + ]; + + const exported = exportOutputs(original); + const imported = importOutputs(exported); + + assertEqual(imported[0].amount, 123456789012345n); + assertEqual(imported[1].amount, 987654321098765n); +}); + +test('outputs round-trip preserves all fields', () => { + const original = [ + { + txHash: 'aa'.repeat(32), + outputIndex: 1, + globalIndex: 99999n, + amount: 1000n, + publicKey: new Uint8Array(32).fill(0xaa), + keyImage: new Uint8Array(32).fill(0xbb), + commitment: new Uint8Array(32).fill(0xcc), + mask: new Uint8Array(32).fill(0xdd), + blockHeight: 50000, + assetType: 'SAL', + subaddressIndex: { major: 0, minor: 5 } + } + ]; + + const exported = exportOutputs(original); + const imported = importOutputs(exported); + + assertEqual(imported[0].txHash, original[0].txHash); + assertEqual(imported[0].outputIndex, original[0].outputIndex); + assertEqual(imported[0].globalIndex, 99999n); + assertEqual(imported[0].blockHeight, original[0].blockHeight); + assertEqual(imported[0].assetType, 'SAL'); +}); + +// ============================================================================ +// Error Handling Tests +// ============================================================================ + +console.log('\n--- Error Handling ---'); + +test('importUnsignedTx throws on invalid data', () => { + let threw = false; + try { + importUnsignedTx('not valid json'); + } catch (e) { + threw = true; + } + assert(threw, 'Should throw on invalid data'); +}); + +test('importSignedTx throws on invalid data', () => { + let threw = false; + try { + importSignedTx('not valid json'); + } catch (e) { + threw = true; + } + assert(threw, 'Should throw on invalid data'); +}); + +test('importKeyImages throws on invalid data', () => { + let threw = false; + try { + importKeyImages('garbage'); + } catch (e) { + threw = true; + } + assert(threw, 'Should throw on invalid data'); +}); + +test('importOutputs throws on invalid data', () => { + let threw = false; + try { + importOutputs('garbage'); + } catch (e) { + threw = true; + } + assert(threw, 'Should throw on invalid data'); +}); + +test('parseUnsignedTx throws on wrong version', () => { + let threw = false; + try { + parseUnsignedTx({ version: 999, tx: {} }); + } catch (e) { + threw = true; + assert(e.message.includes('version')); + } + assert(threw, 'Should throw on invalid version'); +}); + +// ============================================================================ +// Summary +// ============================================================================ + +console.log('\n--- Summary ---'); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); + +if (failed > 0) { + console.log('\n✗ Some tests failed!'); + process.exit(1); +} else { + console.log('\n✓ All offline signing tests passed!'); + process.exit(0); +} diff --git a/test/persistent-wallet.test.js b/test/persistent-wallet.test.js new file mode 100644 index 0000000..f9307e1 --- /dev/null +++ b/test/persistent-wallet.test.js @@ -0,0 +1,770 @@ +#!/usr/bin/env bun +/** + * Persistent Wallet Tests + * + * Tests for persistent-wallet.js: + * - PersistentWallet class + * - Storage integration + * - Balance calculation + * - Transaction creation + */ + +import { + PersistentWallet, + createPersistentWallet, + restorePersistentWallet, + openPersistentWallet +} from '../src/persistent-wallet.js'; + +import { MemoryStorage, WalletOutput, WalletTransaction } from '../src/wallet-store.js'; +import { generateSeed, deriveKeys } from '../src/carrot.js'; +import { mnemonicToSeed, seedToMnemonic } from '../src/mnemonic.js'; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (e) { + console.log(` ✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } +} + +async function testAsync(name, fn) { + try { + await fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (e) { + console.log(` ✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message || 'Assertion failed'); +} + +function assertEqual(actual, expected, message) { + if (actual !== expected) { + throw new Error(message || `Expected ${expected}, got ${actual}`); + } +} + +// Mock daemon for testing +class MockDaemon { + constructor(options = {}) { + this.height = options.height || 1000; + } + + async getInfo() { + return { + success: true, + result: { height: this.height, status: 'OK' } + }; + } + + async getBlockHeadersRange(start, end) { + return { success: true, result: { headers: [] } }; + } + + async getBlock(opts) { + return { + success: true, + result: { tx_hashes: [], miner_tx_hash: 'miner' } + }; + } + + async getTransactions() { + return { success: true, result: { txs: [] } }; + } + + async sendRawTransaction(blob) { + return { success: true, result: { status: 'OK' } }; + } + + async getOutputDistribution() { + return { + success: true, + result: { distributions: [{ amount: 1000000 }] } + }; + } + + async getOuts(indices) { + return { + success: true, + result: { + outs: indices.map(i => ({ + key: 'aa'.repeat(32), + mask: 'bb'.repeat(32) + })) + } + }; + } +} + +console.log('=== Persistent Wallet Tests ===\n'); + +// ============================================================================ +// Construction Tests +// ============================================================================ + +console.log('--- Construction ---'); + +test('creates PersistentWallet with options', () => { + const seed = generateSeed(); + const keys = deriveKeys(seed); + + const wallet = new PersistentWallet({ + seed, + storage: { type: 'memory' }, + daemon: new MockDaemon() + }); + + assert(wallet !== undefined); + assert(!wallet.isOpen()); +}); + +test('creates with custom storage instance', () => { + const storage = new MemoryStorage(); + const seed = generateSeed(); + + const wallet = new PersistentWallet({ + seed, + storage, + daemon: new MockDaemon() + }); + + assertEqual(wallet._storage, storage); +}); + +test('creates with custom daemon instance', () => { + const daemon = new MockDaemon({ height: 5000 }); + const seed = generateSeed(); + + const wallet = new PersistentWallet({ + seed, + storage: { type: 'memory' }, + daemon + }); + + assertEqual(wallet._daemon, daemon); +}); + +// ============================================================================ +// Lifecycle Tests +// ============================================================================ + +console.log('\n--- Lifecycle ---'); + +await testAsync('open initializes wallet', async () => { + const seed = generateSeed(); + const wallet = new PersistentWallet({ + seed, + storage: { type: 'memory' }, + daemon: new MockDaemon() + }); + + assert(!wallet.isOpen()); + await wallet.open(); + assert(wallet.isOpen()); + + await wallet.close(); +}); + +await testAsync('close cleans up', async () => { + const seed = generateSeed(); + const wallet = new PersistentWallet({ + seed, + storage: { type: 'memory' }, + daemon: new MockDaemon() + }); + + await wallet.open(); + assert(wallet.isOpen()); + + await wallet.close(); + assert(!wallet.isOpen()); +}); + +await testAsync('open is idempotent', async () => { + const seed = generateSeed(); + const wallet = new PersistentWallet({ + seed, + storage: { type: 'memory' }, + daemon: new MockDaemon() + }); + + await wallet.open(); + await wallet.open(); // Should not throw + assert(wallet.isOpen()); + + await wallet.close(); +}); + +await testAsync('close is idempotent', async () => { + const seed = generateSeed(); + const wallet = new PersistentWallet({ + seed, + storage: { type: 'memory' }, + daemon: new MockDaemon() + }); + + await wallet.open(); + await wallet.close(); + await wallet.close(); // Should not throw + assert(!wallet.isOpen()); +}); + +// ============================================================================ +// Balance Tests +// ============================================================================ + +console.log('\n--- Balance ---'); + +await testAsync('getBalance returns 0 for empty wallet', async () => { + const seed = generateSeed(); + const wallet = new PersistentWallet({ + seed, + storage: { type: 'memory' }, + daemon: new MockDaemon() + }); + + await wallet.open(); + const balance = await wallet.getBalance(); + assertEqual(balance, 0n); + + await wallet.close(); +}); + +await testAsync('getBalance sums unspent outputs', async () => { + const seed = generateSeed(); + const storage = new MemoryStorage(); + await storage.open(); + + // Add some outputs + await storage.putOutput(new WalletOutput({ + keyImage: 'ki1', + amount: 1000000000n, + isSpent: false, + blockHeight: 100 + })); + await storage.putOutput(new WalletOutput({ + keyImage: 'ki2', + amount: 2000000000n, + isSpent: false, + blockHeight: 101 + })); + await storage.putOutput(new WalletOutput({ + keyImage: 'ki3', + amount: 500000000n, + isSpent: true, // Spent, should not count + blockHeight: 102 + })); + + const wallet = new PersistentWallet({ + seed, + storage, + daemon: new MockDaemon({ height: 200 }) + }); + + await wallet.open(); + const balance = await wallet.getBalance(); + + // 1000000000 + 2000000000 = 3000000000 + assertEqual(balance, 3000000000n); + + await wallet.close(); +}); + +await testAsync('getBalance filters by asset type', async () => { + const seed = generateSeed(); + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putOutput(new WalletOutput({ + keyImage: 'ki1', + amount: 1000n, + assetType: 'SAL', + isSpent: false, + blockHeight: 100 + })); + await storage.putOutput(new WalletOutput({ + keyImage: 'ki2', + amount: 2000n, + assetType: 'USD', + isSpent: false, + blockHeight: 101 + })); + + const wallet = new PersistentWallet({ + seed, + storage, + daemon: new MockDaemon({ height: 200 }) + }); + + await wallet.open(); + + const salBalance = await wallet.getBalance('SAL'); + const usdBalance = await wallet.getBalance('USD'); + + assertEqual(salBalance, 1000n); + assertEqual(usdBalance, 2000n); + + await wallet.close(); +}); + +await testAsync('getUnlockedBalance excludes locked outputs', async () => { + const seed = generateSeed(); + const storage = new MemoryStorage(); + await storage.open(); + + // Unlocked output (old enough) + await storage.putOutput(new WalletOutput({ + keyImage: 'ki1', + amount: 1000n, + isSpent: false, + blockHeight: 100, + unlockTime: 0n + })); + + // Locked output (too recent) + await storage.putOutput(new WalletOutput({ + keyImage: 'ki2', + amount: 2000n, + isSpent: false, + blockHeight: 195, // Only 5 blocks old at height 200 + unlockTime: 0n + })); + + const wallet = new PersistentWallet({ + seed, + storage, + daemon: new MockDaemon({ height: 200 }) + }); + + await wallet.open(); + + const balance = await wallet.getBalance(); + const unlocked = await wallet.getUnlockedBalance(); + + assertEqual(balance, 3000n); + assertEqual(unlocked, 1000n); // Only the old output + + await wallet.close(); +}); + +await testAsync('getBalances returns all asset types', async () => { + const seed = generateSeed(); + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putOutput(new WalletOutput({ + keyImage: 'ki1', amount: 100n, assetType: 'SAL', isSpent: false, blockHeight: 10 + })); + await storage.putOutput(new WalletOutput({ + keyImage: 'ki2', amount: 200n, assetType: 'USD', isSpent: false, blockHeight: 10 + })); + await storage.putOutput(new WalletOutput({ + keyImage: 'ki3', amount: 300n, assetType: 'EUR', isSpent: false, blockHeight: 10 + })); + + const wallet = new PersistentWallet({ + seed, + storage, + daemon: new MockDaemon({ height: 200 }) + }); + + await wallet.open(); + const balances = await wallet.getBalances(); + + assert(balances.has('SAL')); + assert(balances.has('USD')); + assert(balances.has('EUR')); + assertEqual(balances.get('SAL').balance, 100n); + assertEqual(balances.get('USD').balance, 200n); + assertEqual(balances.get('EUR').balance, 300n); + + await wallet.close(); +}); + +// ============================================================================ +// Output Tests +// ============================================================================ + +console.log('\n--- Outputs ---'); + +await testAsync('getOutputs returns all outputs', async () => { + const seed = generateSeed(); + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putOutput(new WalletOutput({ keyImage: 'ki1', amount: 100n })); + await storage.putOutput(new WalletOutput({ keyImage: 'ki2', amount: 200n })); + + const wallet = new PersistentWallet({ + seed, + storage, + daemon: new MockDaemon() + }); + + await wallet.open(); + const outputs = await wallet.getOutputs(); + + assertEqual(outputs.length, 2); + + await wallet.close(); +}); + +await testAsync('getUnspentOutputs filters spent', async () => { + const seed = generateSeed(); + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putOutput(new WalletOutput({ keyImage: 'ki1', amount: 100n, isSpent: false })); + await storage.putOutput(new WalletOutput({ keyImage: 'ki2', amount: 200n, isSpent: true })); + + const wallet = new PersistentWallet({ + seed, + storage, + daemon: new MockDaemon() + }); + + await wallet.open(); + const outputs = await wallet.getUnspentOutputs(); + + assertEqual(outputs.length, 1); + assertEqual(outputs[0].keyImage, 'ki1'); + + await wallet.close(); +}); + +await testAsync('freezeOutput prevents spending', async () => { + const seed = generateSeed(); + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putOutput(new WalletOutput({ + keyImage: 'ki1', + amount: 100n, + isSpent: false, + isFrozen: false, + blockHeight: 10 + })); + + const wallet = new PersistentWallet({ + seed, + storage, + daemon: new MockDaemon({ height: 200 }) + }); + + await wallet.open(); + + // Before freeze + let spendable = await wallet.getSpendableOutputs(); + assertEqual(spendable.length, 1); + + // Freeze + await wallet.freezeOutput('ki1'); + + // After freeze + spendable = await wallet.getSpendableOutputs(); + assertEqual(spendable.length, 0); + + await wallet.close(); +}); + +await testAsync('thawOutput allows spending again', async () => { + const seed = generateSeed(); + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putOutput(new WalletOutput({ + keyImage: 'ki1', + amount: 100n, + isSpent: false, + isFrozen: true, + blockHeight: 10 + })); + + const wallet = new PersistentWallet({ + seed, + storage, + daemon: new MockDaemon({ height: 200 }) + }); + + await wallet.open(); + + // Before thaw + let spendable = await wallet.getSpendableOutputs(); + assertEqual(spendable.length, 0); + + // Thaw + await wallet.thawOutput('ki1'); + + // After thaw + spendable = await wallet.getSpendableOutputs(); + assertEqual(spendable.length, 1); + + await wallet.close(); +}); + +// ============================================================================ +// Transaction Tests +// ============================================================================ + +console.log('\n--- Transactions ---'); + +await testAsync('getTransactions returns all transactions', async () => { + const seed = generateSeed(); + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putTransaction(new WalletTransaction({ txHash: 'tx1', blockHeight: 100 })); + await storage.putTransaction(new WalletTransaction({ txHash: 'tx2', blockHeight: 101 })); + + const wallet = new PersistentWallet({ + seed, + storage, + daemon: new MockDaemon() + }); + + await wallet.open(); + const txs = await wallet.getTransactions(); + + assertEqual(txs.length, 2); + + await wallet.close(); +}); + +await testAsync('getTransaction returns specific tx', async () => { + const seed = generateSeed(); + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putTransaction(new WalletTransaction({ + txHash: 'specific_tx', + blockHeight: 500, + incomingAmount: 1000000n + })); + + const wallet = new PersistentWallet({ + seed, + storage, + daemon: new MockDaemon() + }); + + await wallet.open(); + const tx = await wallet.getTransaction('specific_tx'); + + assertEqual(tx.txHash, 'specific_tx'); + assertEqual(tx.blockHeight, 500); + assertEqual(tx.incomingAmount, 1000000n); + + await wallet.close(); +}); + +await testAsync('getTransaction returns null for nonexistent', async () => { + const seed = generateSeed(); + const wallet = new PersistentWallet({ + seed, + storage: { type: 'memory' }, + daemon: new MockDaemon() + }); + + await wallet.open(); + const tx = await wallet.getTransaction('nonexistent'); + + assertEqual(tx, null); + + await wallet.close(); +}); + +await testAsync('setTransactionNote updates note', async () => { + const seed = generateSeed(); + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putTransaction(new WalletTransaction({ + txHash: 'tx_with_note', + blockHeight: 100 + })); + + const wallet = new PersistentWallet({ + seed, + storage, + daemon: new MockDaemon() + }); + + await wallet.open(); + + await wallet.setTransactionNote('tx_with_note', 'Payment for coffee'); + const tx = await wallet.getTransaction('tx_with_note'); + + assertEqual(tx.note, 'Payment for coffee'); + + await wallet.close(); +}); + +// ============================================================================ +// Sync Height Tests +// ============================================================================ + +console.log('\n--- Sync Height ---'); + +await testAsync('getSyncHeight returns stored height', async () => { + const seed = generateSeed(); + const storage = new MemoryStorage(); + await storage.open(); + await storage.setSyncHeight(5000); + + const wallet = new PersistentWallet({ + seed, + storage, + daemon: new MockDaemon() + }); + + await wallet.open(); + const height = await wallet.getSyncHeight(); + + assertEqual(height, 5000); + + await wallet.close(); +}); + +await testAsync('getDaemonHeight returns daemon height', async () => { + const seed = generateSeed(); + const wallet = new PersistentWallet({ + seed, + storage: { type: 'memory' }, + daemon: new MockDaemon({ height: 12345 }) + }); + + await wallet.open(); + const height = await wallet.getDaemonHeight(); + + assertEqual(height, 12345); + + await wallet.close(); +}); + +await testAsync('isSynced returns true when caught up', async () => { + const seed = generateSeed(); + const storage = new MemoryStorage(); + await storage.open(); + await storage.setSyncHeight(1000); + + const wallet = new PersistentWallet({ + seed, + storage, + daemon: new MockDaemon({ height: 1000 }) + }); + + await wallet.open(); + const synced = await wallet.isSynced(); + + assert(synced); + + await wallet.close(); +}); + +await testAsync('isSynced returns false when behind', async () => { + const seed = generateSeed(); + const storage = new MemoryStorage(); + await storage.open(); + await storage.setSyncHeight(500); + + const wallet = new PersistentWallet({ + seed, + storage, + daemon: new MockDaemon({ height: 1000 }) + }); + + await wallet.open(); + const synced = await wallet.isSynced(); + + assert(!synced); + + await wallet.close(); +}); + +// ============================================================================ +// Error Handling Tests +// ============================================================================ + +console.log('\n--- Error Handling ---'); + +await testAsync('operations throw when not open', async () => { + const seed = generateSeed(); + const wallet = new PersistentWallet({ + seed, + storage: { type: 'memory' }, + daemon: new MockDaemon() + }); + + let threw = false; + try { + await wallet.getBalance(); + } catch (e) { + threw = true; + assert(e.message.includes('not open')); + } + assert(threw, 'Should throw when not open'); +}); + +// ============================================================================ +// Factory Function Tests +// ============================================================================ + +console.log('\n--- Factory Functions ---'); + +await testAsync('createPersistentWallet creates and opens', async () => { + const seed = generateSeed(); + const wallet = await createPersistentWallet({ + seed, + storage: { type: 'memory' }, + daemon: new MockDaemon() + }); + + assert(wallet instanceof PersistentWallet); + assert(wallet.isOpen()); + + await wallet.close(); +}); + +await testAsync('restorePersistentWallet restores from mnemonic', async () => { + const seed = generateSeed(); + const mnemonic = seedToMnemonic(seed); + + const wallet = await restorePersistentWallet(mnemonic, { + storage: { type: 'memory' }, + daemon: new MockDaemon() + }); + + assert(wallet instanceof PersistentWallet); + assert(wallet.isOpen()); + + await wallet.close(); +}); + +// ============================================================================ +// Summary +// ============================================================================ + +console.log('\n--- Summary ---'); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); + +if (failed > 0) { + console.log('\n✗ Some tests failed!'); + process.exit(1); +} else { + console.log('\n✓ All persistent wallet tests passed!'); + process.exit(0); +} diff --git a/test/query.test.js b/test/query.test.js new file mode 100644 index 0000000..c880dbd --- /dev/null +++ b/test/query.test.js @@ -0,0 +1,488 @@ +#!/usr/bin/env bun +/** + * Query System Tests + * + * Tests for query.js: + * - OutputQuery class + * - TxQuery class + * - TransferQuery class + * - Query factory functions + * - Query preset functions + */ + +import { + OutputQuery, + TxQuery, + TransferQuery, + createOutputQuery, + createTxQuery, + createTransferQuery, + unspentOutputs, + spentOutputs, + lockedOutputs, + unlockedOutputs, + stakingOutputs, + yieldOutputs, + incomingTxs, + outgoingTxs, + pendingTxs, + confirmedTxs, + stakingTxs, + yieldTxs +} from '../src/query.js'; + +import { TX_TYPE } from '../src/wallet.js'; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (e) { + console.log(` ✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message || 'Assertion failed'); +} + +function assertEqual(actual, expected, message) { + if (actual !== expected) { + throw new Error(message || `Expected ${expected}, got ${actual}`); + } +} + +function assertDeepEqual(actual, expected, message) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(message || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +console.log('=== Query System Tests ===\n'); + +// ============================================================================ +// OutputQuery Tests +// ============================================================================ + +console.log('--- OutputQuery ---'); + +test('creates with default values', () => { + const query = new OutputQuery(); + assertEqual(query.isSpent, null); + assertEqual(query.isFrozen, null); + assertEqual(query.isLocked, null); + assertEqual(query.assetType, null); + assertEqual(query.minAmount, null); + assertEqual(query.maxAmount, null); +}); + +test('creates with provided config', () => { + const query = new OutputQuery({ + isSpent: false, + isFrozen: false, + assetType: 'SAL', + minAmount: 1000n, + maxAmount: 10000n, + accountIndex: 0, + subaddressIndices: [0, 1, 2] + }); + + assertEqual(query.isSpent, false); + assertEqual(query.isFrozen, false); + assertEqual(query.assetType, 'SAL'); + assertEqual(query.minAmount, 1000n); + assertEqual(query.maxAmount, 10000n); + assertEqual(query.accountIndex, 0); + assertDeepEqual(query.subaddressIndices, [0, 1, 2]); +}); + +test('matches unspent output', () => { + const query = new OutputQuery({ isSpent: false }); + const output = { isSpent: false, amount: 1000n }; + + assert(query.matches(output), 'Should match unspent output'); +}); + +test('rejects spent output when querying unspent', () => { + const query = new OutputQuery({ isSpent: false }); + const output = { isSpent: true, amount: 1000n }; + + assert(!query.matches(output), 'Should not match spent output'); +}); + +test('matches by asset type', () => { + const query = new OutputQuery({ assetType: 'SAL' }); + + assert(query.matches({ assetType: 'SAL' }), 'Should match SAL'); + assert(!query.matches({ assetType: 'USD' }), 'Should not match USD'); +}); + +test('matches by amount range', () => { + const query = new OutputQuery({ minAmount: 100n, maxAmount: 1000n }); + + assert(!query.matches({ amount: 50n }), 'Should not match below min'); + assert(query.matches({ amount: 100n }), 'Should match at min'); + assert(query.matches({ amount: 500n }), 'Should match in range'); + assert(query.matches({ amount: 1000n }), 'Should match at max'); + assert(!query.matches({ amount: 1001n }), 'Should not match above max'); +}); + +test('matches by account index', () => { + const query = new OutputQuery({ accountIndex: 1 }); + + assert(query.matches({ subaddressIndex: { major: 1, minor: 0 } }), 'Should match account 1'); + assert(!query.matches({ subaddressIndex: { major: 0, minor: 0 } }), 'Should not match account 0'); +}); + +test('matches by subaddress indices', () => { + const query = new OutputQuery({ + subaddressIndices: [ + { major: 0, minor: 1 }, + { major: 0, minor: 2 }, + { major: 0, minor: 3 } + ] + }); + + assert(query.matches({ subaddressIndex: { major: 0, minor: 1 } }), 'Should match minor 1'); + assert(query.matches({ subaddressIndex: { major: 0, minor: 2 } }), 'Should match minor 2'); + assert(!query.matches({ subaddressIndex: { major: 0, minor: 0 } }), 'Should not match minor 0'); + assert(!query.matches({ subaddressIndex: { major: 0, minor: 5 } }), 'Should not match minor 5'); +}); + +test('matches by tx type', () => { + const query = new OutputQuery({ txType: TX_TYPE.STAKE }); + + assert(query.matches({ txType: TX_TYPE.STAKE }), 'Should match stake'); + assert(!query.matches({ txType: TX_TYPE.TRANSFER }), 'Should not match transfer'); +}); + +test('matches by key images list', () => { + const query = new OutputQuery({ keyImages: ['ki1', 'ki2', 'ki3'] }); + + assert(query.matches({ keyImage: 'ki1' }), 'Should match ki1'); + assert(query.matches({ keyImage: 'ki2' }), 'Should match ki2'); + assert(!query.matches({ keyImage: 'ki4' }), 'Should not match ki4'); +}); + +test('matches by block height range', () => { + const query = new OutputQuery({ minHeight: 100, maxHeight: 200 }); + + assert(!query.matches({ blockHeight: 50 }), 'Should not match below min'); + assert(query.matches({ blockHeight: 100 }), 'Should match at min'); + assert(query.matches({ blockHeight: 150 }), 'Should match in range'); + assert(query.matches({ blockHeight: 200 }), 'Should match at max'); + assert(!query.matches({ blockHeight: 250 }), 'Should not match above max'); +}); + +test('combines multiple criteria (AND logic)', () => { + const query = new OutputQuery({ + isSpent: false, + assetType: 'SAL', + minAmount: 100n + }); + + assert(query.matches({ isSpent: false, assetType: 'SAL', amount: 200n }), 'Should match all criteria'); + assert(!query.matches({ isSpent: true, assetType: 'SAL', amount: 200n }), 'Should fail on isSpent'); + assert(!query.matches({ isSpent: false, assetType: 'USD', amount: 200n }), 'Should fail on assetType'); + assert(!query.matches({ isSpent: false, assetType: 'SAL', amount: 50n }), 'Should fail on amount'); +}); + +test('config values are accessible', () => { + const query = new OutputQuery({ isSpent: false, assetType: 'SAL' }); + + assertEqual(query.isSpent, false); + assertEqual(query.assetType, 'SAL'); +}); + +// ============================================================================ +// TxQuery Tests +// ============================================================================ + +console.log('\n--- TxQuery ---'); + +test('creates with default values', () => { + const query = new TxQuery(); + assertEqual(query.isIncoming, null); + assertEqual(query.isOutgoing, null); + assertEqual(query.isConfirmed, null); + assertEqual(query.inTxPool, null); +}); + +test('matches by direction', () => { + const incomingQuery = new TxQuery({ isIncoming: true }); + const outgoingQuery = new TxQuery({ isOutgoing: true }); + + assert(incomingQuery.matches({ isIncoming: true, isOutgoing: false })); + assert(!incomingQuery.matches({ isIncoming: false, isOutgoing: true })); + assert(outgoingQuery.matches({ isIncoming: false, isOutgoing: true })); +}); + +test('matches by confirmation status', () => { + const confirmedQuery = new TxQuery({ isConfirmed: true }); + const pendingQuery = new TxQuery({ inTxPool: true }); + + assert(confirmedQuery.matches({ isConfirmed: true, blockHeight: 1000 })); + assert(!confirmedQuery.matches({ isConfirmed: false, blockHeight: null })); + assert(pendingQuery.matches({ inTxPool: true })); + assert(!pendingQuery.matches({ inTxPool: false })); +}); + +test('matches by tx hash', () => { + const query = new TxQuery({ hash: 'abc123' }); + + assert(query.matches({ txHash: 'abc123' })); + assert(!query.matches({ txHash: 'xyz789' })); +}); + +test('matches by tx hashes list', () => { + const query = new TxQuery({ hashes: ['tx1', 'tx2', 'tx3'] }); + + assert(query.matches({ txHash: 'tx1' })); + assert(query.matches({ txHash: 'tx2' })); + assert(!query.matches({ txHash: 'tx4' })); +}); + +test('matches by height range', () => { + const query = new TxQuery({ minHeight: 100, maxHeight: 200 }); + + assert(!query.matches({ blockHeight: 50 })); + assert(query.matches({ blockHeight: 150 })); + assert(!query.matches({ blockHeight: 250 })); +}); + +test('matches by height', () => { + const query = new TxQuery({ height: 1500 }); + + assert(!query.matches({ blockHeight: 500 })); + assert(query.matches({ blockHeight: 1500 })); + assert(!query.matches({ blockHeight: 2500 })); +}); + +test('matches by payment ID', () => { + const query = new TxQuery({ paymentId: 'pay123' }); + + assert(query.matches({ paymentId: 'pay123' })); + assert(!query.matches({ paymentId: 'pay456' })); + assert(!query.matches({ paymentId: null })); +}); + +test('matches by tx type', () => { + const query = new TxQuery({ txType: TX_TYPE.STAKE }); + + assert(query.matches({ txType: TX_TYPE.STAKE })); + assert(!query.matches({ txType: TX_TYPE.TRANSFER })); +}); + +test('matches by amount range', () => { + const query = new TxQuery({ minAmount: 100n, maxAmount: 1000n }); + + assert(query.matches({ amount: 500n })); + assert(!query.matches({ amount: 50n })); + assert(!query.matches({ amount: 2000n })); +}); + +// ============================================================================ +// TransferQuery Tests +// ============================================================================ + +console.log('\n--- TransferQuery ---'); + +test('creates transfer query', () => { + const query = new TransferQuery({ + isIncoming: true, + address: 'Salv1...' + }); + + assertEqual(query.isIncoming, true); + assertEqual(query.address, 'Salv1...'); +}); + +test('matches by address', () => { + const query = new TransferQuery({ address: 'addr1' }); + + assert(query.matches({ address: 'addr1' })); + assert(!query.matches({ address: 'addr2' })); +}); + +test('matches by subaddress index', () => { + const query = new TransferQuery({ accountIndex: 0, subaddressIndex: 5 }); + + assert(query.matches({ accountIndex: 0, subaddressIndex: 5 })); + assert(!query.matches({ accountIndex: 0, subaddressIndex: 3 })); + assert(!query.matches({ accountIndex: 1, subaddressIndex: 5 })); +}); + +// ============================================================================ +// Factory Functions Tests +// ============================================================================ + +console.log('\n--- Factory Functions ---'); + +test('createOutputQuery creates OutputQuery', () => { + const query = createOutputQuery({ isSpent: false }); + assert(query instanceof OutputQuery); + assertEqual(query.isSpent, false); +}); + +test('createTxQuery creates TxQuery', () => { + const query = createTxQuery({ isConfirmed: true }); + assert(query instanceof TxQuery); + assertEqual(query.isConfirmed, true); +}); + +test('createTransferQuery creates TransferQuery', () => { + const query = createTransferQuery({ isIncoming: true }); + assert(query instanceof TransferQuery); + assertEqual(query.isIncoming, true); +}); + +// ============================================================================ +// Query Preset Functions Tests +// ============================================================================ + +console.log('\n--- Query Presets ---'); + +test('unspentOutputs creates correct query', () => { + const query = unspentOutputs(); + assertEqual(query.isSpent, false); +}); + +test('unspentOutputs merges additional config', () => { + const query = unspentOutputs({ assetType: 'USD' }); + assertEqual(query.isSpent, false); + assertEqual(query.assetType, 'USD'); +}); + +test('spentOutputs creates correct query', () => { + const query = spentOutputs(); + assertEqual(query.isSpent, true); +}); + +test('lockedOutputs creates correct query', () => { + const query = lockedOutputs(); + assertEqual(query.isLocked, true); + assertEqual(query.isSpent, false); +}); + +test('unlockedOutputs creates correct query', () => { + const query = unlockedOutputs(); + assertEqual(query.isLocked, false); + assertEqual(query.isSpent, false); +}); + +test('stakingOutputs creates correct query', () => { + const query = stakingOutputs(); + assertEqual(query.txType, TX_TYPE.STAKE); + assertEqual(query.isSpent, false); +}); + +test('yieldOutputs creates correct query', () => { + const query = yieldOutputs(); + assertEqual(query.txType, TX_TYPE.PROTOCOL); + assertEqual(query.isSpent, false); +}); + +test('incomingTxs creates correct query', () => { + const query = incomingTxs(); + assertEqual(query.isIncoming, true); +}); + +test('outgoingTxs creates correct query', () => { + const query = outgoingTxs(); + assertEqual(query.isOutgoing, true); +}); + +test('pendingTxs creates correct query', () => { + const query = pendingTxs(); + assertEqual(query.inTxPool, true); + assertEqual(query.isConfirmed, false); +}); + +test('confirmedTxs creates correct query', () => { + const query = confirmedTxs(); + assertEqual(query.isConfirmed, true); +}); + +test('stakingTxs creates correct query', () => { + const query = stakingTxs(); + assertEqual(query.txType, TX_TYPE.STAKE); +}); + +test('yieldTxs creates correct query', () => { + const query = yieldTxs(); + assertEqual(query.txType, TX_TYPE.PROTOCOL); +}); + +// ============================================================================ +// Complex Query Tests +// ============================================================================ + +console.log('\n--- Complex Queries ---'); + +test('filter array of outputs', () => { + const outputs = [ + { keyImage: 'ki1', isSpent: false, amount: 100n, assetType: 'SAL' }, + { keyImage: 'ki2', isSpent: true, amount: 200n, assetType: 'SAL' }, + { keyImage: 'ki3', isSpent: false, amount: 300n, assetType: 'USD' }, + { keyImage: 'ki4', isSpent: false, amount: 400n, assetType: 'SAL' }, + ]; + + const query = new OutputQuery({ isSpent: false, assetType: 'SAL' }); + const filtered = outputs.filter(o => query.matches(o)); + + assertEqual(filtered.length, 2); + assertEqual(filtered[0].keyImage, 'ki1'); + assertEqual(filtered[1].keyImage, 'ki4'); +}); + +test('filter array of transactions', () => { + const txs = [ + { txHash: 'tx1', isIncoming: true, isConfirmed: true, blockHeight: 100 }, + { txHash: 'tx2', isIncoming: false, isConfirmed: true, blockHeight: 200 }, + { txHash: 'tx3', isIncoming: true, isConfirmed: false, inPool: true }, + { txHash: 'tx4', isIncoming: true, isConfirmed: true, blockHeight: 300 }, + ]; + + const query = new TxQuery({ isIncoming: true, isConfirmed: true }); + const filtered = txs.filter(tx => query.matches(tx)); + + assertEqual(filtered.length, 2); + assertEqual(filtered[0].txHash, 'tx1'); + assertEqual(filtered[1].txHash, 'tx4'); +}); + +test('chain query modifications', () => { + // Start with unspent, add more criteria + const baseQuery = unspentOutputs(); + const refinedQuery = new OutputQuery({ + isSpent: baseQuery.isSpent, + assetType: 'SAL', + minAmount: 1000n + }); + + assertEqual(refinedQuery.isSpent, false); + assertEqual(refinedQuery.assetType, 'SAL'); + assertEqual(refinedQuery.minAmount, 1000n); +}); + +// ============================================================================ +// Summary +// ============================================================================ + +console.log('\n--- Summary ---'); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); + +if (failed > 0) { + console.log('\n✗ Some tests failed!'); + process.exit(1); +} else { + console.log('\n✓ All query system tests passed!'); + process.exit(0); +} diff --git a/test/wallet-store.test.js b/test/wallet-store.test.js new file mode 100644 index 0000000..debbce0 --- /dev/null +++ b/test/wallet-store.test.js @@ -0,0 +1,609 @@ +#!/usr/bin/env bun +/** + * Wallet Storage Tests + * + * Tests for wallet-store.js: + * - WalletOutput model + * - WalletTransaction model + * - MemoryStorage implementation + * - Storage queries and operations + */ + +import { + WalletStorage, + WalletOutput, + WalletTransaction, + MemoryStorage, + createStorage +} from '../src/wallet-store.js'; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (e) { + console.log(` ✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } +} + +async function testAsync(name, fn) { + try { + await fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (e) { + console.log(` ✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message || 'Assertion failed'); +} + +function assertEqual(actual, expected, message) { + if (actual !== expected) { + throw new Error(message || `Expected ${expected}, got ${actual}`); + } +} + +console.log('=== Wallet Storage Tests ===\n'); + +// ============================================================================ +// WalletOutput Tests +// ============================================================================ + +console.log('--- WalletOutput Model ---'); + +test('creates output with default values', () => { + const output = new WalletOutput(); + assertEqual(output.keyImage, null); + assertEqual(output.amount, 0n); + assertEqual(output.assetType, 'SAL'); + assertEqual(output.isSpent, false); + assertEqual(output.isFrozen, false); + assertEqual(output.txType, 3); +}); + +test('creates output with provided values', () => { + const output = new WalletOutput({ + keyImage: 'abc123', + publicKey: 'def456', + txHash: 'tx789', + outputIndex: 2, + blockHeight: 1000, + amount: 5000000000n, + assetType: 'SAL' + }); + assertEqual(output.keyImage, 'abc123'); + assertEqual(output.publicKey, 'def456'); + assertEqual(output.txHash, 'tx789'); + assertEqual(output.outputIndex, 2); + assertEqual(output.blockHeight, 1000); + assertEqual(output.amount, 5000000000n); +}); + +test('amount accepts number and converts to BigInt', () => { + const output = new WalletOutput({ amount: 1000 }); + assertEqual(output.amount, 1000n); +}); + +test('amount accepts string and converts to BigInt', () => { + const output = new WalletOutput({ amount: '999999999999' }); + assertEqual(output.amount, 999999999999n); +}); + +test('isUnlocked returns true for confirmed output with enough confirmations', () => { + const output = new WalletOutput({ + blockHeight: 100, + unlockTime: 0n + }); + assert(output.isUnlocked(115, 10), 'Should be unlocked at 15 confirmations'); + assert(output.isUnlocked(110, 10), 'Should be unlocked at exactly 10 confirmations'); +}); + +test('isUnlocked returns false for insufficient confirmations', () => { + const output = new WalletOutput({ + blockHeight: 100, + unlockTime: 0n + }); + assert(!output.isUnlocked(105, 10), 'Should not be unlocked at 5 confirmations'); + assert(!output.isUnlocked(109, 10), 'Should not be unlocked at 9 confirmations'); +}); + +test('isUnlocked handles block-height unlock time', () => { + const output = new WalletOutput({ + blockHeight: 100, + unlockTime: 200n // Locked until block 200 + }); + assert(!output.isUnlocked(150), 'Should be locked at height 150'); + assert(!output.isUnlocked(199), 'Should be locked at height 199'); + assert(output.isUnlocked(200), 'Should be unlocked at height 200'); + assert(output.isUnlocked(250), 'Should be unlocked at height 250'); +}); + +test('isUnlocked handles timestamp unlock time', () => { + const futureTime = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now + const pastTime = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago + + const lockedOutput = new WalletOutput({ + blockHeight: 100, + unlockTime: BigInt(futureTime) + }); + assert(!lockedOutput.isUnlocked(1000), 'Should be locked with future timestamp'); + + const unlockedOutput = new WalletOutput({ + blockHeight: 100, + unlockTime: BigInt(pastTime) + }); + assert(unlockedOutput.isUnlocked(1000), 'Should be unlocked with past timestamp'); +}); + +test('isSpendable requires key image', () => { + const outputNoKeyImage = new WalletOutput({ + blockHeight: 100, + amount: 1000n, + keyImage: null + }); + assert(!outputNoKeyImage.isSpendable(200), 'Should not be spendable without key image'); + + const outputWithKeyImage = new WalletOutput({ + blockHeight: 100, + amount: 1000n, + keyImage: 'abc123' + }); + assert(outputWithKeyImage.isSpendable(200), 'Should be spendable with key image'); +}); + +test('isSpendable returns false for spent outputs', () => { + const output = new WalletOutput({ + blockHeight: 100, + keyImage: 'abc123', + isSpent: true + }); + assert(!output.isSpendable(200), 'Spent output should not be spendable'); +}); + +test('isSpendable returns false for frozen outputs', () => { + const output = new WalletOutput({ + blockHeight: 100, + keyImage: 'abc123', + isFrozen: true + }); + assert(!output.isSpendable(200), 'Frozen output should not be spendable'); +}); + +test('toJSON serializes correctly', () => { + const output = new WalletOutput({ + keyImage: 'abc', + amount: 1234567890n, + unlockTime: 100n + }); + const json = output.toJSON(); + assertEqual(json.keyImage, 'abc'); + assertEqual(json.amount, '1234567890'); + assertEqual(json.unlockTime, '100'); + assertEqual(typeof json.amount, 'string'); +}); + +test('fromJSON deserializes correctly', () => { + const json = { + keyImage: 'xyz', + amount: '9876543210', + unlockTime: '500', + blockHeight: 1000 + }; + const output = WalletOutput.fromJSON(json); + assertEqual(output.keyImage, 'xyz'); + assertEqual(output.amount, 9876543210n); + assertEqual(output.unlockTime, 500n); + assertEqual(output.blockHeight, 1000); +}); + +test('round-trip JSON serialization preserves BigInt values', () => { + const original = new WalletOutput({ + amount: 123456789012345678901234n, + unlockTime: 999999999999n + }); + const json = original.toJSON(); + const restored = WalletOutput.fromJSON(json); + assertEqual(restored.amount, original.amount); + assertEqual(restored.unlockTime, original.unlockTime); +}); + +// ============================================================================ +// WalletTransaction Tests +// ============================================================================ + +console.log('\n--- WalletTransaction Model ---'); + +test('creates transaction with default values', () => { + const tx = new WalletTransaction(); + assertEqual(tx.txHash, null); + assertEqual(tx.incomingAmount, 0n); + assertEqual(tx.outgoingAmount, 0n); + assertEqual(tx.fee, 0n); + assertEqual(tx.isIncoming, false); + assertEqual(tx.isOutgoing, false); + assertEqual(tx.inPool, false); +}); + +test('creates transaction with provided values', () => { + const tx = new WalletTransaction({ + txHash: 'hash123', + blockHeight: 5000, + isIncoming: true, + incomingAmount: 10000000000n, + fee: 50000000n + }); + assertEqual(tx.txHash, 'hash123'); + assertEqual(tx.blockHeight, 5000); + assertEqual(tx.isIncoming, true); + assertEqual(tx.incomingAmount, 10000000000n); + assertEqual(tx.fee, 50000000n); +}); + +test('isConfirmed is true when blockHeight is set', () => { + const confirmedTx = new WalletTransaction({ blockHeight: 1000 }); + const unconfirmedTx = new WalletTransaction({ blockHeight: null }); + + assert(confirmedTx.isConfirmed, 'Should be confirmed with block height'); + assert(!unconfirmedTx.isConfirmed, 'Should not be confirmed without block height'); +}); + +test('getNetAmount calculates correctly', () => { + const tx = new WalletTransaction({ + incomingAmount: 10000n, + outgoingAmount: 3000n, + fee: 100n + }); + assertEqual(tx.getNetAmount(), 6900n); // 10000 - 3000 - 100 +}); + +test('getNetAmount handles negative result', () => { + const tx = new WalletTransaction({ + incomingAmount: 0n, + outgoingAmount: 5000n, + fee: 100n + }); + assertEqual(tx.getNetAmount(), -5100n); +}); + +test('toJSON serializes correctly', () => { + const tx = new WalletTransaction({ + txHash: 'abc', + incomingAmount: 1000000n, + fee: 10000n + }); + const json = tx.toJSON(); + assertEqual(json.txHash, 'abc'); + assertEqual(json.incomingAmount, '1000000'); + assertEqual(json.fee, '10000'); +}); + +test('fromJSON deserializes correctly', () => { + const json = { + txHash: 'xyz', + incomingAmount: '5000000', + outgoingAmount: '1000000', + fee: '50000', + changeAmount: '100000', + unlockTime: '0' + }; + const tx = WalletTransaction.fromJSON(json); + assertEqual(tx.txHash, 'xyz'); + assertEqual(tx.incomingAmount, 5000000n); + assertEqual(tx.outgoingAmount, 1000000n); + assertEqual(tx.fee, 50000n); + assertEqual(tx.changeAmount, 100000n); +}); + +// ============================================================================ +// MemoryStorage Tests +// ============================================================================ + +console.log('\n--- MemoryStorage ---'); + +await testAsync('open and close work', async () => { + const storage = new MemoryStorage(); + await storage.open(); + await storage.close(); +}); + +await testAsync('putOutput and getOutput work', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + const output = new WalletOutput({ + keyImage: 'ki_test_1', + amount: 1000000n, + blockHeight: 100 + }); + + await storage.putOutput(output); + const retrieved = await storage.getOutput('ki_test_1'); + + assertEqual(retrieved.keyImage, 'ki_test_1'); + assertEqual(retrieved.amount, 1000000n); + assertEqual(retrieved.blockHeight, 100); + + await storage.close(); +}); + +await testAsync('getOutput returns null for nonexistent key', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + const result = await storage.getOutput('nonexistent'); + assertEqual(result, null); + + await storage.close(); +}); + +await testAsync('getOutputs returns all outputs', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putOutput(new WalletOutput({ keyImage: 'ki1', amount: 100n })); + await storage.putOutput(new WalletOutput({ keyImage: 'ki2', amount: 200n })); + await storage.putOutput(new WalletOutput({ keyImage: 'ki3', amount: 300n })); + + const outputs = await storage.getOutputs(); + assertEqual(outputs.length, 3); + + await storage.close(); +}); + +await testAsync('getOutputs filters by isSpent', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putOutput(new WalletOutput({ keyImage: 'ki1', isSpent: false })); + await storage.putOutput(new WalletOutput({ keyImage: 'ki2', isSpent: true })); + await storage.putOutput(new WalletOutput({ keyImage: 'ki3', isSpent: false })); + + const unspent = await storage.getOutputs({ isSpent: false }); + const spent = await storage.getOutputs({ isSpent: true }); + + assertEqual(unspent.length, 2); + assertEqual(spent.length, 1); + + await storage.close(); +}); + +await testAsync('getOutputs filters by assetType', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putOutput(new WalletOutput({ keyImage: 'ki1', assetType: 'SAL' })); + await storage.putOutput(new WalletOutput({ keyImage: 'ki2', assetType: 'USD' })); + await storage.putOutput(new WalletOutput({ keyImage: 'ki3', assetType: 'SAL' })); + + const salOutputs = await storage.getOutputs({ assetType: 'SAL' }); + const usdOutputs = await storage.getOutputs({ assetType: 'USD' }); + + assertEqual(salOutputs.length, 2); + assertEqual(usdOutputs.length, 1); + + await storage.close(); +}); + +await testAsync('getOutputs filters by amount range', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putOutput(new WalletOutput({ keyImage: 'ki1', amount: 100n })); + await storage.putOutput(new WalletOutput({ keyImage: 'ki2', amount: 500n })); + await storage.putOutput(new WalletOutput({ keyImage: 'ki3', amount: 1000n })); + + const filtered = await storage.getOutputs({ minAmount: 200n, maxAmount: 800n }); + assertEqual(filtered.length, 1); + assertEqual(filtered[0].amount, 500n); + + await storage.close(); +}); + +await testAsync('getOutputs filters by account index', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putOutput(new WalletOutput({ keyImage: 'ki1', subaddressIndex: { major: 0, minor: 0 } })); + await storage.putOutput(new WalletOutput({ keyImage: 'ki2', subaddressIndex: { major: 1, minor: 0 } })); + await storage.putOutput(new WalletOutput({ keyImage: 'ki3', subaddressIndex: { major: 0, minor: 1 } })); + + const account0 = await storage.getOutputs({ accountIndex: 0 }); + const account1 = await storage.getOutputs({ accountIndex: 1 }); + + assertEqual(account0.length, 2); + assertEqual(account1.length, 1); + + await storage.close(); +}); + +await testAsync('markOutputSpent updates output', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putOutput(new WalletOutput({ keyImage: 'ki1', amount: 100n })); + + const before = await storage.getOutput('ki1'); + assertEqual(before.isSpent, false); + + await storage.markOutputSpent('ki1', 'spending_tx_hash', 500); + + const after = await storage.getOutput('ki1'); + assertEqual(after.isSpent, true); + assertEqual(after.spentTxHash, 'spending_tx_hash'); + assertEqual(after.spentHeight, 500); + + await storage.close(); +}); + +await testAsync('putTransaction and getTransaction work', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + const tx = new WalletTransaction({ + txHash: 'tx_test_1', + blockHeight: 1000, + incomingAmount: 5000000n + }); + + await storage.putTransaction(tx); + const retrieved = await storage.getTransaction('tx_test_1'); + + assertEqual(retrieved.txHash, 'tx_test_1'); + assertEqual(retrieved.blockHeight, 1000); + assertEqual(retrieved.incomingAmount, 5000000n); + + await storage.close(); +}); + +await testAsync('getTransactions filters by direction', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putTransaction(new WalletTransaction({ txHash: 'tx1', isIncoming: true, blockHeight: 100 })); + await storage.putTransaction(new WalletTransaction({ txHash: 'tx2', isOutgoing: true, blockHeight: 101 })); + await storage.putTransaction(new WalletTransaction({ txHash: 'tx3', isIncoming: true, blockHeight: 102 })); + + const incoming = await storage.getTransactions({ isIncoming: true }); + const outgoing = await storage.getTransactions({ isOutgoing: true }); + + assertEqual(incoming.length, 2); + assertEqual(outgoing.length, 1); + + await storage.close(); +}); + +await testAsync('getTransactions sorts by height descending', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putTransaction(new WalletTransaction({ txHash: 'tx1', blockHeight: 100 })); + await storage.putTransaction(new WalletTransaction({ txHash: 'tx3', blockHeight: 300 })); + await storage.putTransaction(new WalletTransaction({ txHash: 'tx2', blockHeight: 200 })); + + const txs = await storage.getTransactions(); + + assertEqual(txs[0].blockHeight, 300); + assertEqual(txs[1].blockHeight, 200); + assertEqual(txs[2].blockHeight, 100); + + await storage.close(); +}); + +await testAsync('sync height operations work', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + const initial = await storage.getSyncHeight(); + assertEqual(initial, 0); + + await storage.setSyncHeight(5000); + const updated = await storage.getSyncHeight(); + assertEqual(updated, 5000); + + await storage.close(); +}); + +await testAsync('state operations work', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + await storage.setState('testKey', { foo: 'bar', num: 123 }); + const value = await storage.getState('testKey'); + + assertEqual(value.foo, 'bar'); + assertEqual(value.num, 123); + + await storage.close(); +}); + +await testAsync('clear removes all data', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putOutput(new WalletOutput({ keyImage: 'ki1' })); + await storage.putTransaction(new WalletTransaction({ txHash: 'tx1' })); + await storage.setSyncHeight(1000); + + await storage.clear(); + + const outputs = await storage.getOutputs(); + const txs = await storage.getTransactions(); + const height = await storage.getSyncHeight(); + + assertEqual(outputs.length, 0); + assertEqual(txs.length, 0); + assertEqual(height, 0); + + await storage.close(); +}); + +await testAsync('key image tracking works', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + await storage.putKeyImage('ki1', { txHash: 'tx1', outputIndex: 0 }); + + const isSpentBefore = await storage.isKeyImageSpent('ki1'); + assertEqual(isSpentBefore, false); + + // Mark output spent (which also marks key image spent) + await storage.putOutput(new WalletOutput({ keyImage: 'ki1' })); + await storage.markOutputSpent('ki1', 'spending_tx'); + + const isSpentAfter = await storage.isKeyImageSpent('ki1'); + assertEqual(isSpentAfter, true); + + const spentKeyImages = await storage.getSpentKeyImages(); + assert(spentKeyImages.includes('ki1')); + + await storage.close(); +}); + +// ============================================================================ +// createStorage Factory Tests +// ============================================================================ + +console.log('\n--- createStorage Factory ---'); + +test('createStorage with type=memory returns MemoryStorage', () => { + const storage = createStorage({ type: 'memory' }); + assert(storage instanceof MemoryStorage); +}); + +test('createStorage with auto falls back to MemoryStorage (no IndexedDB)', () => { + // In Node/Bun environment, IndexedDB is not available + const storage = createStorage({ type: 'auto' }); + assert(storage instanceof MemoryStorage); +}); + +test('createStorage with no options returns MemoryStorage', () => { + const storage = createStorage(); + assert(storage instanceof MemoryStorage); +}); + +// ============================================================================ +// Summary +// ============================================================================ + +console.log('\n--- Summary ---'); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); + +if (failed > 0) { + console.log('\n✗ Some tests failed!'); + process.exit(1); +} else { + console.log('\n✓ All wallet storage tests passed!'); + process.exit(0); +} diff --git a/test/wallet-sync.test.js b/test/wallet-sync.test.js new file mode 100644 index 0000000..6d4cd1e --- /dev/null +++ b/test/wallet-sync.test.js @@ -0,0 +1,622 @@ +#!/usr/bin/env bun +/** + * Wallet Sync Engine Tests + * + * Tests for wallet-sync.js: + * - WalletSync class + * - Event system + * - Progress tracking + * - Mock daemon interaction + */ + +import { + WalletSync, + createWalletSync, + SYNC_STATUS, + DEFAULT_BATCH_SIZE, + SYNC_UNLOCK_BLOCKS +} from '../src/wallet-sync.js'; +import { MemoryStorage, WalletOutput } from '../src/wallet-store.js'; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (e) { + console.log(` ✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } +} + +async function testAsync(name, fn) { + try { + await fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (e) { + console.log(` ✗ ${name}`); + console.log(` Error: ${e.message}`); + failed++; + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message || 'Assertion failed'); +} + +function assertEqual(actual, expected, message) { + if (actual !== expected) { + throw new Error(message || `Expected ${expected}, got ${actual}`); + } +} + +// ============================================================================ +// Mock Daemon +// ============================================================================ + +class MockDaemon { + constructor(options = {}) { + this.height = options.height || 1000; + this.blocks = options.blocks || []; + this.transactions = options.transactions || {}; + this.callLog = []; + } + + async getInfo() { + this.callLog.push('getInfo'); + return { + success: true, + result: { + height: this.height, + status: 'OK' + } + }; + } + + async getBlockHeadersRange(start, end) { + this.callLog.push(`getBlockHeadersRange(${start}, ${end})`); + const headers = []; + for (let h = start; h <= end && h < this.height; h++) { + headers.push({ + height: h, + hash: `block_hash_${h}`, + timestamp: 1700000000 + h * 120 + }); + } + return { + success: true, + result: { headers } + }; + } + + async getBlock(opts) { + const height = opts.height; + this.callLog.push(`getBlock(${height})`); + return { + success: true, + result: { + block_header: { + height, + hash: `block_hash_${height}`, + timestamp: 1700000000 + height * 120 + }, + tx_hashes: this.blocks[height]?.txHashes || [], + miner_tx_hash: `miner_tx_${height}` + } + }; + } + + async getTransactions(txHashes, opts) { + this.callLog.push(`getTransactions([${txHashes.join(',')}])`); + const txs = txHashes.map(hash => ({ + tx_hash: hash, + as_hex: this.transactions[hash] || '00' + })); + return { + success: true, + result: { txs } + }; + } + + async getTransactionPool() { + this.callLog.push('getTransactionPool'); + return { + success: true, + result: { transactions: [] } + }; + } +} + +console.log('=== Wallet Sync Engine Tests ===\n'); + +// ============================================================================ +// Constants Tests +// ============================================================================ + +console.log('--- Constants ---'); + +test('SYNC_STATUS has correct values', () => { + assertEqual(SYNC_STATUS.IDLE, 'idle'); + assertEqual(SYNC_STATUS.SYNCING, 'syncing'); + assertEqual(SYNC_STATUS.COMPLETE, 'complete'); + assertEqual(SYNC_STATUS.ERROR, 'error'); +}); + +test('DEFAULT_BATCH_SIZE is 100', () => { + assertEqual(DEFAULT_BATCH_SIZE, 100); +}); + +test('SYNC_UNLOCK_BLOCKS is 10', () => { + assertEqual(SYNC_UNLOCK_BLOCKS, 10); +}); + +// ============================================================================ +// WalletSync Construction Tests +// ============================================================================ + +console.log('\n--- WalletSync Construction ---'); + +test('creates sync engine with options', () => { + const storage = new MemoryStorage(); + const daemon = new MockDaemon(); + const keys = { + viewSecretKey: new Uint8Array(32), + spendSecretKey: new Uint8Array(32), + spendPublicKey: new Uint8Array(32) + }; + + const sync = new WalletSync({ + storage, + daemon, + keys, + batchSize: 50 + }); + + assertEqual(sync.storage, storage); + assertEqual(sync.daemon, daemon); + assertEqual(sync.keys, keys); + assertEqual(sync.batchSize, 50); + assertEqual(sync.status, SYNC_STATUS.IDLE); +}); + +test('uses default batch size when not specified', () => { + const sync = new WalletSync({}); + assertEqual(sync.batchSize, DEFAULT_BATCH_SIZE); +}); + +test('createWalletSync factory works', () => { + const sync = createWalletSync({ + storage: new MemoryStorage(), + daemon: new MockDaemon() + }); + assert(sync instanceof WalletSync); +}); + +// ============================================================================ +// Event System Tests +// ============================================================================ + +console.log('\n--- Event System ---'); + +test('on adds event listener', () => { + const sync = new WalletSync({}); + let called = false; + + sync.on('test', () => { called = true; }); + sync._emit('test'); + + assert(called, 'Listener should be called'); +}); + +test('off removes event listener', () => { + const sync = new WalletSync({}); + let callCount = 0; + const handler = () => { callCount++; }; + + sync.on('test', handler); + sync._emit('test'); + assertEqual(callCount, 1); + + sync.off('test', handler); + sync._emit('test'); + assertEqual(callCount, 1); // Should not increase +}); + +test('multiple listeners can be added', () => { + const sync = new WalletSync({}); + const calls = []; + + sync.on('test', () => calls.push('a')); + sync.on('test', () => calls.push('b')); + sync._emit('test'); + + assertEqual(calls.length, 2); + assert(calls.includes('a')); + assert(calls.includes('b')); +}); + +test('event passes arguments to listener', () => { + const sync = new WalletSync({}); + let receivedArgs = null; + + sync.on('test', (a, b, c) => { receivedArgs = [a, b, c]; }); + sync._emit('test', 1, 'two', { three: 3 }); + + assertEqual(receivedArgs[0], 1); + assertEqual(receivedArgs[1], 'two'); + assertEqual(receivedArgs[2].three, 3); +}); + +test('listener errors are caught and logged', () => { + const sync = new WalletSync({}); + let secondCalled = false; + + sync.on('test', () => { throw new Error('Intentional error'); }); + sync.on('test', () => { secondCalled = true; }); + + // Should not throw and should continue to next listener + sync._emit('test'); + assert(secondCalled, 'Second listener should still be called'); +}); + +// ============================================================================ +// Progress Tracking Tests +// ============================================================================ + +console.log('\n--- Progress Tracking ---'); + +test('getProgress returns correct structure', () => { + const sync = new WalletSync({}); + sync.startHeight = 0; + sync.currentHeight = 500; + sync.targetHeight = 1000; + sync.status = SYNC_STATUS.SYNCING; + + const progress = sync.getProgress(); + + assertEqual(progress.status, SYNC_STATUS.SYNCING); + assertEqual(progress.currentHeight, 500); + assertEqual(progress.targetHeight, 1000); + assertEqual(progress.startHeight, 0); + assertEqual(progress.blocksProcessed, 500); + assertEqual(progress.blocksRemaining, 500); + assertEqual(progress.percentComplete, 50); +}); + +test('getProgress handles zero total blocks', () => { + const sync = new WalletSync({}); + sync.startHeight = 100; + sync.currentHeight = 100; + sync.targetHeight = 100; + + const progress = sync.getProgress(); + assertEqual(progress.percentComplete, 0); +}); + +test('getProgress caps percent at 100', () => { + const sync = new WalletSync({}); + sync.startHeight = 0; + sync.currentHeight = 1100; // Beyond target + sync.targetHeight = 1000; + + const progress = sync.getProgress(); + assertEqual(progress.percentComplete, 100); +}); + +// ============================================================================ +// Sync Control Tests +// ============================================================================ + +console.log('\n--- Sync Control ---'); + +await testAsync('start syncs from stored height', async () => { + const storage = new MemoryStorage(); + await storage.open(); + await storage.setSyncHeight(50); + + const daemon = new MockDaemon({ height: 100 }); + const sync = new WalletSync({ + storage, + daemon, + keys: {}, + batchSize: 200 // Larger than range to complete in one batch + }); + + await sync.start(); + + assertEqual(sync.status, SYNC_STATUS.COMPLETE); + assertEqual(sync.startHeight, 50); + assert(daemon.callLog.includes('getInfo')); + + await storage.close(); +}); + +await testAsync('start uses provided startHeight', async () => { + const storage = new MemoryStorage(); + await storage.open(); + await storage.setSyncHeight(50); // This should be ignored + + const daemon = new MockDaemon({ height: 100 }); + const sync = new WalletSync({ + storage, + daemon, + keys: {}, + batchSize: 200 + }); + + await sync.start(75); + + assertEqual(sync.startHeight, 75); + + await storage.close(); +}); + +await testAsync('start throws if already syncing', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + const daemon = new MockDaemon({ height: 1000000 }); // Very high to keep syncing + const sync = new WalletSync({ + storage, + daemon, + keys: {}, + batchSize: 1 + }); + + // Start sync but don't await + const syncPromise = sync.start(0); + + // Try to start again immediately + let threw = false; + try { + await sync.start(0); + } catch (e) { + threw = true; + assert(e.message.includes('Already syncing')); + } + + // Stop the original sync + sync.stop(); + try { + await syncPromise; + } catch (e) { + // Expected - sync was stopped + } + + assert(threw, 'Should throw when already syncing'); + + await storage.close(); +}); + +await testAsync('stop sets flag to halt sync', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + const sync = new WalletSync({ + storage, + daemon: new MockDaemon({ height: 100 }), + keys: {}, + batchSize: 10 + }); + + // Verify stop sets the flag + assertEqual(sync._stopRequested, false); + sync.stop(); + assertEqual(sync._stopRequested, true); + + await storage.close(); +}); + +await testAsync('rescan clears storage and restarts', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + // Add some data + await storage.putOutput(new WalletOutput({ keyImage: 'ki1' })); + await storage.setSyncHeight(500); + + const daemon = new MockDaemon({ height: 100 }); + const sync = new WalletSync({ + storage, + daemon, + keys: {}, + batchSize: 200 + }); + + await sync.rescan(0); + + // Storage should be cleared + const outputs = await storage.getOutputs(); + assertEqual(outputs.length, 0); + + // Sync should complete from 0 + assertEqual(sync.startHeight, 0); + + await storage.close(); +}); + +// ============================================================================ +// Event Emission Tests +// ============================================================================ + +console.log('\n--- Event Emissions ---'); + +await testAsync('emits syncStart event', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + const daemon = new MockDaemon({ height: 100 }); + const sync = new WalletSync({ + storage, + daemon, + keys: {}, + batchSize: 200 + }); + + let startEvent = null; + sync.on('syncStart', (data) => { startEvent = data; }); + + await sync.start(10); + + assert(startEvent !== null, 'syncStart should be emitted'); + assertEqual(startEvent.startHeight, 10); + assertEqual(startEvent.targetHeight, 100); + + await storage.close(); +}); + +await testAsync('emits syncComplete event', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + const daemon = new MockDaemon({ height: 100 }); + const sync = new WalletSync({ + storage, + daemon, + keys: {}, + batchSize: 200 + }); + + let completeEvent = null; + sync.on('syncComplete', (data) => { completeEvent = data; }); + + await sync.start(0); + + assert(completeEvent !== null, 'syncComplete should be emitted'); + assert(completeEvent.height >= 99, 'Should complete near target height'); + + await storage.close(); +}); + +await testAsync('emits syncProgress events', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + const daemon = new MockDaemon({ height: 50 }); + const sync = new WalletSync({ + storage, + daemon, + keys: {}, + batchSize: 10 // Small batches to get multiple progress events + }); + + const progressEvents = []; + sync.on('syncProgress', (data) => { progressEvents.push(data); }); + + await sync.start(0); + + assert(progressEvents.length > 0, 'Should emit progress events'); + // Verify progress increases + for (let i = 1; i < progressEvents.length; i++) { + assert( + progressEvents[i].currentHeight >= progressEvents[i - 1].currentHeight, + 'Progress should increase' + ); + } + + await storage.close(); +}); + +await testAsync('emits newBlock events', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + const daemon = new MockDaemon({ height: 10 }); + const sync = new WalletSync({ + storage, + daemon, + keys: {}, + batchSize: 20 + }); + + const blockEvents = []; + sync.on('newBlock', (data) => { blockEvents.push(data); }); + + await sync.start(0); + + assert(blockEvents.length > 0, 'Should emit newBlock events'); + assert(blockEvents[0].height !== undefined); + assert(blockEvents[0].hash !== undefined); + assert(blockEvents[0].timestamp !== undefined); + + await storage.close(); +}); + +await testAsync('emits syncError on daemon failure', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + const daemon = { + async getInfo() { + return { success: false, error: { message: 'Connection failed' } }; + } + }; + + const sync = new WalletSync({ + storage, + daemon, + keys: {}, + batchSize: 100 + }); + + let errorEvent = null; + sync.on('syncError', (error) => { errorEvent = error; }); + + try { + await sync.start(0); + } catch (e) { + // Expected + } + + assert(errorEvent !== null, 'syncError should be emitted'); + assertEqual(sync.status, SYNC_STATUS.ERROR); + + await storage.close(); +}); + +// ============================================================================ +// Mempool Scanning Tests +// ============================================================================ + +console.log('\n--- Mempool Scanning ---'); + +await testAsync('scanMempool returns empty array when pool is empty', async () => { + const storage = new MemoryStorage(); + await storage.open(); + + const daemon = new MockDaemon({ height: 100 }); + const sync = new WalletSync({ + storage, + daemon, + keys: { + viewSecretKey: new Uint8Array(32), + spendPublicKey: new Uint8Array(32) + } + }); + + const pending = await sync.scanMempool(); + assertEqual(pending.length, 0); + + await storage.close(); +}); + +// ============================================================================ +// Summary +// ============================================================================ + +console.log('\n--- Summary ---'); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); + +if (failed > 0) { + console.log('\n✗ Some tests failed!'); + process.exit(1); +} else { + console.log('\n✓ All wallet sync tests passed!'); + process.exit(0); +}