UNPKG

@bigmi/core

Version:

TypeScript library for Bitcoin apps.

232 lines 13.2 kB
import { address, Transaction } from 'bitcoinjs-lib'; import { TransactionNotFoundError, TransactionReceiptNotFoundError, WaitForTransactionReceiptTimeoutError, } from '../errors/transaction.js'; import { getAction } from '../utils/getAction.js'; import { observe } from '../utils/observe.js'; import { stringify } from '../utils/stringify.js'; import { withRetry } from '../utils/withRetry.js'; import { getBlock } from './getBlock.js'; import { getBlockStats } from './getBlockStats.js'; import { getUTXOTransaction } from './getUTXOTransaction.js'; import { watchBlockNumber } from './watchBlockNumber.js'; /** * Waits for the transaction to be included on a block (one confirmation), and then returns the transaction. * - JSON-RPC Methods: * - Polls getrawtransaction on each block until it has been processed. * - If a transaction has been replaced: * - Calls getblock and extracts the transactions * - Checks if one of the transactions is a replacement * - If so, calls getrawtransaction. * * The `waitForTransaction` action additionally supports replacement detection (e.g. RBF - transactions replaced-by-fee ). * * Transactions can be replaced when a user modifies their transaction in their wallet (to speed up or cancel). * https://bitcoinops.org/en/topics/replace-by-fee/ * * There are 3 types of Transaction Replacement reasons: * * - `repriced`: The fee has been modified (e.g. same outputs, different amounts) * - `cancelled`: The Transaction has been cancelled (e.g. output is sender address) * - `replaced`: The Transaction has been replaced (e.g. different outputs) * @param client - Client to use * @param parameters - {@link WaitForTransactionReceiptParameters} * @returns The UTXO transaction. {@link WaitForTransactionReceiptReturnType} */ export async function waitForTransaction(client, { confirmations = 1, txId, txHex, senderAddress, onReplaced, pollingInterval = client.pollingInterval, retryCount = 10, retryDelay = 3000, timeout, }) { const observerId = stringify(['waitForTransaction', client.uid, txId]); let count = 0; let transaction; let replacedTransaction; let retrying = false; return new Promise((resolve, reject) => { if (timeout) { setTimeout(() => reject(new WaitForTransactionReceiptTimeoutError({ hash: txId })), timeout); } const _unobserve = observe(observerId, { onReplaced, resolve, reject }, (emit) => { const _unwatch = getAction(client, watchBlockNumber, 'watchBlockNumber')({ emitMissed: true, emitOnBegin: true, pollingInterval, async onBlockNumber(blockNumber_) { const done = (fn) => { _unwatch(); fn(); _unobserve(); }; let blockNumber = blockNumber_; if (retrying) { return; } if (count > retryCount) { done(() => emit.reject(new WaitForTransactionReceiptTimeoutError({ hash: txId, }))); } try { // If we already have a valid receipt, let's check if we have enough // confirmations. If we do, then we can resolve. if (transaction?.blockhash) { const blockStats = await getAction(client, getBlockStats, 'getBlockStats')({ blockHash: transaction.blockhash, stats: ['height'], }); if (confirmations > 1 && (!blockStats.height || blockNumber - blockStats.height + 1 < confirmations)) { return; } done(() => emit.resolve(transaction)); return; } // Get the transaction to check if it's been replaced. // We need to retry as some RPC Providers may be slow to sync // up mined transactions. retrying = true; transaction = await withRetry(() => getAction(client, getUTXOTransaction, 'getUTXOTransaction' // If transaction exists it might be the replaced one with different txId )({ txId: transaction?.txid || txId }), { delay: retryDelay, retryCount, }); if (transaction.blockhash) { const blockStats = await getAction(client, getBlockStats, 'getBlockStats')({ blockHash: transaction.blockhash, stats: ['height'], }); if (blockStats.height) { blockNumber = blockStats.height; } } retrying = false; // Check if transaction has been processed. if (!transaction?.confirmations) { throw new TransactionReceiptNotFoundError({ hash: txId, }); } // Check if we have enough confirmations. If not, continue polling. if (transaction.confirmations < confirmations) { return; } done(() => emit.resolve(transaction)); } catch (err) { // If the receipt is not found, the transaction will be pending. // We need to check if it has potentially been replaced. if (err instanceof TransactionNotFoundError || err instanceof TransactionReceiptNotFoundError) { try { replacedTransaction = Transaction.fromHex(transaction?.hex || txHex); // Let's retrieve the transactions from the current block. // We need to retry as some RPC Providers may be slow to sync // up mined blocks. retrying = true; const block = await withRetry(() => getAction(client, getBlock, 'getBlock')({ blockNumber, }), { delay: retryDelay, retryCount, // shouldRetry: ({ error }) => // error instanceof BlockNotFoundError, }); retrying = false; // Create a set of input identifiers for mempool transaction const replacedTransactionInputs = new Set(); for (const input of replacedTransaction.ins) { const txid = Array.from(input.hash) .reverse() .map((byte) => `00${byte.toString(16)}`.slice(-2)) .join(''); const vout = input.index; const inputId = `${txid}:${vout}`; replacedTransactionInputs.add(inputId); } let replacementTransaction; for (const tx of block.transactions) { if (tx.isCoinbase()) { continue; } // Check if any input of this transaction matches an input of mempool transaction for (const input of tx.ins) { const txid = Array.from(input.hash) .reverse() .map((byte) => `00${byte.toString(16)}`.slice(-2)) .join(''); const vout = input.index; const inputId = `${txid}:${vout}`; if (replacedTransactionInputs.has(inputId)) { replacementTransaction = tx; break; } } if (replacementTransaction) { break; } } // If we couldn't find a replacement transaction, continue polling. if (!replacementTransaction) { return; } // If we found a replacement transaction, return it's receipt. transaction = await getAction(client, getUTXOTransaction, 'getUTXOTransaction')({ txId: replacementTransaction.getId(), }); // Check if we have enough confirmations. If not, continue polling. if (transaction.confirmations && transaction.confirmations < confirmations) { return; } let reason = 'replaced'; // Function to get output addresses function getOutputAddresses(tx) { const addresses = []; for (const output of tx.outs) { try { const outputAddress = address.fromOutputScript(output.script); addresses.push(outputAddress); } catch (_e) { // Handle non-standard scripts (e.g., OP_RETURN) } } return addresses; } // Get the recipient addresses from the original transaction const originalOutputAddresses = getOutputAddresses(replacedTransaction); // Get the recipient addresses from the replacement transaction const replacementOutputAddresses = getOutputAddresses(replacementTransaction); if (originalOutputAddresses.length === replacementOutputAddresses.length && originalOutputAddresses.every((address) => replacementOutputAddresses.includes(address))) { reason = 'repriced'; } else if (senderAddress && replacementOutputAddresses.length === 1 && replacementOutputAddresses.includes(senderAddress)) { reason = 'cancelled'; } done(() => { emit.onReplaced?.({ reason, replacedTransaction: replacedTransaction, transaction: transaction, }); emit.resolve(transaction); }); } catch (err_) { done(() => emit.reject(err_)); } } else { done(() => emit.reject(err)); } } finally { count++; } }, }); }); }); } //# sourceMappingURL=waitForTransaction.js.map