opnet
Version:
The perfect library for building Bitcoin-based applications.
1,341 lines (1,079 loc) • 78.1 kB
text/typescript
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