e751b6569a
- Add tests for wallet-store.js (40 tests: MemoryStorage, WalletOutput, WalletTransaction) - Add tests for wallet-sync.js (25 tests: sync engine, events, progress tracking) - Add tests for query.js (45 tests: OutputQuery, TxQuery, TransferQuery, presets) - Add tests for connection-manager.js (29 tests: ConnectionInfo, failover, events) - Add tests for offline.js (29 tests: unsigned/signed tx, key images, output export) - Add tests for multisig.js (39 tests: KEX, signers, accounts, tx sets) - Add tests for persistent-wallet.js (27 tests: lifecycle, balance, outputs, sync) - Update test/all.js to include new test suites - Rename SYNC_UNLOCK_BLOCKS to avoid export conflict with wallet.js
610 lines
18 KiB
JavaScript
610 lines
18 KiB
JavaScript
#!/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);
|
|
}
|