@arkade-os/sdk
Version:
Bitcoin wallet SDK with Taproot and Ark integration
202 lines (201 loc) • 7.53 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.OnchainWallet = void 0;
exports.selectCoins = selectCoins;
const payment_1 = require("@scure/btc-signer/payment");
const networks_1 = require("../networks");
const onchain_1 = require("../providers/onchain");
const btc_signer_1 = require("@scure/btc-signer");
const anchor_1 = require("../utils/anchor");
const txSizeEstimator_1 = require("../utils/txSizeEstimator");
/**
* Onchain Bitcoin wallet implementation for traditional Bitcoin transactions.
*
* This wallet handles regular Bitcoin transactions on the blockchain without
* using the Ark protocol. It supports P2TR (Pay-to-Taproot) addresses and
* provides basic Bitcoin wallet functionality.
*
* @example
* ```typescript
* const wallet = new OnchainWallet(identity, 'mainnet');
* const balance = await wallet.getBalance();
* const txid = await wallet.send({
* address: 'bc1...',
* amount: 50000
* });
* ```
*/
class OnchainWallet {
constructor(identity, network, provider) {
this.identity = identity;
const pubkey = identity.xOnlyPublicKey();
if (!pubkey) {
throw new Error("Invalid configured public key");
}
this.provider = provider || new onchain_1.EsploraProvider(onchain_1.ESPLORA_URL[network]);
this.network = (0, networks_1.getNetwork)(network);
this.onchainP2TR = (0, payment_1.p2tr)(pubkey, undefined, this.network);
}
get address() {
return this.onchainP2TR.address || "";
}
async getCoins() {
return this.provider.getCoins(this.address);
}
async getBalance() {
const coins = await this.getCoins();
const onchainConfirmed = coins
.filter((coin) => coin.status.confirmed)
.reduce((sum, coin) => sum + coin.value, 0);
const onchainUnconfirmed = coins
.filter((coin) => !coin.status.confirmed)
.reduce((sum, coin) => sum + coin.value, 0);
const onchainTotal = onchainConfirmed + onchainUnconfirmed;
return onchainTotal;
}
async send(params) {
if (params.amount <= 0) {
throw new Error("Amount must be positive");
}
if (params.amount < OnchainWallet.DUST_AMOUNT) {
throw new Error("Amount is below dust limit");
}
const coins = await this.getCoins();
let feeRate = params.feeRate;
if (!feeRate) {
feeRate = await this.provider.getFeeRate();
}
if (!feeRate || feeRate < OnchainWallet.MIN_FEE_RATE) {
feeRate = OnchainWallet.MIN_FEE_RATE;
}
// Ensure fee is an integer by rounding up
const estimatedFee = Math.ceil(174 * feeRate);
const totalNeeded = params.amount + estimatedFee;
// Select coins
const selected = selectCoins(coins, totalNeeded);
// Create transaction
let tx = new btc_signer_1.Transaction();
// Add inputs
for (const input of selected.inputs) {
tx.addInput({
txid: input.txid,
index: input.vout,
witnessUtxo: {
script: this.onchainP2TR.script,
amount: BigInt(input.value),
},
tapInternalKey: this.onchainP2TR.tapInternalKey,
});
}
// Add payment output
tx.addOutputAddress(params.address, BigInt(params.amount), this.network);
// Add change output if needed
if (selected.changeAmount > 0n) {
tx.addOutputAddress(this.address, selected.changeAmount, this.network);
}
// Sign inputs and Finalize
tx = await this.identity.sign(tx);
tx.finalize();
// Broadcast
const txid = await this.provider.broadcastTransaction(tx.hex);
return txid;
}
async bumpP2A(parent) {
const parentVsize = parent.vsize;
let child = new btc_signer_1.Transaction({
allowUnknownInputs: true,
allowLegacyWitnessUtxo: true,
version: 3,
});
child.addInput((0, anchor_1.findP2AOutput)(parent)); // throws if not found
const childVsize = txSizeEstimator_1.TxWeightEstimator.create()
.addKeySpendInput(true)
.addP2AInput()
.addP2TROutput()
.vsize().value;
const packageVSize = parentVsize + Number(childVsize);
let feeRate = await this.provider.getFeeRate();
if (!feeRate || feeRate < OnchainWallet.MIN_FEE_RATE) {
feeRate = OnchainWallet.MIN_FEE_RATE;
}
const fee = Math.ceil(feeRate * packageVSize);
if (!fee) {
throw new Error(`invalid fee, got ${fee} with vsize ${packageVSize}, feeRate ${feeRate}`);
}
// Select coins
const coins = await this.getCoins();
const selected = selectCoins(coins, fee, true);
for (const input of selected.inputs) {
child.addInput({
txid: input.txid,
index: input.vout,
witnessUtxo: {
script: this.onchainP2TR.script,
amount: BigInt(input.value),
},
tapInternalKey: this.onchainP2TR.tapInternalKey,
});
}
child.addOutputAddress(this.address, anchor_1.P2A.amount + selected.changeAmount, this.network);
// Sign inputs and Finalize
child = await this.identity.sign(child);
for (let i = 1; i < child.inputsLength; i++) {
child.finalizeIdx(i);
}
try {
await this.provider.broadcastTransaction(parent.hex, child.hex);
}
catch (error) {
console.error(error);
}
finally {
return [parent.hex, child.hex];
}
}
}
exports.OnchainWallet = OnchainWallet;
OnchainWallet.MIN_FEE_RATE = 1; // sat/vbyte
OnchainWallet.DUST_AMOUNT = 546; // sats
/**
* Select coins to reach a target amount, prioritizing those closer to expiry
* @param coins List of coins to select from
* @param targetAmount Target amount to reach in satoshis
* @param forceChange If true, ensure the coin selection will require a change output
* @returns Selected coins and change amount, or null if insufficient funds
*/
function selectCoins(coins, targetAmount, forceChange = false) {
if (isNaN(targetAmount)) {
throw new Error("Target amount is NaN, got " + targetAmount);
}
if (targetAmount < 0) {
throw new Error("Target amount is negative, got " + targetAmount);
}
if (targetAmount === 0) {
return { inputs: [], changeAmount: 0n };
}
// Sort coins by amount (descending)
const sortedCoins = [...coins].sort((a, b) => b.value - a.value);
const selectedCoins = [];
let selectedAmount = 0;
// Select coins until we have enough
for (const coin of sortedCoins) {
selectedCoins.push(coin);
selectedAmount += coin.value;
if (forceChange
? selectedAmount > targetAmount
: selectedAmount >= targetAmount) {
break;
}
}
if (selectedAmount === targetAmount) {
return { inputs: selectedCoins, changeAmount: 0n };
}
if (selectedAmount < targetAmount) {
throw new Error("Insufficient funds");
}
const changeAmount = BigInt(selectedAmount - targetAmount);
return {
inputs: selectedCoins,
changeAmount,
};
}