import { RawIUTXO } from '../bitcoin/interfaces/IUTXO.js'; import { UTXO, UTXOs } from '../bitcoin/UTXOs.js'; import { JsonRpcPayload } from '../providers/interfaces/JSONRpc.js'; import { JSONRpcMethods } from '../providers/interfaces/JSONRpcMethods.js'; import { JsonRpcCallResult, JsonRpcResult } from '../providers/interfaces/JSONRpcResult.js'; import { IProviderForUTXO } from './interfaces/IProviderForUTXO.js'; import { IUTXOsData, RawIUTXOsData, RequestMultipleUTXOsParams, RequestUTXOsParams, RequestUTXOsParamsWithAmount, SpentUTXORef, } from './interfaces/IUTXOsManager.js'; const AUTO_PURGE_AFTER: number = 1000 * 60; // 1 minute const FETCH_COOLDOWN = 10000; // 10 seconds const MEMPOOL_CHAIN_LIMIT = 25; /** * A helper interface for per-address data tracking. */ interface AddressData { spentUTXOs: UTXOs; pendingUTXOs: UTXOs; /** * Key: `${transactionId}:${outputIndex}` * Value: the unconfirmed chain depth */ pendingUtxoDepth: Record; lastCleanup: number; lastFetchTimestamp: number; lastFetchedData: IUTXOsData | null; } /** * Manages unspent transaction outputs (UTXOs) by address/wallet. * @category Bitcoin */ export class UTXOsManager { /** * Holds all address-specific data so we don't mix up UTXOs between addresses/wallets. */ private dataByAddress: Record = {}; public constructor(private readonly provider: IProviderForUTXO) {} /** * Mark UTXOs as spent and track new UTXOs created by the transaction, _per address_. * * Enforces a mempool chain limit of 25 unconfirmed transaction descendants. * * @param address - The address these spent/new UTXOs belong to * @param {UTXOs} spent - The UTXOs that were spent. * @param {UTXOs} newUTXOs - The new UTXOs created by the transaction. * @throws {Error} If adding the new unconfirmed outputs would exceed the mempool chain limit. */ public spentUTXO(address: string, spent: UTXOs, newUTXOs: UTXOs): void { const addressData = this.getAddressData(address); const utxoKey = (u: UTXO) => `${u.transactionId}:${u.outputIndex}`; // Remove spent UTXOs from that address's pending addressData.pendingUTXOs = addressData.pendingUTXOs.filter((utxo) => { return !spent.some( (spentUtxo) => spentUtxo.transactionId === utxo.transactionId && spentUtxo.outputIndex === utxo.outputIndex, ); }); // Determine the parent depth for new UTXOs BEFORE removing from depth map. // If a spent UTXO was pending, it contributes to the chain depth. // If it was confirmed, depth = 0 for that. let maxParentDepth = 0; for (const spentUtxo of spent) { const key = utxoKey(spentUtxo); const parentDepth = addressData.pendingUtxoDepth[key] ?? 0; if (parentDepth > maxParentDepth) { maxParentDepth = parentDepth; } } // Now remove them from the depth map for (const spentUtxo of spent) { const key = utxoKey(spentUtxo); delete addressData.pendingUtxoDepth[key]; } // Add spent UTXOs to the "spent" list addressData.spentUTXOs.push(...spent); const newDepth = maxParentDepth + 1; if (newDepth > MEMPOOL_CHAIN_LIMIT) { throw new Error( `too-long-mempool-chain, too many descendants for tx ... [limit: ${MEMPOOL_CHAIN_LIMIT}]`, ); } // Push the new UTXOs into this address's pending and set their depth for (const nu of newUTXOs) { addressData.pendingUTXOs.push(nu); addressData.pendingUtxoDepth[utxoKey(nu)] = newDepth; } } /** * Get the pending UTXOs for a specific address. * @param address */ public getPendingUTXOs(address: string): UTXOs { const addressData = this.getAddressData(address); return addressData.pendingUTXOs; } /** * Clean (reset) the data for a particular address or for all addresses if none is passed. */ public clean(address?: string, threshold?: bigint): void { if (address) { // Reset a single address const addressData = this.getAddressData(address, threshold); addressData.spentUTXOs = []; addressData.pendingUTXOs = []; addressData.pendingUtxoDepth = {}; addressData.lastCleanup = Date.now(); addressData.lastFetchTimestamp = 0; addressData.lastFetchedData = null; } else { // Reset everything this.dataByAddress = {}; } } /** * Get UTXOs with configurable options, specifically for an address. * * If the last UTXO fetch for that address was <10s ago, returns cached data. * Otherwise, fetches fresh data from the provider. * * @param {object} options - The UTXO fetch options * @param {string} options.address - The address to get the UTXOs for * @param {boolean} [options.optimize=true] - Whether to optimize the UTXOs * @param {boolean} [options.mergePendingUTXOs=true] - Merge locally pending UTXOs * @param {boolean} [options.filterSpentUTXOs=true] - Filter out known-spent UTXOs * @param {boolean} [options.isCSV=false] - Whether to this UTXO as a CSV UTXO * @param {bigint} [options.olderThan] - Only fetch UTXOs older than this value * @returns {Promise} The UTXOs * @throws {Error} If something goes wrong */ public async getUTXOs({ address, isCSV = false, optimize = true, mergePendingUTXOs = true, filterSpentUTXOs = true, olderThan, }: RequestUTXOsParams): Promise { const addressData = this.getAddressData(address, olderThan); const fetchedData = await this.maybeFetchUTXOs(address, optimize, olderThan, isCSV); const utxoKey = (utxo: UTXO) => `${utxo.transactionId}:${utxo.outputIndex}`; const spentRefKey = (ref: SpentUTXORef) => `${ref.transactionId}:${ref.outputIndex}`; const pendingUTXOKeys = new Set(addressData.pendingUTXOs.map(utxoKey)); const spentUTXOKeys = new Set(addressData.spentUTXOs.map(utxoKey)); const fetchedSpentKeys = new Set(fetchedData.spentTransactions.map(spentRefKey)); // Start with confirmed UTXOs const combinedUTXOs: UTXOs = []; const combinedKeysSet = new Set(); for (const utxo of fetchedData.confirmed) { const key = utxoKey(utxo); if (!combinedKeysSet.has(key)) { combinedUTXOs.push(utxo); combinedKeysSet.add(key); } } // Merge pending UTXOs if requested if (mergePendingUTXOs) { // Add currently pending for (const utxo of addressData.pendingUTXOs) { const key = utxoKey(utxo); if (!combinedKeysSet.has(key)) { combinedUTXOs.push(utxo); combinedKeysSet.add(key); } } // Add fetched pending UTXOs that aren't already known for (const utxo of fetchedData.pending) { const key = utxoKey(utxo); if (!pendingUTXOKeys.has(key) && !combinedKeysSet.has(key)) { combinedUTXOs.push(utxo); combinedKeysSet.add(key); } } } // Filter out UTXOs spent locally let finalUTXOs = combinedUTXOs.filter((utxo) => !spentUTXOKeys.has(utxoKey(utxo))); // Optionally filter out UTXOs that are known spent in the fetch if (filterSpentUTXOs && fetchedSpentKeys.size > 0) { finalUTXOs = finalUTXOs.filter((utxo) => !fetchedSpentKeys.has(utxoKey(utxo))); } return finalUTXOs; } /** * Fetch UTXOs for a specific amount needed, from a single address, * merging from pending and confirmed UTXOs. * * Prioritizes normal UTXOs first, only falling back to CSV UTXOs * if the normal ones cannot cover the requested amount. * * @param {object} options * @param {string} options.address The address to fetch UTXOs for * @param {bigint} options.amount The needed amount * @param {boolean} [options.optimize=true] Optimize the UTXOs * @param {boolean} [options.csvAddress] Use CSV UTXOs as fallback * @param {boolean} [options.mergePendingUTXOs=true] Merge pending * @param {boolean} [options.filterSpentUTXOs=true] Filter out spent * @param {boolean} [options.throwErrors=false] Throw error if insufficient * @param {bigint} [options.olderThan] Only fetch UTXOs older than this value * @returns {Promise} */ public async getUTXOsForAmount({ address, amount, csvAddress, optimize = true, mergePendingUTXOs = true, filterSpentUTXOs = true, throwErrors = false, olderThan, maxUTXOs = 5000, throwIfUTXOsLimitReached = false, }: RequestUTXOsParamsWithAmount): Promise { const selected: UTXOs = []; let currentValue = 0n; // Fetch and greedily select from normal UTXOs first const normalUTXOs: UTXOs = await this.getUTXOs({ address, optimize, mergePendingUTXOs, filterSpentUTXOs, olderThan, }); currentValue = this.selectUTXOsGreedily( normalUTXOs, selected, currentValue, amount, maxUTXOs, throwIfUTXOsLimitReached, ); // Fall back to CSV UTXOs only if normal ones were insufficient if (currentValue < amount && csvAddress) { const csvUTXOs: UTXOs = await this.getUTXOs({ address: csvAddress, optimize: true, mergePendingUTXOs: false, filterSpentUTXOs: true, olderThan: 1n, isCSV: true, }); currentValue = this.selectUTXOsGreedily( csvUTXOs, selected, currentValue, amount, maxUTXOs, throwIfUTXOsLimitReached, ); } if (currentValue < amount && throwErrors) { throw new Error( `Insufficient UTXOs to cover amount. Available: ${currentValue}, Needed: ${amount}`, ); } return selected; } /** * Fetch UTXOs for multiple addresses in a single batch request. * * This method bypasses caching and directly fetches from the provider * for all requested addresses in parallel using batch RPC calls. * * @param {RequestMultipleUTXOsParams} options - The batch UTXO fetch options * @param {BatchUTXORequest[]} options.requests - Array of address-specific fetch parameters * @param {boolean} [options.mergePendingUTXOs=true] - Merge locally pending UTXOs * @param {boolean} [options.filterSpentUTXOs=true] - Filter out known-spent UTXOs * @returns {Promise>} Map of address to UTXOs * @throws {Error} If something goes wrong during the batch fetch */ public async getMultipleUTXOs({ requests, mergePendingUTXOs = true, filterSpentUTXOs = true, }: RequestMultipleUTXOsParams): Promise> { if (requests.length === 0) { return {}; } // Fetch all UTXOs in a single batch call const fetchedDataMap = await this.fetchMultipleUTXOs(requests); const result: Record = {}; for (const request of requests) { const { address, isCSV = false } = request; const addressData = this.getAddressData(address); const fetchedData = fetchedDataMap[address]; if (!fetchedData) { result[address] = []; continue; } // Update cache for this address addressData.lastFetchedData = fetchedData; addressData.lastFetchTimestamp = Date.now(); // Sync pending state with fetched data this.syncPendingDepthWithFetched(address); const utxoKey = (utxo: UTXO) => `${utxo.transactionId}:${utxo.outputIndex}`; const spentRefKey = (ref: SpentUTXORef) => `${ref.transactionId}:${ref.outputIndex}`; const pendingUTXOKeys = new Set(addressData.pendingUTXOs.map(utxoKey)); const spentUTXOKeys = new Set(addressData.spentUTXOs.map(utxoKey)); const fetchedSpentKeys = new Set(fetchedData.spentTransactions.map(spentRefKey)); // Start with confirmed UTXOs const combinedUTXOs: UTXOs = []; const combinedKeysSet = new Set(); for (const utxo of fetchedData.confirmed) { const key = utxoKey(utxo); if (!combinedKeysSet.has(key)) { combinedUTXOs.push(utxo); combinedKeysSet.add(key); } } // Merge pending UTXOs if requested if (mergePendingUTXOs) { // Add currently pending for (const utxo of addressData.pendingUTXOs) { const key = utxoKey(utxo); if (!combinedKeysSet.has(key)) { combinedUTXOs.push(utxo); combinedKeysSet.add(key); } } // Add fetched pending UTXOs that aren't already known for (const utxo of fetchedData.pending) { const key = utxoKey(utxo); if (!pendingUTXOKeys.has(key) && !combinedKeysSet.has(key)) { combinedUTXOs.push(utxo); combinedKeysSet.add(key); } } } // Filter out UTXOs spent locally let finalUTXOs = combinedUTXOs.filter((utxo) => !spentUTXOKeys.has(utxoKey(utxo))); // Optionally filter out UTXOs that are known spent in the fetch if (filterSpentUTXOs && fetchedSpentKeys.size > 0) { finalUTXOs = finalUTXOs.filter((utxo) => !fetchedSpentKeys.has(utxoKey(utxo))); } result[address] = finalUTXOs; } return result; } /** * Sort UTXOs by value descending and greedily append to `selected` until * `currentValue >= amount` or the pool is exhausted. Mutates `candidates` * (sort in-place) and `selected` (pushes chosen UTXOs). Returns the * updated cumulative value. */ private selectUTXOsGreedily( candidates: UTXOs, selected: UTXOs, currentValue: bigint, amount: bigint, maxUTXOs: number, throwIfLimitReached: boolean, ): bigint { candidates.sort((a, b) => { if (b.value > a.value) return 1; if (b.value < a.value) return -1; return 0; }); for (const utxo of candidates) { if (currentValue >= amount) break; if (maxUTXOs && selected.length >= maxUTXOs) { if (throwIfLimitReached) { throw new Error( `Woah. You must consolidate your UTXOs (${candidates.length + selected.length})! This transaction is too large.`, ); } break; } selected.push(utxo); currentValue += utxo.value; } return currentValue; } /** * Fetch UTXOs for multiple addresses in a single batch RPC call. * @private */ private async fetchMultipleUTXOs( requests: RequestUTXOsParams[], ): Promise> { const payloads: JsonRpcPayload[] = requests.map((request) => { const data: [string, boolean?, string?] = [request.address, request.optimize ?? true]; if (request.olderThan !== undefined) { data.push(request.olderThan.toString()); } return this.provider.buildJsonRpcPayload(JSONRpcMethods.GET_UTXOS, data); }); const rawResults: JsonRpcCallResult = await this.provider.callMultiplePayloads(payloads); if ('error' in rawResults) { throw new Error(`Error fetching UTXOs: ${rawResults.error}`); } const result: Record = {}; for (let i = 0; i < rawResults.length; i++) { const rawUTXOs = rawResults[i]; const request = requests[i]; if (!request) { throw new Error('Impossible index mismatch'); } if ('error' in rawUTXOs) { throw new Error(`Error fetching UTXOs for ${request.address}: ${rawUTXOs.error}`); } const rawData: RawIUTXOsData = (rawUTXOs.result as RawIUTXOsData) || { confirmed: [], pending: [], spentTransactions: [], raw: [], }; // The raw array contains the actual transaction hex strings (base64 encoded) const rawTransactions = rawData.raw || []; const isCSV = request.isCSV ?? false; result[request.address] = { confirmed: rawData.confirmed.map((utxo) => { return this.parseUTXO(utxo, isCSV, rawTransactions); }), pending: rawData.pending.map((utxo) => { return this.parseUTXO(utxo, isCSV, rawTransactions); }), spentTransactions: rawData.spentTransactions.map( (spent): SpentUTXORef => ({ transactionId: spent.transactionId, outputIndex: spent.outputIndex, }), ), }; } return result; } /** * Return the AddressData object for a given address. Initializes it if nonexistent. */ private getAddressData(address: string, threshold?: bigint): AddressData { const addressWithThreshold = threshold ? `${address}_${threshold}` : address; if (!this.dataByAddress[addressWithThreshold]) { this.dataByAddress[addressWithThreshold] = { spentUTXOs: [], pendingUTXOs: [], pendingUtxoDepth: {}, lastCleanup: Date.now(), lastFetchTimestamp: 0, lastFetchedData: null, }; } return this.dataByAddress[addressWithThreshold]; } /** * Checks if we need to fetch fresh UTXOs or can return the cached data (per address). */ private async maybeFetchUTXOs( address: string, optimize: boolean, olderThan: bigint | undefined, isCSV: boolean = false, ): Promise { const addressData = this.getAddressData(address, olderThan); const now = Date.now(); const age = now - addressData.lastFetchTimestamp; // Purge if it's been too long for this address if (now - addressData.lastCleanup > AUTO_PURGE_AFTER) { this.clean(address, olderThan); // Clean only this address data } // If it's been less than FETCH_COOLDOWN ms, return cached data if available if (addressData.lastFetchedData && age < FETCH_COOLDOWN) { return addressData.lastFetchedData; } // Otherwise, fetch from the RPC addressData.lastFetchedData = await this.fetchUTXOs(address, optimize, olderThan, isCSV); addressData.lastFetchTimestamp = now; // Remove any pending UTXOs that have become confirmed or known spent if (!olderThan) { this.syncPendingDepthWithFetched(address); } return addressData.lastFetchedData; } /** * Generic method to fetch all UTXOs in one call (confirmed, pending, spent) for a given address. */ private async fetchUTXOs( address: string, optimize: boolean = false, olderThan: bigint | undefined, isCSV: boolean = false, ): Promise { const data: [string, boolean?, string?] = [address, optimize]; if (olderThan !== undefined) { data.push(olderThan.toString()); } const payload: JsonRpcPayload = this.provider.buildJsonRpcPayload( JSONRpcMethods.GET_UTXOS, data, ); const rawUTXOs: JsonRpcResult = await this.provider.callPayloadSingle(payload); if ('error' in rawUTXOs) { throw new Error(`Error fetching UTXOs: ${rawUTXOs.error}`); } const rawResult = rawUTXOs.result as RawIUTXOsData | undefined | null; // Handle malformed or missing result const result: RawIUTXOsData = rawResult && typeof rawResult === 'object' && Array.isArray(rawResult.confirmed) ? rawResult : { confirmed: [], pending: [], spentTransactions: [], raw: [], }; // The raw array contains the actual transaction hex strings (base64 encoded) // Each UTXO has a `raw` field that is an index into this array const rawTransactions = result.raw || []; return { confirmed: (result.confirmed || []).map((utxo) => { return this.parseUTXO(utxo, isCSV, rawTransactions); }), pending: (result.pending || []).map((utxo) => { return this.parseUTXO(utxo, isCSV, rawTransactions); }), // spentTransactions only contain transactionId and outputIndex (no raw data needed) spentTransactions: (result.spentTransactions || []).map( (spent): SpentUTXORef => ({ transactionId: spent.transactionId, outputIndex: spent.outputIndex, }), ), }; } private parseUTXO(utxo: RawIUTXO, isCSV: boolean, rawTransactions: string[]): UTXO { // raw is now an index into the rawTransactions array if (utxo.raw === undefined || utxo.raw === null) { throw new Error('Missing raw index field in UTXO'); } const rawHex = rawTransactions[utxo.raw]; if (!rawHex) { throw new Error(`Invalid raw index ${utxo.raw} - not found in rawTransactions array`); } const raw = { ...utxo, raw: rawTransactions[utxo.raw], }; return new UTXO(raw, isCSV); } /** * After fetching new data for a single address, some pending UTXOs may have confirmed * or become known-spent. Remove them from pending state/depth map. */ private syncPendingDepthWithFetched(address: string): void { const addressData = this.getAddressData(address); const fetched = addressData.lastFetchedData; if (!fetched) return; const confirmedKeys = new Set( fetched.confirmed.map((u) => `${u.transactionId}:${u.outputIndex}`), ); const spentKeys = new Set( fetched.spentTransactions.map( (s: SpentUTXORef) => `${s.transactionId}:${s.outputIndex}`, ), ); addressData.pendingUTXOs = addressData.pendingUTXOs.filter((u) => { const key = `${u.transactionId}:${u.outputIndex}`; // If it's now confirmed or known spent, remove it from pending if (confirmedKeys.has(key) || spentKeys.has(key)) { delete addressData.pendingUtxoDepth[key]; return false; } return true; }); } }