UNPKG

@bitcoinerlab/discovery

Version:

A TypeScript library for retrieving Bitcoin funds from ranged descriptors, leveraging @bitcoinerlab/explorer for standardized access to multiple blockchain explorers.

886 lines (885 loc) 58.4 kB
"use strict"; // Copyright (c) 2023 Jose-Luis Landabaso - https://bitcoinerlab.com // Distributed under the MIT software license var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) { if (kind === "m") throw new TypeError("Private method is not writable"); if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter"); if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it"); return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value; }; var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) { if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.DiscoveryFactory = void 0; const DEFAULT_GAP_LIMIT = 20; const immer_1 = require("immer"); const shallow_equal_1 = require("shallow-equal"); const deriveData_1 = require("./deriveData"); const networks_1 = require("./networks"); const descriptors_1 = require("@bitcoinerlab/descriptors"); const bitcoinjs_lib_1 = require("bitcoinjs-lib"); const lodash_clonedeep_1 = __importDefault(require("lodash.clonedeep")); const types_1 = require("./types"); const now = () => Math.floor(Date.now() / 1000); /** * Creates and returns a Discovery class for discovering funds in a Bitcoin network * using descriptors. The class provides methods for descriptor expression discovery, * balance checking, transaction status checking, and so on. * * @returns A Discovery class, constructed with the given explorer instance. */ function DiscoveryFactory( /** * The explorer instance that communicates with the * Bitcoin network. It is responsible for fetching blockchain data like UTXOs, * transaction details, etc. */ explorer, /** * The Bitcoin network to use. * One of bitcoinjs-lib [`networks`](https://github.com/bitcoinjs/bitcoinjs-lib/blob/master/src/networks.js) (or another one following the same interface). */ network) { var _Discovery_instances, _Discovery_derivers, _Discovery_discoveryData, _Discovery_getDescriptorByScriptPubKey, _Discovery_ensureScriptPubKeyUniqueness, _Discovery_fetchOutput, _Discovery_fetchTxs, _Discovery_ensureFetched; /** * A class to discover funds in a Bitcoin network using descriptors. * The {@link DiscoveryFactory | `DiscoveryFactory`} function internally creates and returns an instance of this class. * The returned class is specialized for the provided `Explorer`, which is responsible for fetching blockchain data like transaction details. */ class Discovery { /** * Constructs a Discovery instance. Discovery is used to discover funds * in a Bitcoin network using descriptors. * * @param options */ constructor({ descriptorsCacheSize = 1000, outputsPerDescriptorCacheSize = 10000, imported } = { descriptorsCacheSize: 1000, outputsPerDescriptorCacheSize: 10000 }) { _Discovery_instances.add(this); _Discovery_derivers.set(this, void 0); _Discovery_discoveryData.set(this, void 0); if (imported) { if (imported.version !== types_1.DATA_MODEL_VERSION) throw new Error(`Cannot import data model. ${imported.version} != ${types_1.DATA_MODEL_VERSION}`); __classPrivateFieldSet(this, _Discovery_discoveryData, (0, lodash_clonedeep_1.default)(imported.discoveryData), "f"); } else { __classPrivateFieldSet(this, _Discovery_discoveryData, {}, "f"); for (const networkId of Object.values(types_1.NetworkId)) { const txMap = {}; const descriptorMap = {}; const networkData = { descriptorMap, txMap }; __classPrivateFieldGet(this, _Discovery_discoveryData, "f")[networkId] = networkData; } } __classPrivateFieldSet(this, _Discovery_derivers, (0, deriveData_1.deriveDataFactory)({ descriptorsCacheSize, outputsPerDescriptorCacheSize }), "f"); } /** * Asynchronously fetches one or more descriptor expressions, retrieving * all the historical txs associated with the outputs represented by the * expressions. * * @param options * @returns Resolves when the fetch operation completes. If used expressions * are found, waits for the discovery of associated transactions. */ async fetch({ descriptor, index, descriptors, gapLimit = DEFAULT_GAP_LIMIT, onUsed, onChecking, onProgress, next }) { const descriptorOrDescriptors = descriptor || descriptors; if ((descriptor && descriptors) || !descriptorOrDescriptors) throw new Error(`Pass descriptor or descriptors`); if (typeof index !== 'undefined' && (descriptors || !descriptor?.includes('*'))) throw new Error(`Don't pass index`); if (onChecking) await onChecking(descriptorOrDescriptors); const canonicalInput = (0, deriveData_1.canonicalize)(descriptorOrDescriptors, network); let nextPromise = undefined; let usedOutput = false; let usedOutputNotified = false; const descriptorArray = Array.isArray(canonicalInput) ? canonicalInput : [canonicalInput]; const networkId = (0, networks_1.getNetworkId)(network); const descriptorFetchPromises = descriptorArray.map(async (descriptor) => { __classPrivateFieldSet(this, _Discovery_discoveryData, (0, immer_1.produce)(__classPrivateFieldGet(this, _Discovery_discoveryData, "f"), discoveryData => { const descriptorInfo = discoveryData[networkId].descriptorMap[descriptor]; if (!descriptorInfo) { discoveryData[networkId].descriptorMap[descriptor] = { timeFetched: 0, fetching: true, range: {} }; } else { descriptorInfo.fetching = true; } }), "f"); let gap = 0; let outputsFetched = 0; let indexEvaluated = index || 0; //If it was a passed argument use it; othewise start at zero const isRanged = descriptor.indexOf('*') !== -1; const isGapSearch = isRanged && typeof index === 'undefined'; while (isGapSearch ? gap < gapLimit : outputsFetched < 1) { //batch-request the remaining outputs until gapLimit: const outputsToFetch = isGapSearch ? gapLimit - gap : 1; const fetchPromises = []; let abortCurrentDescriptorLoop = false; for (let i = 0; i < outputsToFetch; i++) { const currentIndexToFetch = isRanged ? indexEvaluated + i : 'non-ranged'; if (onProgress) { const continueProcessing = await onProgress(descriptor, currentIndexToFetch); if (continueProcessing === false) { abortCurrentDescriptorLoop = true; break; } } fetchPromises.push(__classPrivateFieldGet(this, _Discovery_instances, "m", _Discovery_fetchOutput).call(this, { descriptor, ...(isRanged ? { index: indexEvaluated + i } : {}) })); } if (abortCurrentDescriptorLoop && fetchPromises.length === 0) { // This case happens if onProgress returned false before any fetchPromises were added. break; } //Promise.all keeps the order in results const results = await Promise.all(fetchPromises); //Now, evaluate the gap from the batch of results for (const used of results) { if (used) { usedOutput = true; gap = 0; if (next && !nextPromise) nextPromise = next(); if (onUsed && usedOutputNotified === false) { await onUsed(descriptorOrDescriptors); usedOutputNotified = true; } } else { gap++; } indexEvaluated++; outputsFetched++; } if (abortCurrentDescriptorLoop) { break; } } __classPrivateFieldSet(this, _Discovery_discoveryData, (0, immer_1.produce)(__classPrivateFieldGet(this, _Discovery_discoveryData, "f"), discoveryData => { const descriptorInfo = discoveryData[networkId].descriptorMap[descriptor]; if (!descriptorInfo) throw new Error(`Descriptor for ${networkId} and ${descriptor} does not exist`); descriptorInfo.fetching = false; descriptorInfo.timeFetched = now(); }), "f"); }); await Promise.all(descriptorFetchPromises); const promises = []; if (usedOutput) promises.push(__classPrivateFieldGet(this, _Discovery_instances, "m", _Discovery_fetchTxs).call(this)); if (nextPromise) promises.push(nextPromise); await Promise.all(promises); } /** * Retrieves the fetching status and the timestamp of the last fetch for a descriptor. * * Use this function to check if the data for a specific descriptor, or an index within * a ranged descriptor, is currently being fetched or has been fetched. * * This function also helps to avoid errors when attempting to derive data from descriptors with incomplete data, * ensuring that subsequent calls to data derivation methods such as `getUtxos` or * `getBalance` only occur once the necessary data has been successfully retrieved (and does not return `undefined`). * * @returns An object with the fetching status (`fetching`) and the last * fetch time (`timeFetched`), or undefined if never fetched. */ whenFetched({ descriptor, index }) { if (typeof index !== 'undefined' && descriptor.indexOf('*') === -1) throw new Error(`Pass index (optionally) only for ranged descriptors`); const networkId = (0, networks_1.getNetworkId)(network); const descriptorData = __classPrivateFieldGet(this, _Discovery_discoveryData, "f")[networkId].descriptorMap[(0, deriveData_1.canonicalize)(descriptor, network)]; if (!descriptorData) return undefined; if (typeof index !== 'number') { return { fetching: descriptorData.fetching, timeFetched: descriptorData.timeFetched }; } else { const internalIndex = typeof index === 'number' ? index : 'non-ranged'; const outputData = descriptorData.range[internalIndex]; if (!outputData) return undefined; else return { fetching: outputData.fetching, timeFetched: outputData.timeFetched }; } } /** * Asynchronously discovers standard accounts (pkh, sh(wpkh), wpkh) associated * with a master node. It uses a given gap limit for * discovery. * * @param options * @returns Resolves when all the standrd accounts from the master node have * been discovered. */ async fetchStandardAccounts({ masterNode, gapLimit = DEFAULT_GAP_LIMIT, onAccountUsed, onAccountChecking, onAccountProgress }) { const discoveryTasks = []; const { pkhBIP32, shWpkhBIP32, wpkhBIP32, trBIP32 } = descriptors_1.scriptExpressions; if (!masterNode) throw new Error(`Error: provide a masterNode`); for (const expressionFn of [pkhBIP32, shWpkhBIP32, wpkhBIP32, trBIP32]) { let accountNumber = 0; const next = async () => { const descriptors = [0, 1].map(change => expressionFn({ masterNode, network, account: accountNumber, change, index: '*' })); const account = descriptors[0]; //console.log('STANDARD', { descriptors, gapLimit, account }); accountNumber++; const onUsed = onAccountUsed && (async () => await onAccountUsed(account)); const onChecking = onAccountChecking && (async () => await onAccountChecking(account)); const onProgress = onAccountProgress ? async (descriptor, index) => { if (typeof index !== 'number') throw new Error('fetchStandardAccounts shall use only ranged descriptors'); // Standard accounts use ranged descriptors, so index will be a number. const userCallbackResult = await onAccountProgress({ account, descriptor, index }); if (userCallbackResult === false) { return false; // Propagate abort signal } } : undefined; await this.fetch({ descriptors, gapLimit, next, ...(onUsed ? { onUsed } : {}), ...(onChecking ? { onChecking } : {}), ...(onProgress ? { onProgress } : {}) }); }; discoveryTasks.push(next()); } await Promise.all(discoveryTasks); } /** * Retrieves the array of descriptors with used outputs. * The result is cached based on the size specified in the constructor. * As long as this cache size is not exceeded, this function will maintain * the same object reference if the returned array hasn't changed. * This characteristic can be particularly beneficial in * React and similar projects, where re-rendering occurs based on reference changes. * * @param options * @returns Returns an array of descriptor expressions. * These are derived from the discovery information. * */ getUsedDescriptors() { const networkId = (0, networks_1.getNetworkId)(network); return __classPrivateFieldGet(this, _Discovery_derivers, "f").deriveUsedDescriptors(__classPrivateFieldGet(this, _Discovery_discoveryData, "f"), networkId); } /** * Retrieves all the {@link Account accounts} with used outputs: * those descriptors with keyPaths ending in `{/0/*, /1/*}`. An account is identified * by its external descriptor `keyPath = /0/*`. The result is cached based on * the size specified in the constructor. As long as this cache size is not * exceeded, this function will maintain the same object reference if the returned array remains unchanged. * This characteristic can be especially beneficial in * React or similar projects, where re-rendering occurs based on reference changes. * * @param options * @returns An array of accounts, each represented * as its external descriptor expression. */ getUsedAccounts() { const networkId = (0, networks_1.getNetworkId)(network); return __classPrivateFieldGet(this, _Discovery_derivers, "f").deriveUsedAccounts(__classPrivateFieldGet(this, _Discovery_discoveryData, "f"), networkId); } /** * Retrieves descriptor expressions associated with a specific account. * The result is cached based on the size specified in the constructor. * As long as this cache size is not exceeded, this function will maintain * the same object reference. This characteristic can be especially * beneficial in React or similar projects, where re-rendering occurs based * on reference changes. * * @param options * @returns An array of descriptor expressions * associated with the specified account. */ getAccountDescriptors({ account }) { return __classPrivateFieldGet(this, _Discovery_derivers, "f").deriveAccountDescriptors(account); } /** * Retrieves unspent transaction outputs (UTXOs) and balance associated with * one or more descriptor expressions and transaction status. * In addition it also retrieves spent transaction outputs (STXOS) which correspond * to previous UTXOs that have been spent. * * This method is useful for accessing the available funds for specific * descriptor expressions in the wallet, considering the transaction status * (confirmed, unconfirmed, or both). * * The return value is computed based on the current state of discoveryData. * The method uses memoization to maintain the same object reference for the * returned result, given the same input parameters, as long as the * corresponding UTXOs in discoveryData haven't changed. * This can be useful in environments such as React where * preserving object identity can prevent unnecessary re-renders. * * @param outputCriteria * @returns An object containing the UTXOs associated with the * scriptPubKeys and the total balance of these UTXOs. * It also returns previous UTXOs that had been * eventually spent as stxos: Array<Stxo> * Finally, it returns `txoMap`. `txoMap` maps all the txos (unspent or spent * outputs) with their corresponding `indexedDescriptor: IndexedDescriptor` * (see {@link IndexedDescriptor IndexedDescriptor}) * */ getUtxosAndBalance({ descriptor, index, descriptors, txStatus = types_1.TxStatus.ALL }) { __classPrivateFieldGet(this, _Discovery_instances, "m", _Discovery_ensureFetched).call(this, { ...(descriptor ? { descriptor } : {}), ...(descriptors ? { descriptors } : {}), ...(index !== undefined ? { index } : {}) }); if ((descriptor && descriptors) || !(descriptor || descriptors)) throw new Error(`Pass descriptor or descriptors`); if (typeof index !== 'undefined' && (descriptors || !descriptor?.includes('*'))) throw new Error(`Don't pass index`); const descriptorOrDescriptors = (0, deriveData_1.canonicalize)((descriptor || descriptors), network); const networkId = (0, networks_1.getNetworkId)(network); const descriptorMap = __classPrivateFieldGet(this, _Discovery_discoveryData, "f")[networkId].descriptorMap; const txMap = __classPrivateFieldGet(this, _Discovery_discoveryData, "f")[networkId].txMap; if (descriptor && (typeof index !== 'undefined' || !descriptor.includes('*'))) { const internalIndex = typeof index === 'number' ? index : 'non-ranged'; const txMap = __classPrivateFieldGet(this, _Discovery_discoveryData, "f")[networkId].txMap; return __classPrivateFieldGet(this, _Discovery_derivers, "f").deriveUtxosAndBalanceByOutput(networkId, txMap, descriptorMap, descriptorOrDescriptors, internalIndex, txStatus); } else return __classPrivateFieldGet(this, _Discovery_derivers, "f").deriveUtxosAndBalance(networkId, txMap, descriptorMap, descriptorOrDescriptors, txStatus); } /** * Convenience function which internally invokes the * `getUtxosAndBalance(options).balance` method. */ getBalance(outputCriteria) { return this.getUtxosAndBalance(outputCriteria).balance; } /** * Convenience function which internally invokes the * `getUtxosAndBalance(options).utxos` method. */ getUtxos(outputCriteria) { return this.getUtxosAndBalance(outputCriteria).utxos; } /** * Retrieves the next available index for a given descriptor. * * The method retrieves the currently highest index used, and returns the * next available index by incrementing it by 1. * * @param options * @returns The next available index. */ getNextIndex({ descriptor, txStatus = types_1.TxStatus.ALL }) { if (!descriptor || descriptor.indexOf('*') === -1) throw new Error(`Error: invalid ranged descriptor: ${descriptor}`); __classPrivateFieldGet(this, _Discovery_instances, "m", _Discovery_ensureFetched).call(this, { descriptor }); const networkId = (0, networks_1.getNetworkId)(network); const descriptorMap = __classPrivateFieldGet(this, _Discovery_discoveryData, "f")[networkId].descriptorMap; const txMap = __classPrivateFieldGet(this, _Discovery_discoveryData, "f")[networkId].txMap; let index = 0; while (__classPrivateFieldGet(this, _Discovery_derivers, "f").deriveHistoryByOutput(false, networkId, txMap, descriptorMap, (0, deriveData_1.canonicalize)(descriptor, network), index, txStatus).length) index++; return index; } /** * Retrieves the transaction history for one or more descriptor expressions. * * This method accesses transaction records associated with descriptor expressions * and transaction status. * * When `withAttributions` is `false`, it returns an array of historical transactions * (`Array<TxData>`). See {@link TxData TxData}. * * To determine if each transaction corresponds to a sent/received transaction, set * `withAttributions` to `true`. * * When `withAttributions` is `true`, this function returns an array of * {@link TxAttribution TxAttribution} elements. * * `TxAttribution` identifies the owner of the previous output for each input and * the owner of the output for each transaction. * * This is useful in wallet applications to specify whether inputs are from owned * outputs (e.g., change from a previous transaction) or from third parties. It * also specifies if outputs are destined to third parties or are internal change. * This helps wallet apps show transaction history with "Sent" or "Received" labels, * considering only transactions with third parties. * * See {@link TxAttribution TxAttribution} for a complete list of items returned per * transaction. * * The return value is computed based on the current state of `discoveryData`. The * method uses memoization to maintain the same object reference for the returned * result, given the same input parameters, as long as the corresponding transaction * records in `discoveryData` haven't changed. * * This can be useful in environments such as React, where preserving object identity * can prevent unnecessary re-renders. * * @param outputCriteria - Criteria for selecting transaction outputs, including descriptor * expressions, transaction status, and whether to include attributions. * @param withAttributions - Whether to include attributions in the returned data. * @returns An array containing transaction information associated with the descriptor * expressions. */ getHistory({ descriptor, index, descriptors, txStatus = types_1.TxStatus.ALL }, withAttributions = false) { if ((descriptor && descriptors) || !(descriptor || descriptors)) throw new Error(`Pass descriptor or descriptors`); if (typeof index !== 'undefined' && (descriptors || !descriptor?.includes('*'))) throw new Error(`Don't pass index`); const descriptorOrDescriptors = (0, deriveData_1.canonicalize)((descriptor || descriptors), network); __classPrivateFieldGet(this, _Discovery_instances, "m", _Discovery_ensureFetched).call(this, { ...(descriptor ? { descriptor } : {}), ...(descriptors ? { descriptors } : {}), ...(index !== undefined ? { index } : {}) }); const networkId = (0, networks_1.getNetworkId)(network); const descriptorMap = __classPrivateFieldGet(this, _Discovery_discoveryData, "f")[networkId].descriptorMap; const txMap = __classPrivateFieldGet(this, _Discovery_discoveryData, "f")[networkId].txMap; let txWithOrderArray = []; if (descriptor && (typeof index !== 'undefined' || !descriptor.includes('*'))) { const internalIndex = typeof index === 'number' ? index : 'non-ranged'; txWithOrderArray = __classPrivateFieldGet(this, _Discovery_derivers, "f").deriveHistoryByOutput(withAttributions, networkId, txMap, descriptorMap, descriptorOrDescriptors, internalIndex, txStatus); } else txWithOrderArray = __classPrivateFieldGet(this, _Discovery_derivers, "f").deriveHistory(withAttributions, networkId, txMap, descriptorMap, descriptorOrDescriptors, txStatus); return txWithOrderArray; } /** * Retrieves the hexadecimal representation of a transaction (TxHex) from the * discoveryData given the transaction ID (TxId) or a Unspent Transaction Output (Utxo) * * @param options * @returns The hexadecimal representation of the transaction. * @throws Will throw an error if the transaction ID is invalid or if the TxHex is not found. */ getTxHex({ txId, utxo }) { if ((txId && utxo) || (!txId && !utxo)) { throw new Error(`Error: Please provide either a txId or a utxo, not both or neither.`); } const networkId = (0, networks_1.getNetworkId)(network); txId = utxo ? utxo.split(':')[0] : txId; if (!txId) throw new Error(`Error: invalid input`); const txHex = __classPrivateFieldGet(this, _Discovery_discoveryData, "f")[networkId].txMap[txId]?.txHex; if (!txHex) throw new Error(`Error: txHex not found for ${txId} while getting TxHex`); return txHex; } /** * Retrieves the transaction data as a bitcoinjs-lib * {@link https://github.com/bitcoinjs/bitcoinjs-lib/blob/master/ts_src/transaction.ts Transaction} * object given the transaction * ID (TxId) or a Unspent Transaction Output (Utxo) or the hexadecimal * representation of the transaction (it will then use memoization). * The transaction data is obtained by first getting * the transaction hexadecimal representation using getTxHex() method * (unless the txHex was passed). * * Use this method for quick access to the Transaction object, which avoids the * need to parse the transaction hexadecimal representation (txHex). * The data will have already been computed and cached for efficiency within * the Discovery class. * * @param options * @returns The transaction data as a Transaction object. */ getTransaction({ txId, txHex, utxo }) { if (!txHex) txHex = this.getTxHex({ ...(utxo ? { utxo } : {}), ...(txId ? { txId } : {}) }); return __classPrivateFieldGet(this, _Discovery_derivers, "f").transactionFromHex(txHex).tx; } /** * Compares two transactions based on their blockHeight and input dependencies. * Can be used as callback in Array.sort function to sort from old to new. * * @param txWithOrderA - The first transaction data to compare. * @param txWithOrderB - The second transaction data to compare. * * txWithOrderA and txWithOrderB should contain the `blockHeight` (use 0 if * in the mempool) and either `tx` (`Transaction` type) or `txHex` (the * hexadecimal representation of the transaction) * * @returns < 0 if txWithOrderA is older than txWithOrderB, > 0 if * txWithOrderA is newer than txWithOrderB, and 0 if undecided. */ compareTxOrder(txWithOrderA, txWithOrderB) { return __classPrivateFieldGet(this, _Discovery_derivers, "f").compareTxOrder(txWithOrderA, txWithOrderB); } /** * Given an unspent tx output, this function retrieves its descriptor (if still unspent). * Alternatively, pass a txo (any transaction output, which may have been * spent already or not) and this function will also retrieve its descriptor. * txo can be in any of these formats: `${txId}:${vout}` or * using its extended form: `${txId}:${vout}:${recipientTxId}:${recipientVin}` * * Returns the descriptor (and index if ranged) or undefined if not found. */ getDescriptor({ utxo, txo }) { if (utxo && txo) throw new Error('Pass either txo or utxo, not both'); if (utxo) txo = utxo; const networkId = (0, networks_1.getNetworkId)(network); if (!txo) throw new Error('Pass either txo or utxo'); const split = txo.split(':'); if (utxo && split.length !== 2) throw new Error(`Error: invalid utxo: ${utxo}`); if (!utxo && split.length !== 2 && split.length !== 4) throw new Error(`Error: invalid txo: ${txo}`); const txId = split[0]; if (!txId) throw new Error(`Error: invalid txo: ${txo}`); const strVout = split[1]; if (!strVout) throw new Error(`Error: invalid txo: ${txo}`); const vout = parseInt(strVout); if (vout.toString() !== strVout) throw new Error(`Error: invalid txo: ${txo}`); const descriptors = __classPrivateFieldGet(this, _Discovery_derivers, "f").deriveUsedDescriptors(__classPrivateFieldGet(this, _Discovery_discoveryData, "f"), networkId); let output; const { utxos, txoMap } = this.getUtxosAndBalance({ descriptors }); const txoMapKey = `${txId}:${vout}`; //normalizes txos with 4 parts const indexedDescriptor = txoMap[txoMapKey]; if (indexedDescriptor) { if (utxo && !utxos.find(currentUtxo => currentUtxo === utxo)) return undefined; const splitTxo = (str) => { const lastIndex = str.lastIndexOf('~'); if (lastIndex === -1) throw new Error(`Separator '~' not found in string`); return [str.slice(0, lastIndex), str.slice(lastIndex + 1)]; }; const [descriptor, internalIndex] = splitTxo(indexedDescriptor); output = { descriptor, ...(internalIndex === 'non-ranged' ? {} : { index: Number(internalIndex) }) }; } return output; } /** * Pushes a transaction to the network and updates the internal state. * * This function first broadcasts the transaction using the configured explorer. * It then attempts to update the internal `discoveryData` by calling * `addTransaction`. * * If `addTransaction` reports that one or more inputs of the pushed transaction * were already considered spent by other transactions in the library's records * (e.g., in an RBF scenario or a double-spend attempt already known to the * library), `push` will automatically attempt to synchronize the state. It * does this by calling `fetch` on all unique descriptors associated with the * conflicting input(s). This helps ensure the library's state reflects the * most current information from the blockchain/mempool regarding these conflicts. * * The `gapLimit` parameter is used both when `addTransaction` is called and * during any subsequent automatic `fetch` operation triggered by conflicts. * It helps discover new outputs (e.g., change addresses) that might become * active due to the transaction. * * Note: The success of broadcasting the transaction via `explorer.push(txHex)` * depends on the network and node policies. Even if broadcast is successful, * the transaction might not be immediately visible in the mempool or might be * replaced. A warning is logged if the transaction is not found in the * mempool shortly after being pushed. The final state in the library will * reflect the outcome of the internal `addTransaction` call and any * automatic synchronization that occurred. */ async push({ txHex, gapLimit = DEFAULT_GAP_LIMIT }) { const DETECTION_INTERVAL = 3000; const DETECT_RETRY_MAX = 20; const { txId } = __classPrivateFieldGet(this, _Discovery_derivers, "f").transactionFromHex(txHex); await explorer.push(txHex); //Now, make sure it made it to the mempool: let foundInMempool = false; for (let i = 0; i < DETECT_RETRY_MAX; i++) { if (await explorer.fetchTx(txId)) { foundInMempool = true; break; } await new Promise(resolve => setTimeout(resolve, DETECTION_INTERVAL)); } const txData = { irreversible: false, blockHeight: 0, txHex }; const addResult = this.addTransaction({ txData, gapLimit }); let syncPerformed = false; if (!addResult.success && addResult.reason === 'INPUTS_ALREADY_SPENT' && addResult.conflicts.length > 0) { // A conflict occurred: one or more inputs of the pushed transaction were already // considered spent by other transactions in our records. // Fetch all unique descriptors that hold the conflicting TXOs to sync their state. const uniqueDescriptorsToSync = Array.from(new Set(addResult.conflicts.map(c => c.descriptor))); if (uniqueDescriptorsToSync.length > 0) { await this.fetch({ descriptors: uniqueDescriptorsToSync, gapLimit }); syncPerformed = true; } // After fetching, the state for the conflicting descriptors is updated. // The originally pushed transaction might or might not be the one that "won". } if (syncPerformed) { console.warn(`txId ${txId}: Input conflict(s) detected; state synchronization was performed for affected descriptors. The library state reflects this outcome.`); } if (!foundInMempool) { console.warn(`txId ${txId}: Pushed transaction was not found in the mempool immediately after broadcasting.`); } } /** * Given a transaction it updates the internal `discoveryData` state to * include it. * * This function is useful when a transaction affecting one of the * descriptors has been pushed to the blockchain by a third party. It allows * updating the internal representation without performing a more expensive * `fetch` operation. * * If the transaction was recently pushed to the blockchain, set * `txData.irreversible = false` and `txData.blockHeight = 0`. * * The transaction is represented by `txData`, where * `txData = { blockHeight: number; irreversible: boolean; txHex: TxHex; }`. * * It includes its hexadecimal representation `txHex`, its `blockHeight` * (zero if it's in the mempool), and whether it is `irreversible` or * not. `irreversible` is set by the Explorer, using the configuration parameter * `irrevConfThresh` (defaults to `IRREV_CONF_THRESH = 3`). It can be obtained * by calling explorer.fetchTxHistory(), for example. Set it to * `false` when it's been just pushed (which will be the typical use of this * function). * * The `gapLimit` parameter is essential for managing descriptor discovery. * When addint a transaction, there is a possibility the transaction is * adding new funds as change (for example). If the range for that index * does not exist yet, the `gapLimit` helps to update the descriptor * corresponding to a new UTXO for new indices within the gap limit. * * This function updates the internal `discoveryData` state to include the * provided transaction, but only if it doesn't attempt to spend outputs * already considered spent by the library. * * It first checks all inputs of the transaction. If any input corresponds to * a `txo` (a previously known output) that is not a current `utxo` * (i.e., it's already considered spent by another transaction in the * library's records), this function will not modify the library state. * Instead, it will return an object detailing all such conflicting inputs. * This allows the caller (e.g., the `push` method) to handle these * conflicts, for instance, by re-fetching the state of all affected descriptors. * * If no such input conflicts are found, the transaction is processed: * its details are added to the `txMap`, and relevant `txId`s are associated * with the `OutputData` of both its inputs (if owned) and outputs (if owned). * * For other types of errors (e.g., invalid input data), it may still throw. * * Refer to the `push` method's documentation for how it utilizes this * return status for automatic state synchronization. * * @returns An object indicating the outcome: * - `{ success: true }` if the transaction was added without conflicts. * - `{ success: false; reason: 'INPUTS_ALREADY_SPENT'; conflicts: Array<{ descriptor: Descriptor; txo: Utxo; index?: number }> }` * if one or more inputs of the transaction were already considered spent. * `conflicts` contains an array of all such detected conflicts. */ addTransaction({ txData, gapLimit = DEFAULT_GAP_LIMIT }) { const txHex = txData.txHex; if (!txHex) throw new Error('txData must contain complete txHex information'); const { tx, txId } = __classPrivateFieldGet(this, _Discovery_derivers, "f").transactionFromHex(txHex); const networkId = (0, networks_1.getNetworkId)(network); const conflicts = []; // First pass: Check all inputs for conflicts without modifying state yet. for (let vin = 0; vin < tx.ins.length; vin++) { const input = tx.ins[vin]; if (!input) throw new Error(`Error: invalid input for ${txId}:${vin}`); const prevTxId = Buffer.from(input.hash).reverse().toString('hex'); const prevVout = input.index; const prevUtxo = `${prevTxId}:${prevVout}`; const isSpendingKnownUtxo = this.getDescriptor({ utxo: prevUtxo }); if (!isSpendingKnownUtxo) { // Not spending a known UTXO, check if it's spending a known TXO (already spent) const txoDescriptorInfo = this.getDescriptor({ txo: prevUtxo }); if (txoDescriptorInfo) { const conflict = { descriptor: txoDescriptorInfo.descriptor, txo: prevUtxo }; if (txoDescriptorInfo.index !== undefined) conflict.index = txoDescriptorInfo.index; conflicts.push(conflict); } } } if (conflicts.length > 0) { return { success: false, reason: 'INPUTS_ALREADY_SPENT', conflicts }; } // Second pass: No conflicts found, proceed to update discoveryData. __classPrivateFieldSet(this, _Discovery_discoveryData, (0, immer_1.produce)(__classPrivateFieldGet(this, _Discovery_discoveryData, "f"), discoveryData => { const txMap = discoveryData[networkId].txMap; const update = (descriptor, index) => { const range = discoveryData[networkId].descriptorMap[descriptor]?.range; if (!range) throw new Error(`unset range ${networkId}:${descriptor}`); const outputData = range[index]; if (!outputData) throw new Error(`unset index ${index} for descriptor ${descriptor}`); //Note that update is called twice (for inputs and outputs), so //don't push twice when auto-sending from same utxo to same output if (!outputData.txIds.includes(txId)) outputData.txIds.push(txId); if (!txMap[txId]) txMap[txId] = txData; //Only add it once }; // Process inputs (we know they are valid UTXOs or external) for (let vin = 0; vin < tx.ins.length; vin++) { const input = tx.ins[vin]; if (!input) throw new Error(`Error: invalid input for ${txId}:${vin}`); //Note we create a new Buffer since reverse() mutates the Buffer const prevTxId = Buffer.from(input.hash).reverse().toString('hex'); const prevVout = input.index; const prevUtxo = `${prevTxId}:${prevVout}`; const extendedDescriptor = this.getDescriptor({ utxo: prevUtxo }); if (extendedDescriptor) { //This means this tx is spending an utxo tracked by this discovery instance update(extendedDescriptor.descriptor, extendedDescriptor.index === undefined ? 'non-ranged' : extendedDescriptor.index); } } // Process outputs for (let vout = 0; vout < tx.outs.length; vout++) { const nextScriptPubKey = tx.outs[vout]?.script; if (!nextScriptPubKey) throw new Error(`Error: invalid output script for ${txId}:${vout}`); const descriptorWithIndex = __classPrivateFieldGet(this, _Discovery_instances, "m", _Discovery_getDescriptorByScriptPubKey).call(this, { networkId, scriptPubKey: nextScriptPubKey, gapLimit }); if (descriptorWithIndex) { //This means this tx is sending funds to a scriptPubKey tracked by //this discovery instance update(descriptorWithIndex.descriptor, descriptorWithIndex.index); } } }), "f"); return { success: true }; } /** * Retrieves the Explorer instance. * * @returns The Explorer instance. */ getExplorer() { return explorer; } /** * Exports the current state of the Discovery instance. * This method is used to serialize the state of the Discovery instance so * that it can be saved and potentially re-imported later using the * `imported` parameter in the constructor. * * The exported data includes a version string and a deep clone of the * internal discovery data. The deep cloning process ensures that the * exported data is a snapshot of the internal state, isolated from future * changes to the Discovery instance. This isolation maintains the integrity * and immutability of the exported data. * * The inclusion of a version string in the exported data allows for * compatibility checks when re-importing the data. This check ensures that * the data model of the imported data is compatible with the current * version of the Discovery class. * * The exported data is guaranteed to be serializable, allowing it to be * safely stored or transmitted. It can be serialized using JSON.stringify * or other serialization methods, such as structured serialization * (https://html.spec.whatwg.org/multipage/structured-data.html#structuredserializeinternal). * This feature ensures that the data can be serialized and deserialized * without loss of integrity, facilitating data persistence * and transfer across different sessions or environments. * * @returns An object containing the version string and the serialized discovery data. */ export() { return { version: types_1.DATA_MODEL_VERSION, discoveryData: (0, lodash_clonedeep_1.default)(__classPrivateFieldGet(this, _Discovery_discoveryData, "f")) }; } } _Discovery_derivers = new WeakMap(), _Discovery_discoveryData = new WeakMap(), _Discovery_instances = new WeakSet(), _Discovery_getDescriptorByScriptPubKey = function _Discovery_getDescriptorByScriptPubKey({ networkId, scriptPubKey, gapLimit = 0 }) { const descriptorMap = __classPrivateFieldGet(this, _Discovery_discoveryData, "f")[networkId].descriptorMap; const descriptors = Object.keys(descriptorMap); for (const descriptor of descriptors) { const range = descriptorMap[descriptor]?.range || {}; let maxUsedIndex = -1; for (const indexStr of Object.keys(range)) { const index = indexStr === 'non-ranged' ? indexStr : Number(indexStr); if (scriptPubKey.equals(__classPrivateFieldGet(this, _Discovery_derivers, "f").deriveScriptPubKey(networkId, descriptor, index) //This will be very fast (uses memoization) )) { return { descriptor, index }; } if (typeof index === 'number') { if (maxUsedIndex === 'non-ranged') throw new Error('maxUsedIndex shoulnt be set as non-ranged'); if (index > maxUsedIndex && range[index]?.txIds.length) maxUsedIndex = index; } if (index === 'non-ranged')