@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
JavaScript
"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')