UNPKG

opnet

Version:

The perfect library for building Bitcoin-based applications.

342 lines (341 loc) 14.8 kB
import { UTXO } from '../bitcoin/UTXOs.js'; import { JSONRpcMethods } from '../providers/interfaces/JSONRpcMethods.js'; const AUTO_PURGE_AFTER = 1000 * 60; const FETCH_COOLDOWN = 10000; const MEMPOOL_CHAIN_LIMIT = 25; export class UTXOsManager { provider; dataByAddress = {}; constructor(provider) { this.provider = provider; } spentUTXO(address, spent, newUTXOs) { const addressData = this.getAddressData(address); const utxoKey = (u) => `${u.transactionId}:${u.outputIndex}`; addressData.pendingUTXOs = addressData.pendingUTXOs.filter((utxo) => { return !spent.some((spentUtxo) => spentUtxo.transactionId === utxo.transactionId && spentUtxo.outputIndex === utxo.outputIndex); }); let maxParentDepth = 0; for (const spentUtxo of spent) { const key = utxoKey(spentUtxo); const parentDepth = addressData.pendingUtxoDepth[key] ?? 0; if (parentDepth > maxParentDepth) { maxParentDepth = parentDepth; } } for (const spentUtxo of spent) { const key = utxoKey(spentUtxo); delete addressData.pendingUtxoDepth[key]; } 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}]`); } for (const nu of newUTXOs) { addressData.pendingUTXOs.push(nu); addressData.pendingUtxoDepth[utxoKey(nu)] = newDepth; } } getPendingUTXOs(address) { const addressData = this.getAddressData(address); return addressData.pendingUTXOs; } clean(address, threshold) { if (address) { const addressData = this.getAddressData(address, threshold); addressData.spentUTXOs = []; addressData.pendingUTXOs = []; addressData.pendingUtxoDepth = {}; addressData.lastCleanup = Date.now(); addressData.lastFetchTimestamp = 0; addressData.lastFetchedData = null; } else { this.dataByAddress = {}; } } async getUTXOs({ address, isCSV = false, optimize = true, mergePendingUTXOs = true, filterSpentUTXOs = true, olderThan, }) { const addressData = this.getAddressData(address, olderThan); const fetchedData = await this.maybeFetchUTXOs(address, optimize, olderThan, isCSV); const utxoKey = (utxo) => `${utxo.transactionId}:${utxo.outputIndex}`; const spentRefKey = (ref) => `${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)); const combinedUTXOs = []; const combinedKeysSet = new Set(); for (const utxo of fetchedData.confirmed) { const key = utxoKey(utxo); if (!combinedKeysSet.has(key)) { combinedUTXOs.push(utxo); combinedKeysSet.add(key); } } if (mergePendingUTXOs) { for (const utxo of addressData.pendingUTXOs) { const key = utxoKey(utxo); if (!combinedKeysSet.has(key)) { combinedUTXOs.push(utxo); combinedKeysSet.add(key); } } for (const utxo of fetchedData.pending) { const key = utxoKey(utxo); if (!pendingUTXOKeys.has(key) && !combinedKeysSet.has(key)) { combinedUTXOs.push(utxo); combinedKeysSet.add(key); } } } let finalUTXOs = combinedUTXOs.filter((utxo) => !spentUTXOKeys.has(utxoKey(utxo))); if (filterSpentUTXOs && fetchedSpentKeys.size > 0) { finalUTXOs = finalUTXOs.filter((utxo) => !fetchedSpentKeys.has(utxoKey(utxo))); } return finalUTXOs; } async getUTXOsForAmount({ address, amount, csvAddress, optimize = true, mergePendingUTXOs = true, filterSpentUTXOs = true, throwErrors = false, olderThan, maxUTXOs = 5000, throwIfUTXOsLimitReached = false, }) { const selected = []; let currentValue = 0n; const normalUTXOs = await this.getUTXOs({ address, optimize, mergePendingUTXOs, filterSpentUTXOs, olderThan, }); currentValue = this.selectUTXOsGreedily(normalUTXOs, selected, currentValue, amount, maxUTXOs, throwIfUTXOsLimitReached); if (currentValue < amount && csvAddress) { const csvUTXOs = 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; } async getMultipleUTXOs({ requests, mergePendingUTXOs = true, filterSpentUTXOs = true, }) { if (requests.length === 0) { return {}; } const fetchedDataMap = await this.fetchMultipleUTXOs(requests); const result = {}; for (const request of requests) { const { address, isCSV = false } = request; const addressData = this.getAddressData(address); const fetchedData = fetchedDataMap[address]; if (!fetchedData) { result[address] = []; continue; } addressData.lastFetchedData = fetchedData; addressData.lastFetchTimestamp = Date.now(); this.syncPendingDepthWithFetched(address); const utxoKey = (utxo) => `${utxo.transactionId}:${utxo.outputIndex}`; const spentRefKey = (ref) => `${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)); const combinedUTXOs = []; const combinedKeysSet = new Set(); for (const utxo of fetchedData.confirmed) { const key = utxoKey(utxo); if (!combinedKeysSet.has(key)) { combinedUTXOs.push(utxo); combinedKeysSet.add(key); } } if (mergePendingUTXOs) { for (const utxo of addressData.pendingUTXOs) { const key = utxoKey(utxo); if (!combinedKeysSet.has(key)) { combinedUTXOs.push(utxo); combinedKeysSet.add(key); } } for (const utxo of fetchedData.pending) { const key = utxoKey(utxo); if (!pendingUTXOKeys.has(key) && !combinedKeysSet.has(key)) { combinedUTXOs.push(utxo); combinedKeysSet.add(key); } } } let finalUTXOs = combinedUTXOs.filter((utxo) => !spentUTXOKeys.has(utxoKey(utxo))); if (filterSpentUTXOs && fetchedSpentKeys.size > 0) { finalUTXOs = finalUTXOs.filter((utxo) => !fetchedSpentKeys.has(utxoKey(utxo))); } result[address] = finalUTXOs; } return result; } selectUTXOsGreedily(candidates, selected, currentValue, amount, maxUTXOs, throwIfLimitReached) { 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; } async fetchMultipleUTXOs(requests) { const payloads = requests.map((request) => { const data = [request.address, request.optimize ?? true]; if (request.olderThan !== undefined) { data.push(request.olderThan.toString()); } return this.provider.buildJsonRpcPayload(JSONRpcMethods.GET_UTXOS, data); }); const rawResults = await this.provider.callMultiplePayloads(payloads); if ('error' in rawResults) { throw new Error(`Error fetching UTXOs: ${rawResults.error}`); } const result = {}; 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 = rawUTXOs.result || { confirmed: [], pending: [], spentTransactions: [], raw: [], }; 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) => ({ transactionId: spent.transactionId, outputIndex: spent.outputIndex, })), }; } return result; } getAddressData(address, threshold) { 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]; } async maybeFetchUTXOs(address, optimize, olderThan, isCSV = false) { const addressData = this.getAddressData(address, olderThan); const now = Date.now(); const age = now - addressData.lastFetchTimestamp; if (now - addressData.lastCleanup > AUTO_PURGE_AFTER) { this.clean(address, olderThan); } if (addressData.lastFetchedData && age < FETCH_COOLDOWN) { return addressData.lastFetchedData; } addressData.lastFetchedData = await this.fetchUTXOs(address, optimize, olderThan, isCSV); addressData.lastFetchTimestamp = now; if (!olderThan) { this.syncPendingDepthWithFetched(address); } return addressData.lastFetchedData; } async fetchUTXOs(address, optimize = false, olderThan, isCSV = false) { const data = [address, optimize]; if (olderThan !== undefined) { data.push(olderThan.toString()); } const payload = this.provider.buildJsonRpcPayload(JSONRpcMethods.GET_UTXOS, data); const rawUTXOs = await this.provider.callPayloadSingle(payload); if ('error' in rawUTXOs) { throw new Error(`Error fetching UTXOs: ${rawUTXOs.error}`); } const rawResult = rawUTXOs.result; const result = rawResult && typeof rawResult === 'object' && Array.isArray(rawResult.confirmed) ? rawResult : { confirmed: [], pending: [], spentTransactions: [], raw: [], }; 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: (result.spentTransactions || []).map((spent) => ({ transactionId: spent.transactionId, outputIndex: spent.outputIndex, })), }; } parseUTXO(utxo, isCSV, rawTransactions) { 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); } syncPendingDepthWithFetched(address) { 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) => `${s.transactionId}:${s.outputIndex}`)); addressData.pendingUTXOs = addressData.pendingUTXOs.filter((u) => { const key = `${u.transactionId}:${u.outputIndex}`; if (confirmedKeys.has(key) || spentKeys.has(key)) { delete addressData.pendingUtxoDepth[key]; return false; } return true; }); } }