@caravan/clients
Version:
A package for querying different bitcoin blockchain backends
800 lines (796 loc) • 22.5 kB
JavaScript
// src/wallet.ts
import { bitcoinsToSatoshis as bitcoinsToSatoshis2 } from "@caravan/bitcoin";
// src/bitcoind.ts
import axios from "axios";
import BigNumber from "bignumber.js";
import { bitcoinsToSatoshis } from "@caravan/bitcoin";
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 axios(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
import BigNumber2 from "bignumber.js";
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)
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 BigNumber2(utxo.amount);
return {
confirmed: (utxo.confirmations || 0) > 0,
txid: utxo.txid,
index: utxo.vout,
amount: amount.toFixed(8),
amountSats: bitcoinsToSatoshis2(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
import axios2 from "axios";
import { Network, satoshisToBitcoins, sortInputs } from "@caravan/bitcoin";
import BigNumber3 from "bignumber.js";
var BlockchainClientError = class extends Error {
constructor(message) {
super(message);
this.name = "BlockchainClientError";
}
};
var ClientType = /* @__PURE__ */ ((ClientType2) => {
ClientType2["PRIVATE"] = "private";
ClientType2["BLOCKSTREAM"] = "blockstream";
ClientType2["MEMPOOL"] = "mempool";
return ClientType2;
})(ClientType || {});
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 {
txid: walletTx.txid,
version: walletTx.decoded.version,
locktime: walletTx.decoded.locktime,
size: walletTx.decoded.size,
vsize: walletTx.decoded.vsize,
weight: walletTx.decoded.weight,
category,
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 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 : satoshisToBitcoins(output.value),
scriptPubkey: output.scriptpubkey,
scriptPubkeyAddress: output.scriptpubkey_address
})),
size: txData.size,
// add the vsize property to the returned object if txData.vsize is defined
...txData.vsize !== void 0 && { vsize: txData.vsize },
// add the category property to the returned object if txData.category is defined ( For Private clients)
...isReceived !== void 0 && { isReceived },
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 axios2.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;
network;
bitcoindParams;
constructor({
type,
network,
throttled = false,
client = {
url: "",
username: "",
password: "",
walletName: ""
}
}) {
if (type !== "private" /* PRIVATE */ && network !== Network.MAINNET && network !== Network.TESTNET && network !== Network.SIGNET) {
throw new Error("Invalid network");
}
if (type !== "mempool" /* MEMPOOL */ && network === Network.SIGNET) {
throw new Error("Invalid network");
}
let host = "";
if (type === "blockstream" /* BLOCKSTREAM */) {
host = "https://blockstream.info";
} else if (type === "mempool" /* MEMPOOL */) {
host = "https://unchained.mempool.space";
}
if (type !== "private" /* PRIVATE */ && network !== Network.MAINNET) {
host += `/${network}`;
}
if (type !== "private" /* PRIVATE */) {
host += "/api";
}
super(throttled, host);
this.network = network;
this.type = type;
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 BigNumber3(utxo.value);
return {
confirmed: utxo.status.confirmed,
txid: utxo.txid,
index: utxo.vout,
amount: satoshisToBitcoins(utxo.value),
amountSats: amount,
transactionHex,
time: utxo.status.block_time
};
}
async fetchAddressUtxos(address) {
let unsortedUTXOs;
let updates = {
utxos: [],
balanceSats: BigNumber3(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" && isWalletAddressNotFoundError(error)) {
updates = {
utxos: [],
balanceSats: BigNumber3(0),
addressKnown: false,
fetchedUTXOs: true,
fetchUTXOsError: ""
};
} else {
updates = { ...updates, fetchUTXOsError: error.toString() };
}
}
if (!unsortedUTXOs)
return updates;
const utxos = sortInputs(unsortedUTXOs);
const balanceSats = utxos.map((utxo) => utxo.amountSats).reduce(
(accumulator, currentValue) => accumulator.plus(currentValue),
new BigNumber3(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 "blockstream" /* BLOCKSTREAM */:
fees = await this.Get(`/fee-estimates`);
return fees[blocks];
case "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;
}
default:
throw new Error("Invalid client type");
}
} catch (error) {
throw new Error(`Failed to get fee estimate: ${error.message}`);
}
}
async getBlockFeeRatePercentileHistory() {
try {
if (this.type === "private" /* PRIVATE */ || this.type === "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 */) {
return await callBitcoind(
this.bitcoindParams.url,
this.bitcoindParams.auth,
"gettransaction",
[txid]
);
}
return await this.Get(`/tx/${txid}/hex`);
} catch (error) {
throw new Error(`Failed to get transaction: ${error.message}`);
}
}
/**
* 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"
);
}
if (!this.bitcoindParams.walletName) {
throw new BlockchainClientError(
"Wallet name is required for wallet transaction lookups"
);
}
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;
if (this.type === "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}`
);
}
}
const response = await bitcoindRawTxData({
url: this.bitcoindParams.url,
auth: this.bitcoindParams.auth,
txid
});
txData = response;
} else if (this.type === "blockstream" /* BLOCKSTREAM */ || this.type === "mempool" /* MEMPOOL */) {
txData = await this.Get(`/tx/${txid}`);
} else {
throw new Error("Invalid client 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 });
}
};
export {
BlockchainClient,
ClientType,
bitcoindImportDescriptors
};