import { SigHash } from '@scure/btc-signer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { PoolErrorException } from '../../error/pool.error'; import { Collection, Wallet } from '../../wallet/wallet.dto'; import { DUST_LIMIT } from '../constants'; import { findBtc, findCollection, getBtcAndCollectionAmountsFromUtxos, } from '../finder'; const ANCESTOR_COUNT_TRESHOLD = 70000; const ANCESTOR_COUNT_LIMIT = 95000; const DESCENDANT_COUNT_LIMIT = 22; class TxBulder { inputs: any[] = []; outputs: any[] = []; calculateTxBytes = vi.fn(() => 100); addInput = vi.fn((input) => { this.inputs.push(input); }); addOutput = vi.fn((output) => { this.outputs.push(output); }); } interface TestUtxo { txid: string; vout: number; value: bigint; collectionStatuses: any[]; hasInscription: boolean; status: { confirmed: boolean; }; } const createTestUtxo = ( txid: string, value: bigint, confirmed = true, collectionStatuses: { id: string; amount: bigint }[] = [], hasInscription = false, ): TestUtxo => { return { txid: txid, value: value, vout: 0, collectionStatuses, hasInscription, status: { confirmed, }, }; }; const createTestWallet = (utxos: TestUtxo[]): Wallet => ({ address: 'dummyWalletAddress', balance: 1000000n, utxos: utxos as any, }); const testCollection: Collection = { id: 'col1', type: 'rune', }; const dummyMempoolStatusReachedAncestors = { fees: { ancestor: 0, base: 0, modified: 0 }, ancestorsCount: 0, descendantsCount: 0, ancestorsSize: ANCESTOR_COUNT_LIMIT, descendantsSize: 0, }; const dummyMempoolStatusReachedDescendants = { fees: { ancestor: 0, base: 0, modified: 0 }, ancestorsCount: 0, descendantsCount: DESCENDANT_COUNT_LIMIT, ancestorsSize: 0, descendantsSize: 0, }; // We'll use a fresh usedUtxos array in each test. let usedUtxos: Array; let walletMempoolStatus: Map; let usedUtxosMempoolStatus: Map; let txBuilder: TxBulder; let testWallet = createTestWallet([]); const dummyPubKey = '02c0dedb1d4ef7a5a645e5b32023a8f2cd6902ff8c3b39ed9a0e1e8e89f8d4c3a9'; describe('findBtc', () => { beforeEach(() => { txBuilder = new TxBulder(); usedUtxos = []; testWallet.utxos = []; walletMempoolStatus = new Map(); usedUtxosMempoolStatus = new Map(); }); it('Filters out UTXOs with collection statuses and inscriptions', () => { testWallet.utxos = [ // UTXO with a collection – should be filtered out. createTestUtxo('tx1', 1000n, true, [{ id: 'col1', amount: 500n }], false), // UTXO with inscription – should be filtered out. createTestUtxo('tx2', 2000n, true, [], true), // Valid UTXO. createTestUtxo('tx3', 3000n, true), ]; const result = findBtc( testWallet, walletMempoolStatus, usedUtxos, usedUtxosMempoolStatus, 500n, 2n, txBuilder as any, SigHash.ALL, dummyPubKey, DUST_LIMIT, ); expect(result.utxos).toHaveLength(1); expect(result.utxos[0].txid).toBe('tx3'); }); it('Filters out UTXOs with reached limits', () => { testWallet.utxos = [ // UTXO reached ancestors limit. createTestUtxo('tx1', 1000n, true), // Valid UTXO. createTestUtxo('tx2', 3000n, true), // UTXO reached descendant limit. createTestUtxo('tx3', 1000n, true), ]; walletMempoolStatus.set('tx1', dummyMempoolStatusReachedAncestors); walletMempoolStatus.set('tx3', dummyMempoolStatusReachedDescendants); const result = findBtc( testWallet, walletMempoolStatus, usedUtxos, usedUtxosMempoolStatus, 500n, 2n, txBuilder as any, SigHash.ALL, dummyPubKey, DUST_LIMIT, ); expect(result.utxos).toHaveLength(1); expect(result.utxos[0].txid).toBe('tx2'); }); it('Filters out UTXOs already present in usedUtxos', () => { const utxo1 = createTestUtxo('tx1', 1000n, true); const utxo2 = createTestUtxo('tx2', 2000n, true); testWallet.utxos = [utxo1, utxo2]; usedUtxos.push(utxo1); const result = findBtc( testWallet, walletMempoolStatus, usedUtxos, usedUtxosMempoolStatus, 500n, 2n, txBuilder as any, SigHash.ALL, dummyPubKey, DUST_LIMIT, ); expect(result.utxos).toHaveLength(1); expect(result.utxos[0].txid).toBe('tx2'); }); it('Filters out UTXOs below the dust limit', () => { const utxo1 = createTestUtxo('tx1', 500n, true); const utxo2 = createTestUtxo('tx2', 1000n, true); testWallet.utxos = [utxo1, utxo2]; const result = findBtc( testWallet, walletMempoolStatus, usedUtxos, usedUtxosMempoolStatus, 500n, 2n, txBuilder as any, SigHash.ALL, dummyPubKey, DUST_LIMIT, ); expect(result.utxos).toHaveLength(1); expect(result.utxos[0].txid).toBe('tx2'); }); it('Sorts UTXOs so that those reaching the ancestor threshold are last', () => { // utxo1: unconfirmed, ancestorsSize equals threshold. const utxo1 = createTestUtxo('tx1', 1000n, false); // utxo2: confirmed, ancestorsSize lower. const utxo2 = createTestUtxo('tx2', 800n, true); testWallet.utxos = [utxo1, utxo2]; walletMempoolStatus.set('tx1', { ancestorsSize: ANCESTOR_COUNT_TRESHOLD, fees: { ancestor: 0, base: 0, modified: 0 }, }); walletMempoolStatus.set('tx2', { ancestorsSize: 60000, fees: { ancestor: 0, base: 0, modified: 0 }, }); const result = findBtc( testWallet, walletMempoolStatus, usedUtxos, usedUtxosMempoolStatus, 500n, 2n, txBuilder as any, SigHash.ALL, dummyPubKey, DUST_LIMIT, ); expect(result.utxos[0].txid).toBe('tx2'); }); it('Accumulates UTXOs until the required amount plus fee is met', () => { const utxo1 = createTestUtxo('tx1', 1500n, true); const utxo2 = createTestUtxo('tx2', 2000n, true); testWallet.utxos = [utxo1, utxo2]; // Given fee = ceil(100 * 2) = 200, the condition is that accumulated utxo amount // plus ancestors fee must be >= amount + fee. For amount=3000n, // required total is 3000n + 200n = 3200n. const result = findBtc( testWallet, walletMempoolStatus, usedUtxos, usedUtxosMempoolStatus, 3000n, 2n, txBuilder as any, SigHash.ALL, dummyPubKey, DUST_LIMIT, ); expect(result.utxos).toHaveLength(2); expect(result.amount).toBe(1500n + 2000n); }); it('Updates ancestors for unconfirmed UTXOs and adds them to usedUtxos', () => { const utxo1 = createTestUtxo('tx1', 2000n, false); testWallet.utxos = [utxo1]; walletMempoolStatus.set('tx1', { ancestorsSize: 1000, fees: { ancestor: 50, base: 0, modified: 0 }, }); const result = findBtc( testWallet, walletMempoolStatus, usedUtxos, usedUtxosMempoolStatus, 1000n, 2n, txBuilder as any, SigHash.ALL, dummyPubKey, DUST_LIMIT, ); expect(usedUtxos).toHaveLength(1); expect(usedUtxos[0].txid).toBe('tx1'); expect(result.ancestorsSize).toBe(1000); expect(result.ancestorsFee).toBe(50n); }); it('Stops accumulating when the required amount plus fees are met', () => { const utxo1 = createTestUtxo('tx1', 2500n, true); const utxo2 = createTestUtxo('tx2', 2500n, true); const utxo3 = createTestUtxo('tx3', 2000n, true); testWallet.utxos = [utxo1, utxo2, utxo3]; // With fee = ceil(100 * 2) = 200, required accumulation is 3000n + 200n = 3200n. // utxo1 + utxo2 = 5000n, so accumulation should stop. const result = findBtc( testWallet, walletMempoolStatus, usedUtxos, usedUtxosMempoolStatus, 3000n, 2n, txBuilder as any, SigHash.ALL, dummyPubKey, DUST_LIMIT, ); expect(result.utxos).toHaveLength(2); expect(result.amount).toBe(5000n); }); it('Calls txBuilder.addInput for each selected UTXO', () => { const utxo1 = createTestUtxo('tx1', 1500n, true); testWallet.utxos = [utxo1]; findBtc( testWallet, walletMempoolStatus, usedUtxos, usedUtxosMempoolStatus, 1000n, 2n, txBuilder as any, SigHash.ALL, dummyPubKey, DUST_LIMIT, ); expect(txBuilder.addInput).toHaveBeenCalledTimes(1); expect(txBuilder.inputs[0]).toMatchObject({ utxo: utxo1, owner: testWallet.address, publicKey: dummyPubKey, sighashType: SigHash.ALL, }); }); }); describe('findCollection', () => { beforeEach(() => { // Reset wallet and maps for each test. testWallet = createTestWallet([]); usedUtxos = []; walletMempoolStatus = new Map(); usedUtxosMempoolStatus = new Map(); }); it('Should filter UTXOs by matching collection id', () => { const utxo1 = createTestUtxo('tx1', 1000n, true, [ { id: 'col1', amount: 500n }, ]); const utxo2 = createTestUtxo('tx2', 2000n, true, [ { id: 'col2', amount: 1000n }, ]); testWallet.utxos = [utxo1, utxo2]; const result = findCollection( testWallet, walletMempoolStatus, testCollection, 400n, ); expect(result.utxos).toHaveLength(1); expect(result.utxos[0].txid).toBe('tx1'); }); it('Should filter out UTXOs with reached limits', () => { const utxo1 = createTestUtxo('tx1', 1000n, true, [ { id: 'col1', amount: 500n }, ]); const utxo2 = createTestUtxo('tx2', 2000n, true, [ { id: 'col1', amount: 1000n }, ]); testWallet.utxos = [utxo1, utxo2]; // Simulate that utxo1 reaches the ancestors limit. walletMempoolStatus.set('tx1', dummyMempoolStatusReachedAncestors); const result = findCollection( testWallet, walletMempoolStatus, testCollection, 400n, ); expect(result.utxos).toHaveLength(1); expect(result.utxos[0].txid).toBe('tx2'); }); it('Should sort UTXOs in descending order by collection amount', () => { const utxo1 = createTestUtxo('tx1', 1000n, true, [ { id: 'col1', amount: 500n }, ]); const utxo2 = createTestUtxo('tx2', 1500n, true, [ { id: 'col1', amount: 1000n }, ]); const utxo3 = createTestUtxo('tx3', 2000n, true, [ { id: 'col1', amount: 300n }, ]); testWallet.utxos = [utxo1, utxo2, utxo3]; const result = findCollection( testWallet, walletMempoolStatus, testCollection, 5000n, ); expect(result.utxos[0].txid).toBe('tx2'); expect(result.utxos[1].txid).toBe('tx1'); expect(result.utxos[2].txid).toBe('tx3'); }); it('Should accumulate UTXOs until the requested amount is reached', () => { const utxo1 = createTestUtxo('tx1', 1000n, true, [ { id: 'col1', amount: 500n }, ]); const utxo2 = createTestUtxo('tx2', 1500n, true, [ { id: 'col1', amount: 1000n }, ]); testWallet.utxos = [utxo1, utxo2]; const result = findCollection( testWallet, walletMempoolStatus, testCollection, 1200n, ); expect(result.totalAmount).toBe(1500n); expect(result.amount).toBe(1200n); expect(result.filled).toBe(true); }); it('Should accumulate UTXOs and select the largest UTXO if it alone meets the request', () => { // In this test, one UTXO has enough collection amount to meet the requested amount. const utxo1 = createTestUtxo('tx1', 1000n, true, [ { id: 'col1', amount: 500n }, ]); const utxo2 = createTestUtxo('tx2', 1500n, true, [ { id: 'col1', amount: 1000n }, ]); const utxo3 = createTestUtxo('tx3', 1500n, true, [ { id: 'col1', amount: 2000n }, ]); testWallet.utxos = [utxo1, utxo2, utxo3]; const result = findCollection( testWallet, walletMempoolStatus, testCollection, 1200n, ); // Since utxo3 has 2000 (largest), it should be enough on its own. expect(result.totalAmount).toBe(2000n); expect(result.amount).toBe(1200n); expect(result.filled).toBe(true); expect(result.utxos.length).toBe(1); expect(result.utxos[0].txid).toBe('tx3'); }); it('Should mark as not filled if total amount is less than requested', () => { const utxo1 = createTestUtxo('tx1', 1000n, true, [ { id: 'col1', amount: 300n }, ]); const utxo2 = createTestUtxo('tx2', 1500n, true, [ { id: 'col1', amount: 400n }, ]); testWallet.utxos = [utxo1, utxo2]; const result = findCollection( testWallet, walletMempoolStatus, testCollection, 1000n, ); expect(result.totalAmount).toBe(700n); expect(result.amount).toBe(700n); expect(result.filled).toBe(false); }); }); describe('getBtcAndCollectionAmountsFromUtxos', () => { let primaryWallet: Wallet; let secondaryWallet: Wallet | undefined; const collectionId = '100:1'; // Example collection id beforeEach(() => { primaryWallet = createTestWallet([ createTestUtxo('txA', 1000n, true, [{ id: collectionId, amount: 500n }]), createTestUtxo('txB', 2000n, true, []), ]); secondaryWallet = createTestWallet([ createTestUtxo('txC', 1500n, true, [{ id: collectionId, amount: 300n }]), ]); }); it('Should correctly sum BTC and collection amounts from primary wallet utxos', () => { const utxos = ['txA:0', 'txB:0']; const result = getBtcAndCollectionAmountsFromUtxos( primaryWallet, undefined, collectionId, utxos, ); expect(result.btcAmount).toBe(3000n); expect(result.collectionAmount).toBe(500n); expect(result.collectionUtxos).toHaveLength(2); expect(result.collectionUtxos.map((u) => u.txid)).toEqual(['txA', 'txB']); }); it('Should correctly include utxos from both primary and secondary wallets', () => { const utxos = ['txA:0', 'txC:0']; const result = getBtcAndCollectionAmountsFromUtxos( primaryWallet, secondaryWallet, collectionId, utxos, ); expect(result.btcAmount).toBe(2500n); expect(result.collectionAmount).toBe(800n); expect(result.collectionUtxos).toHaveLength(2); expect(result.collectionUtxos.map((u) => u.txid)).toEqual(['txA', 'txC']); }); it('Should throw an error if a UTXO from the input list is not found in either wallet', () => { const utxos = ['txA:0', 'txZ:0']; // txZ does not exist in either wallet expect(() => { getBtcAndCollectionAmountsFromUtxos( primaryWallet, secondaryWallet, collectionId, utxos, ); }).toThrow(PoolErrorException); }); it('Should ignore UTXOs without collection statuses when summing collectionAmount', () => { const utxos = ['txB:0']; const result = getBtcAndCollectionAmountsFromUtxos( primaryWallet, undefined, collectionId, utxos, ); expect(result.btcAmount).toBe(2000n); expect(result.collectionAmount).toBe(0n); expect(result.collectionUtxos).toHaveLength(1); expect(result.collectionUtxos[0].txid).toBe('txB'); }); it('Should correctly sum multiple collection statuses on a UTXO', () => { const walletWithMultiStatus = createTestWallet([ createTestUtxo('txD', 3000n, true, [ { id: collectionId, amount: 400n }, { id: collectionId, amount: 600n }, { id: 'other', amount: 500n }, ]), ]); const utxos = ['txD:0']; const result = getBtcAndCollectionAmountsFromUtxos( walletWithMultiStatus, undefined, collectionId, utxos, ); expect(result.btcAmount).toBe(3000n); expect(result.collectionAmount).toBe(1000n); expect(result.collectionUtxos).toHaveLength(1); expect(result.collectionUtxos[0].txid).toBe('txD'); }); });