UNPKG

@caravan/clients

Version:

A package for querying different bitcoin blockchain backends

1,233 lines (1,227 loc) 41 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { BlockchainClient: () => BlockchainClient, ClientType: () => ClientType, PublicBitcoinProvider: () => PublicBitcoinProvider, bitcoindImportDescriptors: () => bitcoindImportDescriptors }); module.exports = __toCommonJS(index_exports); // src/wallet.ts var import_bitcoin2 = require("@caravan/bitcoin"); var import_bignumber2 = require("bignumber.js"); // src/bitcoind.ts var import_bitcoin = require("@caravan/bitcoin"); var import_axios = __toESM(require("axios")); var import_bignumber = require("bignumber.js"); async function callBitcoind(url, auth, method, params = []) { if (!params) params = []; const rpcRequest = { jsonrpc: "2.0", id: 0, // We use a static ID since we're not batching requests method: `${method}`, params }; try { const response = await (0, import_axios.default)(url, { method: "post", headers: { Accept: "application/json", "Content-Type": "application/json" }, auth, data: rpcRequest }); return response.data; } catch (error) { if (error instanceof Error) { throw error; } throw new Error("Unknown error occurred during RPC call"); } } function isWalletAddressNotFoundError(e) { return e.response && e.response.data && e.response.data.error && e.response.data.error.code === -4; } function bitcoindParams(client) { const { url, username, password, walletName } = client; return { url, auth: { username, password }, walletName }; } async function bitcoindEstimateSmartFee({ url, auth, numBlocks = 2 // Default to targeting inclusion within 2 blocks }) { const resp = await callBitcoind( url, auth, "estimatesmartfee", [numBlocks] ); const feeRate = resp.result.feerate; return Math.ceil(feeRate * 1e5); } async function bitcoindSendRawTransaction({ url, auth, hex }) { try { const resp = await callBitcoind(url, auth, "sendrawtransaction", [ hex ]); return resp.result; } catch (e) { console.log("send tx error", e); throw e.response && e.response.data.error.message || e; } } async function bitcoindRawTxData({ url, auth, txid }) { try { const response = await callBitcoind(url, auth, "getrawtransaction", [ txid, true ]); return response.result; } catch (error) { throw new Error( `Failed to get raw transaction data : ${error instanceof Error ? error.message : "Unknown error"}` ); } } // src/wallet.ts var BitcoindWalletClientError = class extends Error { constructor(message) { super(message); this.name = "BitcoindWalletClientError"; } }; function callBitcoindWallet({ baseUrl, walletName, auth, method, params }) { const url = new URL(baseUrl); if (!walletName) { throw new Error( "Wallet name is required for calling wallet specific methods" ); } url.pathname = url.pathname.replace(/\/$/, "") + `/wallet/${walletName}`; return callBitcoind(url.toString(), auth, method, params); } function bitcoindWalletInfo({ url, auth, walletName }) { return callBitcoindWallet({ baseUrl: url, walletName, auth, method: "getwalletinfo" }); } function bitcoindImportDescriptors({ url, auth, walletName, receive, change, rescan }) { const descriptors = [ { desc: receive, internal: false }, { desc: change, internal: true } ].map((d) => { return { ...d, range: [0, 1005], timestamp: rescan ? 0 : "now", watchonly: true, active: true }; }); return callBitcoindWallet({ baseUrl: url, walletName, auth, method: "importdescriptors", params: [descriptors] }); } async function bitcoindGetAddressStatus({ url, auth, walletName, address }) { try { const resp = await callBitcoindWallet({ baseUrl: url, walletName, auth, method: "getreceivedbyaddress", params: [address] }); if (typeof resp?.result === "undefined") { throw new BitcoindWalletClientError( `Error: invalid response from ${url}` ); } return { used: resp?.result > 0 }; } catch (e) { const error = e; if (isWalletAddressNotFoundError(error)) console.warn( `Address ${address} not found in bitcoind's wallet. Query failed.` ); else console.error(error.message); return e; } } async function bitcoindListUnspent({ url, auth, walletName, address, addresses }) { try { const addressParam = addresses || [address]; const resp = await callBitcoindWallet({ baseUrl: url, auth, walletName, method: "listunspent", params: { minconf: 0, maxconf: 9999999, addresses: addressParam } }); const promises = []; resp.result.forEach((utxo) => { promises.push( callBitcoindWallet({ baseUrl: url, walletName, auth, method: "gettransaction", params: { txid: utxo.txid } }) ); }); const previousTransactions = await Promise.all(promises); return resp.result.map((utxo, mapindex) => { const amount = new import_bignumber2.BigNumber(utxo.amount); return { confirmed: (utxo.confirmations || 0) > 0, txid: utxo.txid, index: utxo.vout, amount: amount.toFixed(8), amountSats: (0, import_bitcoin2.bitcoinsToSatoshis)(amount.toString()), transactionHex: previousTransactions[mapindex].result.hex, time: previousTransactions[mapindex].result.blocktime }; }); } catch (e) { console.error("There was a problem:", e.message); throw e; } } async function bitcoindGetWalletTransaction({ url, auth, walletName, txid, includeWatchonly = true, verbose = true }) { try { const response = await callBitcoindWallet({ baseUrl: url, walletName, auth, method: "gettransaction", params: [txid, includeWatchonly, verbose] }); if (typeof response?.result === "undefined") { throw new BitcoindWalletClientError( `Error: invalid response from ${url} for transaction ${txid}` ); } return response.result; } catch (e) { console.error("Error getting wallet transaction:", e.message); throw e; } } // src/client.ts var import_bitcoin3 = require("@caravan/bitcoin"); var import_axios2 = __toESM(require("axios")); var import_bignumber3 = require("bignumber.js"); var BlockchainClientError = class extends Error { constructor(message) { super(message); this.name = "BlockchainClientError"; } }; var ClientType = /* @__PURE__ */ ((ClientType2) => { ClientType2["PRIVATE"] = "private"; ClientType2["PUBLIC"] = "public"; ClientType2["MEMPOOL"] = "mempool"; ClientType2["BLOCKSTREAM"] = "blockstream"; return ClientType2; })(ClientType || {}); var PublicBitcoinProvider = /* @__PURE__ */ ((PublicBitcoinProvider2) => { PublicBitcoinProvider2["BLOCKSTREAM"] = "blockstream"; PublicBitcoinProvider2["MEMPOOL"] = "mempool"; return PublicBitcoinProvider2; })(PublicBitcoinProvider || {}); var delay = () => { return new Promise((resolve) => setTimeout(resolve, 500)); }; function transformWalletTransactionToRawTransactionData(walletTx) { if (!walletTx.decoded) { throw new Error( "Transaction decoded data is missing. Make sure verbose=true was passed to gettransaction." ); } const feeSats = Math.abs(walletTx.fee || 0) * 1e8; const category = walletTx.details && walletTx.details.length > 0 ? walletTx.details[0]["category"] : "unknown"; return { amount: walletTx.amount, txid: walletTx.txid, version: walletTx.decoded.version, locktime: walletTx.decoded.locktime, size: walletTx.decoded.size, vsize: walletTx.decoded.vsize, weight: walletTx.decoded.weight, category, details: walletTx.details, fee: feeSats, // Convert from BTC to satoshis vin: walletTx.decoded.vin.map((input) => ({ txid: input.txid, vout: input.vout, sequence: input.sequence })), vout: walletTx.decoded.vout.map((output) => ({ value: output.value, scriptpubkey: output.scriptPubKey.hex, scriptpubkey_address: output.scriptPubKey.address })), confirmations: walletTx.confirmations, blockhash: walletTx.blockhash, blocktime: walletTx.blocktime, status: { confirmed: (walletTx.confirmations || 0) > 0, block_height: walletTx.blockheight, block_hash: walletTx.blockhash, block_time: walletTx.blocktime }, hex: walletTx.hex }; } function normalizeTransactionData(txData, clientType) { const normalizedAmount = txData.amount !== void 0 ? clientType === "private" /* PRIVATE */ ? txData.amount : (0, import_bitcoin3.satoshisToBitcoins)(txData.amount) : void 0; const isReceived = txData.category === "receive" || false; return { txid: txData.txid, version: txData.version, locktime: txData.locktime, vin: txData.vin.map((input) => ({ txid: input.txid, vout: input.vout, sequence: input.sequence })), vout: txData.vout.map((output) => ({ value: clientType === "private" /* PRIVATE */ ? output.value : (0, import_bitcoin3.satoshisToBitcoins)(output.value), scriptPubkey: output.scriptpubkey, scriptPubkeyAddress: output.scriptpubkey_address })), size: txData.size, ...txData.vsize !== void 0 && { vsize: txData.vsize }, ...normalizedAmount !== void 0 && { amount: normalizedAmount }, ...isReceived !== void 0 && { isReceived }, ...txData.details !== void 0 && { details: txData.details }, weight: txData.weight, fee: clientType === "private" /* PRIVATE */ ? txData.fee || 0 : txData.fee, status: { confirmed: txData.status?.confirmed ?? txData.confirmations > 0, blockHeight: txData.status?.block_height ?? void 0, blockHash: txData.status?.block_hash ?? txData.blockhash, blockTime: txData.status?.block_time ?? txData.blocktime } }; } var ClientBase = class { throttled; host; constructor(throttled, host) { this.throttled = throttled; this.host = host; } async throttle() { if (this.throttled) { await delay(); } } async Request(method, path, data) { await this.throttle(); try { const response = await import_axios2.default.request({ method, url: this.host + path, data, withCredentials: false, headers: { "Content-Type": "application/x-www-form-urlencoded" } }); return response.data; } catch (e) { throw e.response && e.response.data || e; } } async Get(path) { return this.Request("GET", path); } async Post(path, data) { return this.Request("POST", path, data); } }; var BlockchainClient = class extends ClientBase { type; provider; network; bitcoindParams; constructor({ type, provider, network, throttled = false, client = { url: "", username: "", password: "", walletName: "" } }) { if (type === "public" /* PUBLIC */ && network !== import_bitcoin3.Network.MAINNET && network !== import_bitcoin3.Network.TESTNET && network !== import_bitcoin3.Network.SIGNET) { throw new Error("Invalid network"); } if (type === "public" /* PUBLIC */ && provider === "blockstream" /* BLOCKSTREAM */ && network === import_bitcoin3.Network.SIGNET) { throw new Error("Invalid network: Blockstream does not support Signet"); } if (type === "private" /* PRIVATE */ && provider) { throw new Error("Provider cannot be set for private client type"); } if (type === "mempool" /* MEMPOOL */ || type === "blockstream" /* BLOCKSTREAM */) { provider = type; type = "public" /* PUBLIC */; } if (type === "public" /* PUBLIC */ && !provider) { provider = "mempool" /* MEMPOOL */; } let hostURL = ""; if (type === "public" /* PUBLIC */) { if (provider === "blockstream" /* BLOCKSTREAM */) { hostURL = "https://blockstream.info"; } else if (provider === "mempool" /* MEMPOOL */) { hostURL = "https://unchained.mempool.space"; } if (network !== import_bitcoin3.Network.MAINNET) { hostURL += `/${network}`; } hostURL += "/api"; } super(throttled, hostURL); this.network = network; this.type = type; this.provider = provider; this.bitcoindParams = bitcoindParams(client); } async getAddressUtxos(address) { try { if (this.type === "private" /* PRIVATE */) { return bitcoindListUnspent({ address, ...this.bitcoindParams }); } return await this.Get(`/address/${address}/utxo`); } catch (error) { throw new Error( `Failed to get UTXOs for address ${address}: ${error.message}` ); } } async getAddressTransactions(address) { try { if (this.type === "private" /* PRIVATE */) { const data2 = await callBitcoind( this.bitcoindParams.url, this.bitcoindParams.auth, "listtransactions", [this.bitcoindParams.walletName] ); const txs2 = []; for (const tx of data2.result) { if (tx.address === address) { const rawTxData = await bitcoindRawTxData({ url: this.bitcoindParams.url, auth: this.bitcoindParams.auth, txid: tx.txid }); const transaction = { txid: tx.txid, vin: [], vout: [], size: rawTxData.size, weight: rawTxData.weight, fee: tx.fee, isSend: tx.category === "send" ? true : false, amount: tx.amount, block_time: tx.blocktime }; for (const input of rawTxData.vin) { transaction.vin.push({ prevTxId: input.txid, vout: input.vout, sequence: input.sequence }); } for (const output of rawTxData.vout) { transaction.vout.push({ scriptPubkeyHex: output.scriptPubKey.hex, scriptPubkeyAddress: output.scriptPubKey.address, value: output.value }); } txs2.push(transaction); } } return txs2; } const data = await this.Get(`/address/${address}/txs`); const txs = []; for (const tx of data.txs) { const transaction = { txid: tx.txid, vin: [], vout: [], size: tx.size, weight: tx.weight, fee: tx.fee, isSend: false, amount: 0, block_time: tx.status.block_time }; for (const input of tx.vin) { if (input.prevout.scriptpubkey_address === address) { transaction.isSend = true; } transaction.vin.push({ prevTxId: input.txid, vout: input.vout, sequence: input.sequence }); } let total_amount = 0; for (const output of tx.vout) { total_amount += output.value; transaction.vout.push({ scriptPubkeyHex: output.scriptpubkey, scriptPubkeyAddress: output.scriptpubkey_address, value: output.value }); } transaction.amount = total_amount; txs.push(transaction); } return txs; } catch (error) { throw new Error( `Failed to get transactions for address ${address}: ${error.message}` ); } } async broadcastTransaction(rawTx) { try { if (this.type === "private" /* PRIVATE */) { return bitcoindSendRawTransaction({ hex: rawTx, ...this.bitcoindParams }); } return await this.Post(`/tx`, rawTx); } catch (error) { throw new Error(`Failed to broadcast transaction: ${error.message}`); } } async formatUtxo(utxo) { const transactionHex = await this.getTransactionHex(utxo.txid); const amount = new import_bignumber3.BigNumber(utxo.value); return { confirmed: utxo.status.confirmed, txid: utxo.txid, index: utxo.vout, amount: (0, import_bitcoin3.satoshisToBitcoins)(utxo.value), amountSats: amount, transactionHex, time: utxo.status.block_time }; } async fetchAddressUtxos(address) { let unsortedUTXOs; let updates = { utxos: [], balanceSats: (0, import_bignumber3.BigNumber)(0), addressKnown: true, fetchedUTXOs: false, fetchUTXOsError: "" }; try { if (this.type === "private" /* PRIVATE */) { unsortedUTXOs = await bitcoindListUnspent({ ...this.bitcoindParams, address }); } else { const utxos2 = await this.Get(`/address/${address}/utxo`); unsortedUTXOs = await Promise.all( utxos2.map(async (utxo) => await this.formatUtxo(utxo)) ); } } catch (error) { if (this.type === "private" /* PRIVATE */ && isWalletAddressNotFoundError(error)) { updates = { utxos: [], balanceSats: (0, import_bignumber3.BigNumber)(0), addressKnown: false, fetchedUTXOs: true, fetchUTXOsError: "" }; } else { updates = { ...updates, fetchUTXOsError: error.toString() }; } } if (!unsortedUTXOs) return updates; const utxos = (0, import_bitcoin3.sortInputs)(unsortedUTXOs); const balanceSats = utxos.map((utxo) => utxo.amountSats).reduce( (accumulator, currentValue) => accumulator.plus(currentValue), new import_bignumber3.BigNumber(0) ); return { ...updates, balanceSats, utxos, fetchedUTXOs: true, fetchUTXOsError: "" }; } async getAddressStatus(address) { try { if (this.type === "private" /* PRIVATE */) { return await bitcoindGetAddressStatus({ address, ...this.bitcoindParams }); } const addressData = await this.Get(`/address/${address}`); return { used: addressData.chain_stats.funded_txo_count > 0 || addressData.mempool_stats.funded_txo_count > 0 }; } catch (error) { throw new Error( `Failed to get status for address ${address}: ${error.message}` ); } } async getFeeEstimate(blocks = 3) { let fees; try { switch (this.type) { case "private" /* PRIVATE */: return bitcoindEstimateSmartFee({ numBlocks: +blocks, ...this.bitcoindParams }); case "public" /* PUBLIC */: if (!this.provider) { throw new Error("Provider is required for public client type"); } if (this.provider === "blockstream" /* BLOCKSTREAM */) { fees = await this.Get(`/fee-estimates`); return fees[blocks]; } else if (this.provider === "mempool" /* MEMPOOL */) { fees = await this.Get("/v1/fees/recommended"); if (blocks === 1) { return fees.fastestFee; } else if (blocks <= 3) { return fees.halfHourFee; } else if (blocks <= 6) { return fees.hourFee; } else { return fees.economyFee; } } else { throw new Error("Invalid provider type for public client"); } default: throw new Error(`Invalid client type: ${this.type}`); } } catch (error) { throw new Error(`Failed to get fee estimate: ${error.message}`); } } async getBlockFeeRatePercentileHistory() { try { if (this.type === "private" /* PRIVATE */ || this.provider === "blockstream" /* BLOCKSTREAM */) { throw new Error( "Not supported for private clients and blockstream. Currently only supported for mempool" ); } const data = await this.Get(`/v1/mining/blocks/fee-rates/all`); const feeRatePercentileBlocks = []; for (const block of data) { const feeRatePercentile = { avgHeight: block?.avgHeight, timestamp: block?.timestamp, avgFee_0: block?.avgFee_0, avgFee_10: block?.avgFee_10, avgFee_25: block?.avgFee_25, avgFee_50: block?.avgFee_50, avgFee_75: block?.avgFee_75, avgFee_90: block?.avgFee_90, avgFee_100: block?.avgFee_100 }; feeRatePercentileBlocks.push(feeRatePercentile); } return feeRatePercentileBlocks; } catch (error) { throw new Error( `Failed to get feerate percentile block: ${error.message}` ); } } async getTransactionHex(txid) { try { if (this.type === "private" /* PRIVATE */) { const response = await callBitcoindWallet({ baseUrl: this.bitcoindParams.url, walletName: this.bitcoindParams.walletName, auth: this.bitcoindParams.auth, method: "gettransaction", params: [txid, true, true] // [txid, include_watchonly, verbose] }); if (response?.result?.hex) { return response.result.hex; } throw new Error("Transaction not found in wallet or missing hex data"); } return await this.Get(`/tx/${txid}/hex`); } catch (error) { throw new Error(`Failed to get transaction: ${error.message}`); } } /** * Retrieves transaction history for one or more addresses (public clients only) * * This method is designed for public blockchain explorers (Mempool/Blockstream) that * maintain address indexes, allowing efficient address-specific queries. * * Why this method is PUBLIC CLIENT ONLY: * - Public APIs maintain address indexes for efficient lookups * - Bitcoin Core doesn't support address-specific queries (see getWalletTransactionHistory) * - Allows querying multiple addresses with Promise.all for efficiency * * For PRIVATE clients: Use getWalletTransactionHistory() which returns all wallet transactions * * @param address - Single address or array of addresses to query * @param count - Number of transactions to return per address (1-100) * @param skip - Number of transactions to skip for pagination * @returns Combined array of transactions sorted by time (newest first) * @throws Error if called on private client * * @example * // Single address * const txs = await client.getAddressTransactionHistory("bc1q..."); * * // Multiple addresses * const txs = await client.getAddressTransactionHistory(["bc1q...", "bc1p..."]); * * @see getWalletTransactionHistory - For private clients */ async getAddressTransactionHistory(address, count = 10, skip = 0) { if (count < 1 || count > 1e5) { throw new Error("Count must be between 1 and 100000"); } if (skip < 0) { throw new Error("Skip must be non-negative"); } try { if (this.type === "private" /* PRIVATE */) { throw new Error("Use getWalletTransactionHistory for private clients"); } if (!this.provider) { throw new Error("Provider must be specified for public clients"); } const addresses = Array.isArray(address) ? address : [address]; const allTransactions = []; let anyAddressFailed = false; let firstError = null; const results = await Promise.allSettled( addresses.map(async (addr) => { try { const query = this.provider === "blockstream" /* BLOCKSTREAM */ ? `limit=${count}&offset=${skip}` : `count=${count}&skip=${skip}`; const endpoint = `/address/${addr}/txs?${query}`; const response = await this.Get(endpoint); let transactions = []; if (Array.isArray(response)) { transactions = response; } else if (response && typeof response === "object") { const nestedKeys = [ "transactions", "txs", "data", "items", "result" ]; for (const key of nestedKeys) { if (Array.isArray(response[key])) { transactions = response[key]; break; } } if (transactions.length === 0 && response.txid) { transactions = [response]; } } else { console.warn("Unexpected transaction response format:", response); transactions = []; } return Array.isArray(transactions) ? transactions : []; } catch (error) { anyAddressFailed = true; firstError = error; throw error; } }) ); for (const result of results) { if (result.status === "fulfilled") { const transactions = result.value; if (Array.isArray(transactions)) { allTransactions.push(...transactions); } } } if (anyAddressFailed && allTransactions.length === 0) { throw firstError || new Error("Failed to fetch transactions for all addresses"); } if (!Array.isArray(allTransactions)) { console.warn("allTransactions is not an array:", allTransactions); return []; } const sortedTransactions = allTransactions.sort((a, b) => { const timeA = a?.time || a?.timestamp || 0; const timeB = b?.time || b?.timestamp || 0; return timeB - timeA; }).slice(0, count); if (!Array.isArray(sortedTransactions)) { console.warn("sortedTransactions is not an array:", sortedTransactions); return []; } try { return sortedTransactions.map((rawTx) => { if (!rawTx || typeof rawTx !== "object") { console.warn("Invalid transaction object:", rawTx); return { txid: "unknown", version: 1, locktime: 0, vin: [], vout: [], size: 0, weight: 0, fee: 0, isReceived: false, status: { confirmed: false, blockHeight: void 0, blockHash: void 0, blockTime: void 0 } }; } let isReceived = false; let hasInputFromOurAddresses = false; let hasOutputToOurAddresses = false; if (rawTx.vin && Array.isArray(rawTx.vin)) { for (const input of rawTx.vin) { const inputAddress = input.prevout?.scriptpubkey_address || input.scriptpubkey_address || input.addr || input.address || input.prevout?.addr || input.prevout?.address; if (inputAddress && addresses.includes(inputAddress)) { hasInputFromOurAddresses = true; break; } } } if (rawTx.vout && Array.isArray(rawTx.vout)) { for (const output of rawTx.vout) { const outputAddress = output.scriptpubkey_address || output.addr || output.address; if (outputAddress && addresses.includes(outputAddress)) { hasOutputToOurAddresses = true; break; } } } isReceived = hasOutputToOurAddresses && !hasInputFromOurAddresses; if (typeof rawTx.amount === "number") { rawTx.amount = (0, import_bitcoin3.satoshisToBitcoins)(rawTx.amount); } const normalizedTx = normalizeTransactionData(rawTx, "public" /* PUBLIC */); normalizedTx.isReceived = isReceived; return normalizedTx; }); } catch (mapError) { console.error("Error in transaction mapping:", mapError); console.error("sortedTransactions:", sortedTransactions); throw mapError; } } catch (error) { throw new Error( `Failed to get address transaction history: ${error.message}` ); } } /** * Retrieves transaction history for a Bitcoin Core wallet (private client only) * * This method returns only "send" (spent) transactions from the wallet. This design * decision was made after extensive discussion about Bitcoin Core's limitations: * * 1. Bitcoin Core doesn't maintain an address index for performance/privacy reasons * 2. The `listtransactions` RPC returns ALL wallet transactions with no address filtering * 3. Filtering by address after fetching would be O(n*m) complexity for multiple addresses * * Why this method is PRIVATE CLIENT ONLY: * - Public APIs (Mempool/Blockstream) support direct address querying * - Bitcoin Core requires different approach due to lack of address indexing * - This inconsistency is intentional to optimize for each client type's capabilities * * Why only "send" transactions: * - Unspent "receive" transactions are already available via fetchAddressUtxos() * - Avoids duplication of data that's accessible through UTXO methods * - Focuses on spent transactions which are needed for transaction history * - Coordinator can combine this with UTXO data for complete history * * For PUBLIC clients: Use getAddressTransactionHistory() which supports address filtering * * @param count - Number of transactions to return (1-1000) * @param skip - Number of transactions to skip for pagination * @param includeWatchOnly - Include watch-only addresses in results * @returns Array of spent transactions from the wallet * @throws Error if called on public client or if wallet name is missing * * @example * // For private client - get last 100 spent transactions * const spentTxs = await client.getWalletTransactionHistory(100); * * // For public client - use getAddressTransactionHistory instead * const addressTxs = await client.getAddressTransactionHistory(address); * * @see getAddressTransactionHistory - For public clients * @see fetchAddressUtxos - For unspent transactions */ async getWalletTransactionHistory(count = 100, skip = 0, includeWatchOnly = true) { if (count < 1 || count > 1e5) { throw new Error("Count must be between 1 and 100000"); } if (skip < 0) { throw new Error("Skip must be non-negative"); } if (this.type !== "private" /* PRIVATE */) { throw new Error( "This method is only supported for private clients. Use getAddressTransactionHistory for public clients" ); } const response = await callBitcoindWallet({ baseUrl: this.bitcoindParams.url, walletName: this.bitcoindParams.walletName, auth: this.bitcoindParams.auth, method: "listtransactions", params: ["*", count, skip, includeWatchOnly] }); if (!response?.result || !Array.isArray(response.result)) { throw new Error("Failed to retrieve transactions from Bitcoin Core"); } const allTransactions = response.result; const detailedTransactions = await Promise.allSettled( allTransactions.map(async (tx) => { try { const fullTx = await callBitcoindWallet({ baseUrl: this.bitcoindParams.url, walletName: this.bitcoindParams.walletName, auth: this.bitcoindParams.auth, method: "gettransaction", params: [tx.txid, false, true] // [txid, include_watchonly, verbose] }); const decoded = fullTx.result?.decoded; const size = decoded?.size ?? 0; const vsize = decoded?.vsize ?? decoded?.size ?? 0; const weight = decoded?.weight ?? (decoded?.size ? decoded.size * 4 : 0); return { ...tx, size, vsize, weight, wtxid: fullTx.result?.wtxid || tx.txid // Get wtxid from gettransaction response }; } catch (error) { console.warn(`Failed to fetch details for transaction ${tx.txid}:`, error); return { ...tx, size: 0, vsize: 0, weight: 0, wtxid: tx.txid // Fallback to txid since ListTransactionsItem doesn't have wtxid }; } }) ); return detailedTransactions.map((result, index) => { const enhancedTx = result.status === "fulfilled" ? result.value : { ...allTransactions[index], size: 0, vsize: 0, weight: 0, wtxid: allTransactions[index].txid // Use txid since ListTransactionsItem doesn't have wtxid }; const feeSats = enhancedTx.fee ? Math.abs(enhancedTx.fee * 1e8) : 0; const isReceived = enhancedTx.category === "receive" || enhancedTx.category === "generate" || enhancedTx.category === "immature"; const transformedTx = { txid: enhancedTx.txid, version: 1, locktime: 0, vin: [], vout: [], size: enhancedTx.size || 0, vsize: enhancedTx.vsize || 0, weight: enhancedTx.weight || 0, fee: feeSats, isReceived, status: { confirmed: enhancedTx.confirmations > 0, blockHeight: enhancedTx.blockheight, blockHash: enhancedTx.blockhash, blockTime: enhancedTx.blocktime }, // Required wallet-specific properties amount: enhancedTx.amount, confirmations: enhancedTx.confirmations, category: enhancedTx.category, address: enhancedTx.address, abandoned: enhancedTx.abandoned, time: enhancedTx.time }; return transformedTx; }); } /** * Gets detailed information about a wallet transaction including fee information * * This method is specifically for transactions that are tracked by the wallet, * and provides fee information that isn't available in the general getTransaction * method. This is especially useful for private nodes where fee information is * critical for UI display. * * @see https://developer.bitcoin.org/reference/rpc/gettransaction.html * * @param txid - Transaction ID to retrieve * @returns Normalized transaction details with fee information */ async getWalletTransaction(txid) { if (this.type !== "private" /* PRIVATE */) { throw new BlockchainClientError( "Wallet transactions are only available for private Bitcoin nodes" ); } try { const walletTxData = await bitcoindGetWalletTransaction({ url: this.bitcoindParams.url, auth: this.bitcoindParams.auth, walletName: this.bitcoindParams.walletName, txid }); const normalizedTxData = transformWalletTransactionToRawTransactionData(walletTxData); return normalizeTransactionData(normalizedTxData, this.type); } catch (error) { throw new Error(`Failed to get wallet transaction: ${error.message}`); } } async getTransaction(txid, forceRawTx = false) { try { let txData; switch (this.type) { case "private" /* PRIVATE */: if (!forceRawTx && this.bitcoindParams.walletName) { try { return await this.getWalletTransaction(txid); } catch (walletError) { console.warn( `Wallet transaction lookup failed, falling back to raw transaction: ${walletError.message}` ); } } txData = await bitcoindRawTxData({ url: this.bitcoindParams.url, auth: this.bitcoindParams.auth, txid }); break; case "public" /* PUBLIC */: if (this.provider === "blockstream" /* BLOCKSTREAM */ || this.provider === "mempool" /* MEMPOOL */) { txData = await this.Get(`/tx/${txid}`); } else { throw new Error("Invalid provider for public client."); } break; // Cases for deprecated direct mempool/blockstream types if they existed in ClientType before // and if the current enum still has them for some reason (e.g. backward compatibility layer) // However, the goal of the PR was to consolidate these under ClientType.PUBLIC and a provider. // So, ideally, direct cases for MEMPOOL/BLOCKSTREAM in ClientType shouldn't be needed here // if the constructor correctly maps them to ClientType.PUBLIC and sets the provider. default: throw new Error(`Invalid client type: ${this.type}`); } return normalizeTransactionData(txData, this.type); } catch (error) { throw new Error(`Failed to get transaction: ${error.message}`); } } async importDescriptors({ receive, change, rescan }) { if (this.type !== "private" /* PRIVATE */) { throw new BlockchainClientError( "Only private clients support descriptor importing" ); } return await bitcoindImportDescriptors({ receive, change, rescan, ...this.bitcoindParams }); } async getWalletInfo() { if (this.type !== "private" /* PRIVATE */) { throw new BlockchainClientError( "Only private clients support wallet info" ); } return await bitcoindWalletInfo({ ...this.bitcoindParams }); } /** * Retrieves the fee information for a pending (incoming) transaction . * * Standard methods like `getTransaction` do not provide fee details for transactions * where the user is the recipient. However, this information is required for * fee bumping strategies like CPFP (Child Pays For Parent). * * This method : * - For private nodes: Uses `getmempoolentry` to fetch fee data from the node's mempool * - For public APIs: Uses mempool.space transaction endpoint to get fee information * - Returns null if the transaction is not pending (not in mempool) * * @see https://developer.bitcoin.org/reference/rpc/getmempoolentry.html?highlight=getmempoolentry * @see https://mempool.space/docs/api/rest#get-transaction * * @param txid - Transaction ID to get fees * @returns Tx fees in satoshis, or null if transaction is not pending */ async getFeesForPendingTransaction(txid) { try { if (this.type === "private" /* PRIVATE */) { const rpcResponse = await callBitcoindWallet({ baseUrl: this.bitcoindParams.url, walletName: this.bitcoindParams.walletName, auth: this.bitcoindParams.auth, method: "getmempoolentry", params: [txid] }); const mempoolEntry = rpcResponse.result; if (!mempoolEntry || !mempoolEntry.fees) { return null; } return (0, import_bitcoin3.bitcoinsToSatoshis)(mempoolEntry.fees.base.toString()); } const txData = await this.Get(`/tx/${txid}`); if (txData && txData.fee && !txData.status?.confirmed) { return txData.fee.toString(); } return null; } catch (error) { throw new Error(`Failed to get wallet transaction: ${error.message}`); } } }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { BlockchainClient, ClientType, PublicBitcoinProvider, bitcoindImportDescriptors });