UNPKG

opnet

Version:

The perfect library for building Bitcoin-based applications.

1,341 lines (1,079 loc) 78.1 kB
import { describe, expect, it, beforeEach, vi, afterEach, beforeAll } from 'vitest'; import { UTXOsManager } from '../build/utxos/UTXOsManager.js'; import { UTXO } from '../build/bitcoin/UTXOs.js'; import { JSONRpcProvider } from '../build/providers/JSONRpcProvider.js'; import { networks } from '@btc-vision/bitcoin'; import type { IProviderForUTXO } from '../build/utxos/interfaces/IProviderForUTXO.js'; import type { JsonRpcPayload } from '../build/providers/interfaces/JSONRpc.js'; import type { JsonRpcResult, JsonRpcCallResult } from '../build/providers/interfaces/JSONRpcResult.js'; import type { RawIUTXOsData } from '../build/utxos/interfaces/IUTXOsManager.js'; /** * Helper to create a mock UTXO for testing */ function createMockUTXO( transactionId: string, outputIndex: number, value: bigint, isCSV: boolean = false, ): UTXO { const raw = Buffer.from('mock-raw-tx-data').toString('base64'); return new UTXO( { transactionId, outputIndex, value: value.toString(), scriptPubKey: { asm: 'mock-asm', hex: 'mock-hex', type: 'witness_v1_taproot', address: 'bc1p...', }, raw, }, isCSV, ); } /** * Helper to create mock raw UTXO data as returned by the RPC */ function createMockRawUTXOsData( confirmed: Array<{ txId: string; index: number; value: string }>, pending: Array<{ txId: string; index: number; value: string }> = [], spentTransactions: Array<{ txId: string; index: number }> = [], ): RawIUTXOsData { const rawTransactions: string[] = []; const mapToRawUTXO = (item: { txId: string; index: number; value: string }) => { const rawIndex = rawTransactions.length; rawTransactions.push(Buffer.from(`raw-tx-${item.txId}`).toString('base64')); return { transactionId: item.txId, outputIndex: item.index, value: item.value, scriptPubKey: { asm: 'mock-asm', hex: 'mock-hex', type: 'witness_v1_taproot' as const, address: 'bc1p...', }, raw: rawIndex, }; }; return { confirmed: confirmed.map(mapToRawUTXO), pending: pending.map(mapToRawUTXO), spentTransactions: spentTransactions.map((s) => ({ transactionId: s.txId, outputIndex: s.index, })), raw: rawTransactions, }; } /** * Create a mock provider for testing */ function createMockProvider(): IProviderForUTXO & { mockCallPayloadSingle: ReturnType<typeof vi.fn>; mockCallMultiplePayloads: ReturnType<typeof vi.fn>; mockBuildJsonRpcPayload: ReturnType<typeof vi.fn>; } { const mockCallPayloadSingle = vi.fn<(payload: JsonRpcPayload) => Promise<JsonRpcResult>>(); const mockCallMultiplePayloads = vi.fn<(payloads: JsonRpcPayload[]) => Promise<JsonRpcCallResult>>(); const mockBuildJsonRpcPayload = vi.fn((method: unknown, params: unknown) => ({ method, params, id: 1, jsonrpc: '2.0' as const, })); return { // @ts-expect-error - This is a mockup for mockBuildJsonRpcPayload buildJsonRpcPayload: mockBuildJsonRpcPayload, callPayloadSingle: mockCallPayloadSingle, callMultiplePayloads: mockCallMultiplePayloads, mockCallPayloadSingle, mockCallMultiplePayloads, mockBuildJsonRpcPayload, }; } describe('UTXOsManager - Ultra Complex Tests', () => { let manager: UTXOsManager; let mockProvider: ReturnType<typeof createMockProvider>; beforeEach(() => { vi.useFakeTimers(); mockProvider = createMockProvider(); manager = new UTXOsManager(mockProvider); }); afterEach(() => { vi.useRealTimers(); }); // ========================================== // SECTION 1: CONSTRUCTOR & BASIC STATE // ========================================== describe('constructor and initial state', () => { it('should create a new UTXOsManager instance with provider', () => { expect(manager).toBeInstanceOf(UTXOsManager); }); it('should start with no pending UTXOs for any address', () => { expect(manager.getPendingUTXOs('bc1q...')).toEqual([]); expect(manager.getPendingUTXOs('bc1p...')).toEqual([]); expect(manager.getPendingUTXOs('random-address')).toEqual([]); }); }); // ========================================== // SECTION 2: spentUTXO - COMPLEX SCENARIOS // ========================================== describe('spentUTXO - complex chain depth scenarios', () => { it('should correctly track chain depth when spending confirmed UTXOs', () => { const address = 'bc1qtest...'; // Spending a confirmed UTXO (not in pending) should result in depth 1 const confirmedUtxo = createMockUTXO('confirmed-tx', 0, 1000n); const newUtxo = createMockUTXO('new-tx', 0, 900n); manager.spentUTXO(address, [confirmedUtxo], [newUtxo]); const pending = manager.getPendingUTXOs(address); expect(pending).toHaveLength(1); expect(pending[0].transactionId).toBe('new-tx'); }); it('should calculate max depth from multiple parent UTXOs with different depths', () => { const address = 'bc1qtest...'; // Create a chain: tx0 -> tx1 -> tx2 (depth 3) const tx0 = createMockUTXO('tx0', 0, 1000n); manager.spentUTXO(address, [], [tx0]); // tx0 depth = 1 const tx1 = createMockUTXO('tx1', 0, 900n); manager.spentUTXO(address, [tx0], [tx1]); // tx1 depth = 2 const tx2 = createMockUTXO('tx2', 0, 800n); manager.spentUTXO(address, [tx1], [tx2]); // tx2 depth = 3 // Create parallel chain: tx3 (depth 1) const tx3 = createMockUTXO('tx3', 0, 500n); manager.spentUTXO(address, [], [tx3]); // tx3 depth = 1 // Now spend both tx2 (depth 3) and tx3 (depth 1) // Should use max depth (3) + 1 = 4 const tx4 = createMockUTXO('tx4', 0, 1200n); manager.spentUTXO(address, [tx2, tx3], [tx4]); // tx4 depth = 4 // Verify tx4 is the only pending UTXO now const pending = manager.getPendingUTXOs(address); expect(pending).toHaveLength(1); expect(pending[0].transactionId).toBe('tx4'); }); it('should handle spending multiple outputs from same transaction', () => { const address = 'bc1qtest...'; // Transaction with multiple outputs const output0 = createMockUTXO('multi-output-tx', 0, 500n); const output1 = createMockUTXO('multi-output-tx', 1, 300n); const output2 = createMockUTXO('multi-output-tx', 2, 200n); manager.spentUTXO(address, [], [output0, output1, output2]); expect(manager.getPendingUTXOs(address)).toHaveLength(3); // Spend all outputs in one transaction const consolidated = createMockUTXO('consolidated-tx', 0, 950n); manager.spentUTXO(address, [output0, output1, output2], [consolidated]); const pending = manager.getPendingUTXOs(address); expect(pending).toHaveLength(1); expect(pending[0].transactionId).toBe('consolidated-tx'); }); it('should correctly throw at exactly depth 26 (limit is 25)', () => { const address = 'bc1qtest...'; // Build chain of exactly 25 transactions let lastUTXO = createMockUTXO('tx0', 0, 1000n); manager.spentUTXO(address, [], [lastUTXO]); for (let i = 1; i < 25; i++) { const newUTXO = createMockUTXO(`tx${i}`, 0, 1000n); manager.spentUTXO(address, [lastUTXO], [newUTXO]); lastUTXO = newUTXO; } // This should throw (depth would be 26) const failingUTXO = createMockUTXO('tx25', 0, 1000n); expect(() => { manager.spentUTXO(address, [lastUTXO], [failingUTXO]); }).toThrow('too-long-mempool-chain'); }); it('should NOT throw at exactly depth 25 (the limit)', () => { const address = 'bc1qtest...'; // Build chain of exactly 24 transactions let lastUTXO = createMockUTXO('tx0', 0, 1000n); manager.spentUTXO(address, [], [lastUTXO]); for (let i = 1; i < 24; i++) { const newUTXO = createMockUTXO(`tx${i}`, 0, 1000n); manager.spentUTXO(address, [lastUTXO], [newUTXO]); lastUTXO = newUTXO; } // This should NOT throw (depth would be exactly 25) const successUTXO = createMockUTXO('tx24', 0, 1000n); expect(() => { manager.spentUTXO(address, [lastUTXO], [successUTXO]); }).not.toThrow(); }); it('should handle spending UTXO that was never in pending (confirmed)', () => { const address = 'bc1qtest...'; // Spend a UTXO that was never tracked as pending (it's confirmed) const confirmedUtxo = createMockUTXO('confirmed-tx', 0, 5000n); const newUtxo = createMockUTXO('new-from-confirmed', 0, 4900n); // This should work - the confirmed UTXO has depth 0 manager.spentUTXO(address, [confirmedUtxo], [newUtxo]); const pending = manager.getPendingUTXOs(address); expect(pending).toHaveLength(1); expect(pending[0].value).toBe(4900n); }); it('should handle empty spent and newUTXOs arrays', () => { const address = 'bc1qtest...'; // Add some pending UTXOs first manager.spentUTXO(address, [], [createMockUTXO('tx1', 0, 1000n)]); expect(manager.getPendingUTXOs(address)).toHaveLength(1); // Call with empty arrays - should not change state manager.spentUTXO(address, [], []); expect(manager.getPendingUTXOs(address)).toHaveLength(1); }); it('should handle complex tree structure of UTXOs', () => { const address = 'bc1qtest...'; // Create tree: // root (depth 1) // / \ // a(2) b(2) // / \ \ // c(3) d(3) e(3) const root = createMockUTXO('root', 0, 10000n); manager.spentUTXO(address, [], [root]); const a = createMockUTXO('a', 0, 5000n); const b = createMockUTXO('b', 0, 5000n); manager.spentUTXO(address, [root], [a, b]); const c = createMockUTXO('c', 0, 2500n); const d = createMockUTXO('d', 0, 2500n); manager.spentUTXO(address, [a], [c, d]); const e = createMockUTXO('e', 0, 5000n); manager.spentUTXO(address, [b], [e]); // Now c, d, e should be pending (all depth 3) const pending = manager.getPendingUTXOs(address); expect(pending).toHaveLength(3); // Consolidate all leaves const final = createMockUTXO('final', 0, 9000n); manager.spentUTXO(address, [c, d, e], [final]); // depth 4 expect(manager.getPendingUTXOs(address)).toHaveLength(1); expect(manager.getPendingUTXOs(address)[0].transactionId).toBe('final'); }); }); // ========================================== // SECTION 3: clean() - ALL SCENARIOS // ========================================== describe('clean - comprehensive scenarios', () => { it('should clean specific address data completely', () => { const address = 'bc1qtest...'; // Set up complex state const utxo1 = createMockUTXO('tx1', 0, 1000n); const utxo2 = createMockUTXO('tx2', 0, 2000n); manager.spentUTXO(address, [], [utxo1]); manager.spentUTXO(address, [utxo1], [utxo2]); expect(manager.getPendingUTXOs(address)).toHaveLength(1); // Clean the address manager.clean(address); expect(manager.getPendingUTXOs(address)).toHaveLength(0); }); it('should not affect other addresses when cleaning specific address', () => { const addr1 = 'bc1qaddr1...'; const addr2 = 'bc1qaddr2...'; manager.spentUTXO(addr1, [], [createMockUTXO('tx1', 0, 1000n)]); manager.spentUTXO(addr2, [], [createMockUTXO('tx2', 0, 2000n)]); manager.clean(addr1); expect(manager.getPendingUTXOs(addr1)).toHaveLength(0); expect(manager.getPendingUTXOs(addr2)).toHaveLength(1); }); it('should clean all addresses when no address specified', () => { const addresses = ['addr1', 'addr2', 'addr3', 'addr4', 'addr5']; for (let i = 0; i < addresses.length; i++) { manager.spentUTXO(addresses[i], [], [createMockUTXO(`tx${i}`, 0, 1000n * BigInt(i + 1))]); } // Verify all have pending UTXOs for (const addr of addresses) { expect(manager.getPendingUTXOs(addr)).toHaveLength(1); } // Clean all manager.clean(); // All should be empty for (const addr of addresses) { expect(manager.getPendingUTXOs(addr)).toHaveLength(0); } }); it('should reset fetch timestamp and cached data on clean', async () => { const address = 'bc1qtest...'; const mockData = createMockRawUTXOsData([ { txId: 'tx1', index: 0, value: '1000' }, ]); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); // Fetch to populate cache await manager.getUTXOs({ address }); expect(mockProvider.mockCallPayloadSingle).toHaveBeenCalledTimes(1); // Clean the address manager.clean(address); // Next fetch should hit the provider again (cache cleared) await manager.getUTXOs({ address }); expect(mockProvider.mockCallPayloadSingle).toHaveBeenCalledTimes(2); }); }); // ========================================== // SECTION 4: getUTXOs - ALL EDGE CASES // ========================================== describe('getUTXOs - comprehensive scenarios', () => { it('should pass olderThan parameter to provider', async () => { const address = 'bc1qtest...'; const mockData = createMockRawUTXOsData([]); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); await manager.getUTXOs({ address, olderThan: 12345n }); expect(mockProvider.mockBuildJsonRpcPayload).toHaveBeenCalledWith( expect.any(String), [address, true, '12345'], ); }); it('should handle isCSV parameter correctly', async () => { const address = 'bc1qtest...'; const mockData = createMockRawUTXOsData([ { txId: 'tx1', index: 0, value: '1000' }, ]); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); const utxos = await manager.getUTXOs({ address, isCSV: true }); expect(utxos).toHaveLength(1); expect(utxos[0].isCSV).toBe(true); }); it('should handle optimize=false parameter', async () => { const address = 'bc1qtest...'; const mockData = createMockRawUTXOsData([]); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); await manager.getUTXOs({ address, optimize: false }); expect(mockProvider.mockBuildJsonRpcPayload).toHaveBeenCalledWith( expect.any(String), [address, false], ); }); it('should correctly deduplicate confirmed UTXOs', async () => { const address = 'bc1qtest...'; // Create mock data with duplicate confirmed UTXOs const rawTransactions = [ Buffer.from('raw-tx-dup').toString('base64'), ]; const duplicateUtxo = { transactionId: 'dup-tx', outputIndex: 0, value: '1000', scriptPubKey: { asm: 'mock-asm', hex: 'mock-hex', type: 'witness_v1_taproot' as const, address: 'bc1p...', }, raw: 0, }; mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: { confirmed: [duplicateUtxo, duplicateUtxo], // Same UTXO twice pending: [], spentTransactions: [], raw: rawTransactions, }, jsonrpc: '2.0', id: 1, }); const utxos = await manager.getUTXOs({ address }); // Should only have 1 UTXO despite duplicates expect(utxos).toHaveLength(1); }); it('should correctly merge pending UTXOs that are also in fetched pending', async () => { const address = 'bc1qtest...'; // Add a local pending UTXO const localPending = createMockUTXO('local-pending', 0, 500n); manager.spentUTXO(address, [], [localPending]); // Mock returns same UTXO in pending (it's been broadcast but not confirmed) const mockData = createMockRawUTXOsData( [], // no confirmed [{ txId: 'local-pending', index: 0, value: '500' }], // same UTXO in pending ); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); const utxos = await manager.getUTXOs({ address, mergePendingUTXOs: true }); // Should only have 1 UTXO (deduplicated) expect(utxos).toHaveLength(1); }); it('should NOT filter fetched spent when filterSpentUTXOs is false', async () => { const address = 'bc1qtest...'; const mockData = createMockRawUTXOsData( [{ txId: 'tx1', index: 0, value: '1000' }], [], [{ txId: 'tx1', index: 0 }], // tx1 is marked as spent ); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); const utxos = await manager.getUTXOs({ address, filterSpentUTXOs: false }); // Should still have the UTXO since filterSpentUTXOs is false expect(utxos).toHaveLength(1); }); it('should use cached data within FETCH_COOLDOWN (10 seconds)', async () => { const address = 'bc1qtest...'; const mockData = createMockRawUTXOsData([ { txId: 'tx1', index: 0, value: '1000' }, ]); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); // First call await manager.getUTXOs({ address }); expect(mockProvider.mockCallPayloadSingle).toHaveBeenCalledTimes(1); // Advance 5 seconds (less than 10s cooldown) vi.advanceTimersByTime(5000); // Second call - should use cache await manager.getUTXOs({ address }); expect(mockProvider.mockCallPayloadSingle).toHaveBeenCalledTimes(1); // Advance another 6 seconds (total 11s, past cooldown) vi.advanceTimersByTime(6000); // Third call - should fetch again await manager.getUTXOs({ address }); expect(mockProvider.mockCallPayloadSingle).toHaveBeenCalledTimes(2); }); it('should trigger AUTO_PURGE after 1 minute', async () => { const address = 'bc1qtest...'; // Add some pending UTXOs manager.spentUTXO(address, [], [createMockUTXO('pending-tx', 0, 500n)]); expect(manager.getPendingUTXOs(address)).toHaveLength(1); const mockData = createMockRawUTXOsData([ { txId: 'tx1', index: 0, value: '1000' }, ]); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); // First fetch await manager.getUTXOs({ address }); // Advance time past AUTO_PURGE_AFTER (1 minute) vi.advanceTimersByTime(61000); // This should trigger auto-purge which cleans the address await manager.getUTXOs({ address }); // Pending UTXOs should be cleared by auto-purge expect(manager.getPendingUTXOs(address)).toHaveLength(0); }); it('should handle fetched pending UTXOs that are new', async () => { const address = 'bc1qtest...'; // No local pending UTXOs const mockData = createMockRawUTXOsData( [], // no confirmed [ { txId: 'fetched-pending-1', index: 0, value: '1000' }, { txId: 'fetched-pending-2', index: 0, value: '2000' }, ], ); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); const utxos = await manager.getUTXOs({ address, mergePendingUTXOs: true }); expect(utxos).toHaveLength(2); }); it('should filter locally spent UTXOs even if returned by provider', async () => { const address = 'bc1qtest...'; // Mark a UTXO as spent locally const spentUtxo = createMockUTXO('locally-spent', 0, 1000n); manager.spentUTXO(address, [spentUtxo], [createMockUTXO('new-tx', 0, 900n)]); // Provider still returns the spent UTXO as confirmed (race condition) const mockData = createMockRawUTXOsData([ { txId: 'locally-spent', index: 0, value: '1000' }, { txId: 'other-tx', index: 0, value: '2000' }, ]); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); const utxos = await manager.getUTXOs({ address }); // locally-spent should be filtered out expect(utxos).toHaveLength(2); // other-tx + new-tx (pending) expect(utxos.map(u => u.transactionId)).not.toContain('locally-spent'); }); }); // ========================================== // SECTION 5: getUTXOsForAmount - ALL SCENARIOS // ========================================== describe('getUTXOsForAmount - comprehensive scenarios', () => { it('should prioritize CSV UTXOs when csvAddress is provided', async () => { const address = 'bc1qtest...'; const csvAddress = 'bc1qcsv...'; // CSV address returns some UTXOs const csvMockData = createMockRawUTXOsData([ { txId: 'csv-tx', index: 0, value: '5000' }, ]); // Regular address returns some UTXOs const regularMockData = createMockRawUTXOsData([ { txId: 'regular-tx', index: 0, value: '3000' }, ]); mockProvider.mockCallPayloadSingle .mockResolvedValueOnce({ result: csvMockData, jsonrpc: '2.0', id: 1 }) .mockResolvedValueOnce({ result: regularMockData, jsonrpc: '2.0', id: 2 }); const utxos = await manager.getUTXOsForAmount({ address, amount: 7000n, csvAddress, }); // Should have both CSV and regular UTXOs expect(utxos).toHaveLength(2); // CSV UTXOs are fetched first, so csv-tx should be first expect(utxos[0].transactionId).toBe('csv-tx'); }); it('should stop collecting when amount is reached mid-iteration', async () => { const address = 'bc1qtest...'; const mockData = createMockRawUTXOsData([ { txId: 'tx1', index: 0, value: '1000' }, { txId: 'tx2', index: 0, value: '2000' }, { txId: 'tx3', index: 0, value: '3000' }, { txId: 'tx4', index: 0, value: '4000' }, ]); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); // Need 2500 - should get tx1 (1000) + tx2 (2000) = 3000 const utxos = await manager.getUTXOsForAmount({ address, amount: 2500n, }); expect(utxos).toHaveLength(2); expect(utxos.map(u => u.transactionId)).toEqual(['tx1', 'tx2']); }); it('should respect maxUTXOs even when amount not reached', async () => { const address = 'bc1qtest...'; const mockData = createMockRawUTXOsData([ { txId: 'tx1', index: 0, value: '100' }, { txId: 'tx2', index: 0, value: '100' }, { txId: 'tx3', index: 0, value: '100' }, { txId: 'tx4', index: 0, value: '100' }, ]); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); const utxos = await manager.getUTXOsForAmount({ address, amount: 1000n, // Would need all 4, but maxUTXOs is 2 maxUTXOs: 2, }); expect(utxos).toHaveLength(2); }); it('should throw with correct message when UTXOs limit reached', async () => { const address = 'bc1qtest...'; const mockData = createMockRawUTXOsData([ { txId: 'tx1', index: 0, value: '100' }, { txId: 'tx2', index: 0, value: '100' }, { txId: 'tx3', index: 0, value: '100' }, ]); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); await expect( manager.getUTXOsForAmount({ address, amount: 1000n, maxUTXOs: 2, throwIfUTXOsLimitReached: true, }), ).rejects.toThrow('consolidate your UTXOs'); }); it('should include total UTXO count in error message', async () => { const address = 'bc1qtest...'; // Create many UTXOs const utxoData = Array.from({ length: 50 }, (_, i) => ({ txId: `tx${i}`, index: 0, value: '10', })); const mockData = createMockRawUTXOsData(utxoData); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); try { await manager.getUTXOsForAmount({ address, amount: 10000n, maxUTXOs: 5, throwIfUTXOsLimitReached: true, }); expect.fail('Should have thrown'); } catch (e: unknown) { expect((e as Error).message).toContain('50'); // Should include total count } }); it('should correctly calculate insufficient UTXOs error', async () => { const address = 'bc1qtest...'; const mockData = createMockRawUTXOsData([ { txId: 'tx1', index: 0, value: '1000' }, { txId: 'tx2', index: 0, value: '500' }, ]); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); try { await manager.getUTXOsForAmount({ address, amount: 5000n, throwErrors: true, }); expect.fail('Should have thrown'); } catch (e: unknown) { const message = (e as Error).message; expect(message).toContain('Available: 1500'); expect(message).toContain('Needed: 5000'); } }); it('should handle maxUTXOs=0 (no limit)', async () => { const address = 'bc1qtest...'; const utxoData = Array.from({ length: 100 }, (_, i) => ({ txId: `tx${i}`, index: 0, value: '10', })); const mockData = createMockRawUTXOsData(utxoData); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); const utxos = await manager.getUTXOsForAmount({ address, amount: 900n, // Need 90 UTXOs maxUTXOs: 0, // No limit }); expect(utxos).toHaveLength(90); }); }); // ========================================== // SECTION 6: getMultipleUTXOs - ALL SCENARIOS // ========================================== describe('getMultipleUTXOs - comprehensive scenarios', () => { it('should handle large batch of addresses', async () => { const addresses = Array.from({ length: 20 }, (_, i) => `addr${i}`); const mockResponses = addresses.map((addr, i) => ({ result: createMockRawUTXOsData([ { txId: `tx-${addr}`, index: 0, value: String((i + 1) * 1000) }, ]), jsonrpc: '2.0' as const, id: i + 1, })); mockProvider.mockCallMultiplePayloads.mockResolvedValue(mockResponses); const result = await manager.getMultipleUTXOs({ requests: addresses.map(addr => ({ address: addr })), }); expect(Object.keys(result)).toHaveLength(20); expect(result['addr0'][0].value).toBe(1000n); expect(result['addr19'][0].value).toBe(20000n); }); it('should correctly handle filterSpentUTXOs=false', async () => { const address = 'bc1qtest...'; // Mark UTXO as locally spent const spentUtxo = createMockUTXO('spent-tx', 0, 1000n); manager.spentUTXO(address, [spentUtxo], []); const mockData = createMockRawUTXOsData( [{ txId: 'spent-tx', index: 0, value: '1000' }], [], [{ txId: 'spent-tx', index: 0 }], ); mockProvider.mockCallMultiplePayloads.mockResolvedValue([ { result: mockData, jsonrpc: '2.0', id: 1 }, ]); const result = await manager.getMultipleUTXOs({ requests: [{ address }], filterSpentUTXOs: false, }); // Should still filter locally spent, but not fetched spent // Actually looking at the code, locally spent is always filtered // The filterSpentUTXOs only affects fetchedSpentKeys expect(result[address]).toHaveLength(0); // locally spent is always filtered }); it('should handle missing fetchedData for an address', async () => { const address = 'bc1qtest...'; // Return empty result map (no data for address) mockProvider.mockCallMultiplePayloads.mockResolvedValue([ { result: undefined, jsonrpc: '2.0', id: 1 }, ]); const result = await manager.getMultipleUTXOs({ requests: [{ address }], }); // Should return empty array for that address expect(result[address]).toEqual([]); }); it('should sync pending depth with fetched data', async () => { const address = 'bc1qtest...'; // Add pending UTXO locally const pendingUtxo = createMockUTXO('pending-tx', 0, 500n); manager.spentUTXO(address, [], [pendingUtxo]); expect(manager.getPendingUTXOs(address)).toHaveLength(1); // Mock returns this UTXO as confirmed (it got confirmed) const mockData = createMockRawUTXOsData([ { txId: 'pending-tx', index: 0, value: '500' }, ]); mockProvider.mockCallMultiplePayloads.mockResolvedValue([ { result: mockData, jsonrpc: '2.0', id: 1 }, ]); await manager.getMultipleUTXOs({ requests: [{ address }], }); // Pending should be removed since it's now confirmed expect(manager.getPendingUTXOs(address)).toHaveLength(0); }); it('should throw on global error in batch response', async () => { mockProvider.mockCallMultiplePayloads.mockResolvedValue({ error: { code: -32000, message: 'Server error' }, } as unknown as JsonRpcCallResult); await expect( manager.getMultipleUTXOs({ requests: [{ address: 'addr1' }], }), ).rejects.toThrow('Error fetching UTXOs'); }); it('should throw on per-address error in batch response', async () => { const addresses = ['addr1', 'addr2']; mockProvider.mockCallMultiplePayloads.mockResolvedValue([ { result: createMockRawUTXOsData([]), jsonrpc: '2.0', id: 1 }, { error: { code: -1, message: 'Address not found' }, jsonrpc: '2.0', id: 2 }, ] as unknown as JsonRpcCallResult); await expect( manager.getMultipleUTXOs({ requests: addresses.map(addr => ({ address: addr })), }), ).rejects.toThrow('Error fetching UTXOs for addr2'); }); it('should correctly pass optimize parameter per request', async () => { const mockData = createMockRawUTXOsData([]); mockProvider.mockCallMultiplePayloads.mockResolvedValue([ { result: mockData, jsonrpc: '2.0', id: 1 }, { result: mockData, jsonrpc: '2.0', id: 2 }, ]); await manager.getMultipleUTXOs({ requests: [ { address: 'addr1', optimize: true }, { address: 'addr2', optimize: false }, ], }); expect(mockProvider.mockBuildJsonRpcPayload).toHaveBeenNthCalledWith( 1, expect.any(String), ['addr1', true], ); expect(mockProvider.mockBuildJsonRpcPayload).toHaveBeenNthCalledWith( 2, expect.any(String), ['addr2', false], ); }); it('should handle mergePendingUTXOs=false correctly', async () => { const address = 'bc1qtest...'; // Add local pending UTXO manager.spentUTXO(address, [], [createMockUTXO('local-pending', 0, 500n)]); const mockData = createMockRawUTXOsData( [{ txId: 'confirmed', index: 0, value: '1000' }], [{ txId: 'remote-pending', index: 0, value: '2000' }], ); mockProvider.mockCallMultiplePayloads.mockResolvedValue([ { result: mockData, jsonrpc: '2.0', id: 1 }, ]); const result = await manager.getMultipleUTXOs({ requests: [{ address }], mergePendingUTXOs: false, }); // Should only have confirmed UTXOs expect(result[address]).toHaveLength(1); expect(result[address][0].transactionId).toBe('confirmed'); }); }); // ========================================== // SECTION 7: parseUTXO - ERROR SCENARIOS // ========================================== describe('parseUTXO - error scenarios', () => { it('should throw when raw index is undefined', async () => { const address = 'bc1qtest...'; mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: { confirmed: [{ transactionId: 'tx1', outputIndex: 0, value: '1000', scriptPubKey: { asm: '', hex: '', type: 'witness_v1_taproot', address: '' }, raw: undefined, // Missing raw index }], pending: [], spentTransactions: [], raw: ['some-raw-data'], }, jsonrpc: '2.0', id: 1, }); await expect(manager.getUTXOs({ address })).rejects.toThrow('Missing raw index field'); }); it('should throw when raw index is null', async () => { const address = 'bc1qtest...'; mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: { confirmed: [{ transactionId: 'tx1', outputIndex: 0, value: '1000', scriptPubKey: { asm: '', hex: '', type: 'witness_v1_taproot', address: '' }, raw: null, // Null raw index }], pending: [], spentTransactions: [], raw: ['some-raw-data'], }, jsonrpc: '2.0', id: 1, }); await expect(manager.getUTXOs({ address })).rejects.toThrow('Missing raw index field'); }); it('should throw when raw index points to non-existent entry', async () => { const address = 'bc1qtest...'; mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: { confirmed: [{ transactionId: 'tx1', outputIndex: 0, value: '1000', scriptPubKey: { asm: '', hex: '', type: 'witness_v1_taproot', address: '' }, raw: 99, // Invalid index }], pending: [], spentTransactions: [], raw: ['only-one-entry'], // Only index 0 exists }, jsonrpc: '2.0', id: 1, }); await expect(manager.getUTXOs({ address })).rejects.toThrow('Invalid raw index 99'); }); }); // ========================================== // SECTION 8: syncPendingDepthWithFetched // ========================================== describe('syncPendingDepthWithFetched - scenarios', () => { it('should remove pending UTXO when it becomes confirmed', async () => { const address = 'bc1qtest...'; // Add pending UTXO const pendingUtxo = createMockUTXO('pending-now-confirmed', 0, 1000n); manager.spentUTXO(address, [], [pendingUtxo]); expect(manager.getPendingUTXOs(address)).toHaveLength(1); // Fetch returns it as confirmed const mockData = createMockRawUTXOsData([ { txId: 'pending-now-confirmed', index: 0, value: '1000' }, ]); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); await manager.getUTXOs({ address }); // Should no longer be in pending expect(manager.getPendingUTXOs(address)).toHaveLength(0); }); it('should remove pending UTXO when it becomes spent', async () => { const address = 'bc1qtest...'; // Add pending UTXO const pendingUtxo = createMockUTXO('pending-now-spent', 0, 1000n); manager.spentUTXO(address, [], [pendingUtxo]); expect(manager.getPendingUTXOs(address)).toHaveLength(1); // Fetch returns it as spent const mockData = createMockRawUTXOsData( [], [], [{ txId: 'pending-now-spent', index: 0 }], ); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); await manager.getUTXOs({ address }); // Should no longer be in pending expect(manager.getPendingUTXOs(address)).toHaveLength(0); }); it('should keep pending UTXO when still pending', async () => { const address = 'bc1qtest...'; // Add pending UTXO const pendingUtxo = createMockUTXO('still-pending', 0, 1000n); manager.spentUTXO(address, [], [pendingUtxo]); expect(manager.getPendingUTXOs(address)).toHaveLength(1); // Fetch returns it as pending too const mockData = createMockRawUTXOsData( [], [{ txId: 'still-pending', index: 0, value: '1000' }], ); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); await manager.getUTXOs({ address }); // Should still be in pending expect(manager.getPendingUTXOs(address)).toHaveLength(1); }); it('should handle no fetched data gracefully', () => { const address = 'bc1qtest...'; // Add pending UTXO manager.spentUTXO(address, [], [createMockUTXO('pending', 0, 1000n)]); // Manually call sync with no cached data // This tests the early return in syncPendingDepthWithFetched // We can trigger this by cleaning and checking pending without fetching manager.clean(address); // Pending should be cleared by clean expect(manager.getPendingUTXOs(address)).toHaveLength(0); }); }); // ========================================== // SECTION 9: COMPLEX INTEGRATION SCENARIOS // ========================================== describe('complex integration scenarios', () => { it('should handle complete transaction lifecycle', async () => { const address = 'bc1qwallet...'; // Step 1: Initial fetch shows 3 confirmed UTXOs const initialData = createMockRawUTXOsData([ { txId: 'utxo1', index: 0, value: '10000' }, { txId: 'utxo2', index: 0, value: '20000' }, { txId: 'utxo3', index: 0, value: '30000' }, ]); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: initialData, jsonrpc: '2.0', id: 1, }); let utxos = await manager.getUTXOs({ address }); expect(utxos).toHaveLength(3); // Step 2: User creates a transaction spending utxo1 and utxo2 const spent = [ createMockUTXO('utxo1', 0, 10000n), createMockUTXO('utxo2', 0, 20000n), ]; const change = createMockUTXO('my-change-tx', 0, 29500n); manager.spentUTXO(address, spent, [change]); // Step 3: getUTXOs should now show utxo3 + change (filtering spent) utxos = await manager.getUTXOs({ address }); expect(utxos).toHaveLength(2); expect(utxos.map(u => u.transactionId)).toContain('utxo3'); expect(utxos.map(u => u.transactionId)).toContain('my-change-tx'); // Step 4: Advance time past cooldown and fetch again vi.advanceTimersByTime(11000); // Provider now shows the change as confirmed const updatedData = createMockRawUTXOsData([ { txId: 'utxo3', index: 0, value: '30000' }, { txId: 'my-change-tx', index: 0, value: '29500' }, ]); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: updatedData, jsonrpc: '2.0', id: 1, }); utxos = await manager.getUTXOs({ address }); expect(utxos).toHaveLength(2); // Step 5: Change should no longer be in pending (now confirmed) expect(manager.getPendingUTXOs(address)).toHaveLength(0); }); it('should handle multiple addresses with interleaved operations', async () => { const alice = 'bc1qalice...'; const bob = 'bc1qbob...'; // Initial state for both const aliceData = createMockRawUTXOsData([ { txId: 'alice-utxo', index: 0, value: '50000' }, ]); const bobData = createMockRawUTXOsData([ { txId: 'bob-utxo', index: 0, value: '75000' }, ]); mockProvider.mockCallMultiplePayloads.mockResolvedValue([ { result: aliceData, jsonrpc: '2.0', id: 1 }, { result: bobData, jsonrpc: '2.0', id: 2 }, ]); const result = await manager.getMultipleUTXOs({ requests: [{ address: alice }, { address: bob }], }); expect(result[alice]).toHaveLength(1); expect(result[bob]).toHaveLength(1); // Alice sends to Bob const aliceSpent = createMockUTXO('alice-utxo', 0, 50000n); const aliceChange = createMockUTXO('alice-to-bob-tx', 0, 49000n); manager.spentUTXO(alice, [aliceSpent], [aliceChange]); // Bob receives (as pending) const bobReceive = createMockUTXO('alice-to-bob-tx', 1, 1000n); manager.spentUTXO(bob, [], [bobReceive]); // Verify both states expect(manager.getPendingUTXOs(alice)).toHaveLength(1); expect(manager.getPendingUTXOs(alice)[0].transactionId).toBe('alice-to-bob-tx'); expect(manager.getPendingUTXOs(bob)).toHaveLength(1); expect(manager.getPendingUTXOs(bob)[0].transactionId).toBe('alice-to-bob-tx'); }); it('should handle rapid fire transactions correctly', async () => { const address = 'bc1qrapidfire...'; const mockData = createMockRawUTXOsData([ { txId: 'base-utxo', index: 0, value: '100000' }, ]); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); await manager.getUTXOs({ address }); // Rapid succession of spend operations let lastUtxo = createMockUTXO('base-utxo', 0, 100000n); for (let i = 0; i < 10; i++) { const newUtxo = createMockUTXO(`rapid-tx-${i}`, 0, BigInt(100000 - (i + 1) * 500)); manager.spentUTXO(address, [lastUtxo], [newUtxo]); lastUtxo = newUtxo; } // Should have exactly 1 pending UTXO (the last one) const pending = manager.getPendingUTXOs(address); expect(pending).toHaveLength(1); expect(pending[0].transactionId).toBe('rapid-tx-9'); expect(pending[0].value).toBe(95000n); }); it('should correctly handle UTXO consolidation scenario', async () => { const address = 'bc1qconsolidate...'; // Start with many small UTXOs const smallUtxos = Array.from({ length: 50 }, (_, i) => ({ txId: `small-utxo-${i}`, index: 0, value: '100', })); const mockData = createMockRawUTXOsData(smallUtxos); mockProvider.mockCallPayloadSingle.mockResolvedValue({ result: mockData, jsonrpc: '2.0', id: 1, }); // Get UTXOs for consolidation const utxos = await manager.getUTXOsForAmount({ address, amount: 4000n, maxUTXOs: 40, }); expect(utxos).toHaveLength(40); // Consolidate all into one const consolidated = createMockUTXO('consolidated-tx', 0, 3900n); manager.spentUTXO(address, utxos, [consolidated]); // Verify consolid