chaingate
Version:
Multi-chain cryptocurrency SDK for TypeScript — unified API for Bitcoin, Ethereum, Litecoin, Dogecoin, Bitcoin Cash, Polygon, Arbitrum, and any EVM-compatible chain. Create wallets, query balances, send transactions, and manage tokens and NFTs across UTXO
378 lines (377 loc) • 15.4 kB
JavaScript
"use strict";
/**
* Abstract base class for UTXO-family transactions (BTC/LTC/DOGE/BCH).
*
* Contains all shared logic: fee estimation, UTXO gathering/selection,
* sign-and-broadcast flow, and local UTXO cache management.
*
* Subclasses only need to implement {@link signTransaction} (network-specific
* signing) and a static `create()` factory.
*
* @internal
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.BaseUtxoTransaction = void 0;
exports.buildRecommendedFees = buildRecommendedFees;
exports.estimateMissingFunds = estimateMissingFunds;
const decimal_js_1 = __importDefault(require("decimal.js"));
const btc = __importStar(require("@scure/btc-signer"));
const btc_signer_1 = require("@scure/btc-signer");
const errors_1 = require("../../errors");
const BroadcastedUtxoTransaction_1 = require("./BroadcastedUtxoTransaction");
const utils_1 = require("../../utils");
// ---------------------------------------------------------------------------
// Abstract base class
// ---------------------------------------------------------------------------
class BaseUtxoTransaction {
/** @internal */
constructor(params) {
this.sent = false;
this.explorer = params.explorer;
this.fromAddress = params.fromAddress;
this.toAddress = params.toAddress;
this.valueSat = params.valueSat;
this.networkParams = params.networkParams;
this.feeRates = params.feeRates;
this.getPrivateKey = params.getPrivateKey;
this.state = params.state;
this.currentFeePerKbSat = params.feeRates.normal.feePerKbSat;
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/**
* Returns all recommended fee tiers (low, normal, high, maximum).
*/
recommendedFees() {
return this.feeRates;
}
/**
* Returns whether the wallet has enough funds to cover the transfer plus fee
* at the current fee rate.
*/
enoughFunds() {
return !!this.selectUtxos(this.currentFeePerKbSat);
}
/**
* Returns the estimated virtual size (vbytes) of this transaction at the
* current fee rate, or `null` if there are not enough funds.
*/
estimatedSizeBytes() {
const selected = this.selectUtxos(this.currentFeePerKbSat);
if (!selected)
return null;
return Math.ceil(selected.weight / 4);
}
/**
* Sets the fee for this transaction.
*
* Pass a tier object from {@link recommendedFees} or an object with a
* custom `feePerKbSat` value in satoshis per kilobyte.
*
* @throws {@link TransactionAlreadySentError} if the transaction has already been sent.
*/
setFee(fee) {
if (this.sent) {
throw new errors_1.TransactionAlreadySentError();
}
this.currentFeePerKbSat = fee.feePerKbSat;
}
/**
* Signs the transaction with the wallet's private key and broadcasts it to the network.
*
* @throws {@link TransactionAlreadySentError} if the transaction has already been sent.
* @throws {@link NotEnoughFundsError} if the wallet does not have enough funds.
*/
async signAndBroadcast() {
if (this.sent) {
throw new errors_1.TransactionAlreadySentError();
}
if (!this.enoughFunds()) {
throw new errors_1.NotEnoughFundsError();
}
// Gather UTXOs and select inputs/outputs.
await this.findUtxos(this.currentFeePerKbSat);
const selected = this.selectUtxos(this.currentFeePerKbSat);
if (!selected) {
throw new errors_1.NotEnoughFundsError();
}
// Build and sign the raw transaction.
const privateKey = await this.getPrivateKey();
const rawTx = this.signTransaction(selected.inputs, selected.outputs, privateKey);
privateKey.fill(0);
// Broadcast.
const { txId } = await this.explorer.broadcastTransaction((0, utils_1.bytesToHex)(rawTx));
this.sent = true;
// Update local UTXO cache: mark spent inputs, add change outputs.
const cache = this.explorer.global.utxoCache;
for (const input of selected.inputs) {
cache.markSpent(input.txid, input.n, txId);
}
const fromScript = btc_signer_1.OutScript.encode(btc.Address(this.networkParams).decode(this.fromAddress));
for (let i = 0; i < selected.outputs.length; i++) {
if (selected.outputs[i].address === this.fromAddress) {
cache.addUnspent(this.fromAddress, {
txid: txId,
n: i,
amount: this.explorer.amountFromSat(selected.outputs[i].amount),
script: fromScript,
});
}
}
return new BroadcastedUtxoTransaction_1.BroadcastedUtxoTransaction(txId, this.explorer);
}
// -------------------------------------------------------------------------
// UTXO gathering
// -------------------------------------------------------------------------
/** Fetches UTXOs page by page until we have enough to cover the tx, or all are fetched. */
async findUtxos(feePerKbSat) {
if (this.selectUtxos(feePerKbSat))
return;
while (!this.state.crawled) {
const result = await this.explorer.getUtxosByAddress(this.fromAddress, this.state.page.toString());
if (result.utxos.length === 0) {
this.state.crawled = true;
break;
}
const cache = this.explorer.global.utxoCache;
for (const utxo of result.utxos) {
// Skip duplicates.
if (this.state.utxos.some((u) => u.txid === utxo.txid && u.n === utxo.n))
continue;
// Skip UTXOs that have been spent by a local broadcast.
if (cache.isSpent(utxo.txid, utxo.n))
continue;
this.state.utxos.push({
txid: utxo.txid,
amount: utxo.amount,
n: utxo.n,
script: (0, utils_1.hexToBytes)(utxo.script),
});
}
if (this.selectUtxos(feePerKbSat))
return;
this.state.page++;
}
}
// -------------------------------------------------------------------------
// UTXO selection via @scure/btc-signer
// -------------------------------------------------------------------------
/** Attempts to select UTXOs and compute outputs. Returns null if insufficient. */
selectUtxos(feePerKbSat) {
if (this.state.utxos.length === 0)
return null;
const vins = this.state.utxos.map((utxo) => ({
txid: (0, utils_1.hexToBytes)(utxo.txid),
index: utxo.n,
witnessUtxo: {
script: utxo.script,
amount: utxo.amount.min(),
},
}));
const vouts = [{ address: this.toAddress, amount: this.valueSat }];
const feePerByte = feePerKbSat / 1000n || 1n;
const selected = btc.selectUTXO(vins, vouts, 'default', {
changeAddress: this.fromAddress,
feePerByte,
bip69: true,
createTx: true,
allowLegacyWitnessUtxo: true,
network: this.networkParams,
});
if (!selected)
return null;
const inputs = selected.inputs.map((input) => ({
txid: (0, utils_1.bytesToHex)(input.txid),
amount: this.explorer.amountFromSat(input.witnessUtxo.amount),
n: input.index ?? 0,
script: input.witnessUtxo.script,
}));
const outputs = selected.outputs.map((output) => {
if (!('address' in output)) {
// change output — should not happen with createTx but handle gracefully
return { address: this.fromAddress, amount: output.amount };
}
return { address: output.address, amount: output.amount };
});
return { inputs, outputs, fee: selected.fee ?? 0n, weight: selected.weight };
}
}
exports.BaseUtxoTransaction = BaseUtxoTransaction;
// ---------------------------------------------------------------------------
// Fee tier computation (runs once during create)
// ---------------------------------------------------------------------------
/**
* Builds recommended fee tiers by fetching UTXOs and running selection for each tier.
* Shared by all UTXO-family transaction `create()` factories.
* @internal
*/
async function buildRecommendedFees(apiResponse, explorer, fromAddress, toAddress, valueSat, networkParams, state) {
const tiers = ['low', 'normal', 'high', 'maximum'];
const results = {};
for (const tier of tiers) {
const entry = apiResponse[tier];
const feePerKbSat = BigInt(new decimal_js_1.default(entry.feePerKb).toFixed(0));
// Try to gather UTXOs and select to compute the actual fee.
const tempTx = new TempUtxoSelector(explorer, fromAddress, toAddress, valueSat, networkParams, state);
await tempTx.findUtxos(feePerKbSat);
const selected = tempTx.selectUtxos(feePerKbSat);
const result = {
feePerKbSat,
estimatedFeeSat: selected ? selected.fee : null,
estimatedConfirmationSecs: entry.confirmationTimeSecs,
enoughFunds: !!selected,
};
if (!selected) {
result.missingFunds = estimateMissingFunds({
utxos: state.utxos,
fromAddress,
toAddress,
valueSat,
feePerKbSat,
networkParams,
});
}
results[tier] = result;
}
return results;
}
/**
* Estimates the satoshis missing for a transaction to succeed at a given fee
* rate by re-running selection with a synthetic high-value input that uses the
* same script type as the sender's address. Returns `null` when the gap cannot
* be estimated.
* @internal
*/
function estimateMissingFunds(params) {
const { utxos, fromAddress, toAddress, valueSat, feePerKbSat, networkParams } = params;
const totalAvailable = utxos.reduce((sum, u) => sum + u.amount.min(), 0n);
const fromScript = btc_signer_1.OutScript.encode(btc.Address(networkParams).decode(fromAddress));
const dummyAmount = valueSat + 100000000000n;
const vins = [
...utxos.map((utxo) => ({
txid: (0, utils_1.hexToBytes)(utxo.txid),
index: utxo.n,
witnessUtxo: { script: utxo.script, amount: utxo.amount.min() },
})),
{
txid: new Uint8Array(32),
index: 0,
witnessUtxo: { script: fromScript, amount: dummyAmount },
},
];
const vouts = [{ address: toAddress, amount: valueSat }];
const feePerByte = feePerKbSat / 1000n || 1n;
const selected = btc.selectUTXO(vins, vouts, 'all', {
changeAddress: fromAddress,
feePerByte,
bip69: true,
createTx: false,
allowLegacyWitnessUtxo: true,
network: networkParams,
});
if (!selected)
return null;
const totalNeeded = valueSat + (selected.fee ?? 0n);
return totalNeeded > totalAvailable ? totalNeeded - totalAvailable : null;
}
/**
* Lightweight helper used only during fee estimation.
* Shares the UtxoApiState with the main transaction so UTXOs fetched
* during fee estimation are reused when signing.
*/
class TempUtxoSelector {
constructor(explorer, fromAddress, toAddress, valueSat, networkParams, state) {
this.explorer = explorer;
this.fromAddress = fromAddress;
this.toAddress = toAddress;
this.valueSat = valueSat;
this.networkParams = networkParams;
this.state = state;
}
async findUtxos(feePerKbSat) {
if (this.selectUtxos(feePerKbSat))
return;
while (!this.state.crawled) {
const result = await this.explorer.getUtxosByAddress(this.fromAddress, this.state.page.toString());
if (result.utxos.length === 0) {
this.state.crawled = true;
break;
}
const cache = this.explorer.global.utxoCache;
for (const utxo of result.utxos) {
if (this.state.utxos.some((u) => u.txid === utxo.txid && u.n === utxo.n))
continue;
if (cache.isSpent(utxo.txid, utxo.n))
continue;
this.state.utxos.push({
txid: utxo.txid,
amount: utxo.amount,
n: utxo.n,
script: (0, utils_1.hexToBytes)(utxo.script),
});
}
if (this.selectUtxos(feePerKbSat))
return;
this.state.page++;
}
}
selectUtxos(feePerKbSat) {
if (this.state.utxos.length === 0)
return null;
const vins = this.state.utxos.map((utxo) => ({
txid: (0, utils_1.hexToBytes)(utxo.txid),
index: utxo.n,
witnessUtxo: { script: utxo.script, amount: utxo.amount.min() },
}));
const vouts = [{ address: this.toAddress, amount: this.valueSat }];
const feePerByte = feePerKbSat / 1000n || 1n;
const selected = btc.selectUTXO(vins, vouts, 'default', {
changeAddress: this.fromAddress,
feePerByte,
bip69: true,
createTx: true,
allowLegacyWitnessUtxo: true,
network: this.networkParams,
});
if (!selected)
return null;
return { fee: selected.fee ?? 0n };
}
}