opnet
Version:
The perfect library for building Bitcoin-based applications.
342 lines (341 loc) • 14.8 kB
JavaScript
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;
});
}
}