@arklabs/wallet-sdk
Version:
Bitcoin wallet SDK with Taproot and Ark integration
726 lines (725 loc) • 31.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Wallet = void 0;
const base_1 = require("@scure/base");
const payment_1 = require("@scure/btc-signer/payment");
const psbt_1 = require("@scure/btc-signer/psbt");
const transactionHistory_1 = require("../utils/transactionHistory");
const bip21_1 = require("../utils/bip21");
const address_1 = require("../script/address");
const default_1 = require("../script/default");
const coinselect_1 = require("../utils/coinselect");
const networks_1 = require("../networks");
const onchain_1 = require("../providers/onchain");
const ark_1 = require("../providers/ark");
const forfeit_1 = require("../forfeit");
const txSizeEstimator_1 = require("../utils/txSizeEstimator");
const validation_1 = require("../tree/validation");
const _1 = require(".");
const base_2 = require("../script/base");
const tapscript_1 = require("../script/tapscript");
const psbt_2 = require("../utils/psbt");
const btc_signer_1 = require("@scure/btc-signer");
const arknote_1 = require("../arknote");
// Wallet does not store any data and rely on the Ark and onchain providers to fetch utxos and vtxos
class Wallet {
constructor(identity, network, onchainProvider, onchainP2TR, arkProvider, arkServerPublicKey, offchainTapscript, boardingTapscript) {
this.identity = identity;
this.network = network;
this.onchainProvider = onchainProvider;
this.onchainP2TR = onchainP2TR;
this.arkProvider = arkProvider;
this.arkServerPublicKey = arkServerPublicKey;
this.offchainTapscript = offchainTapscript;
this.boardingTapscript = boardingTapscript;
}
static async create(config) {
const network = (0, networks_1.getNetwork)(config.network);
const onchainProvider = new onchain_1.EsploraProvider(config.esploraUrl || onchain_1.ESPLORA_URL[config.network]);
// Derive onchain address
const pubkey = config.identity.xOnlyPublicKey();
if (!pubkey) {
throw new Error("Invalid configured public key");
}
let arkProvider;
if (config.arkServerUrl) {
arkProvider = new ark_1.RestArkProvider(config.arkServerUrl);
}
// Save onchain Taproot address key-path only
const onchainP2TR = (0, payment_1.p2tr)(pubkey, undefined, network);
if (arkProvider) {
let serverPubKeyHex = config.arkServerPublicKey;
let exitTimelock = config.exitTimelock;
let boardingTimelock = config.boardingTimelock;
if (!serverPubKeyHex || !exitTimelock) {
const info = await arkProvider.getInfo();
serverPubKeyHex = info.pubkey;
exitTimelock = {
value: info.unilateralExitDelay,
type: info.unilateralExitDelay < 512n ? "blocks" : "seconds",
};
boardingTimelock = {
value: info.unilateralExitDelay * 2n,
type: info.unilateralExitDelay * 2n < 512n
? "blocks"
: "seconds",
};
}
// Generate tapscripts for offchain and boarding address
const serverPubKey = base_1.hex.decode(serverPubKeyHex).slice(1);
const bareVtxoTapscript = new default_1.DefaultVtxo.Script({
pubKey: pubkey,
serverPubKey,
csvTimelock: exitTimelock,
});
const boardingTapscript = new default_1.DefaultVtxo.Script({
pubKey: pubkey,
serverPubKey,
csvTimelock: boardingTimelock,
});
// Save tapscripts
const offchainTapscript = bareVtxoTapscript;
return new Wallet(config.identity, network, onchainProvider, onchainP2TR, arkProvider, serverPubKey, offchainTapscript, boardingTapscript);
}
return new Wallet(config.identity, network, onchainProvider, onchainP2TR);
}
get onchainAddress() {
return this.onchainP2TR.address || "";
}
get boardingAddress() {
if (!this.boardingTapscript || !this.arkServerPublicKey) {
throw new Error("Boarding address not configured");
}
return this.boardingTapscript.address(this.network.hrp, this.arkServerPublicKey);
}
get boardingOnchainAddress() {
if (!this.boardingTapscript) {
throw new Error("Boarding address not configured");
}
return this.boardingTapscript.onchainAddress(this.network);
}
get offchainAddress() {
if (!this.offchainTapscript || !this.arkServerPublicKey) {
throw new Error("Offchain address not configured");
}
return this.offchainTapscript.address(this.network.hrp, this.arkServerPublicKey);
}
getAddress() {
const addressInfo = {
onchain: this.onchainAddress,
bip21: bip21_1.BIP21.create({
address: this.onchainAddress,
}),
};
// Only include Ark-related fields if Ark provider is configured and address is available
if (this.arkProvider &&
this.offchainTapscript &&
this.boardingTapscript &&
this.arkServerPublicKey) {
const offchainAddress = this.offchainAddress.encode();
addressInfo.offchain = offchainAddress;
addressInfo.bip21 = bip21_1.BIP21.create({
address: this.onchainP2TR.address,
ark: offchainAddress,
});
addressInfo.boarding = this.boardingOnchainAddress;
}
return Promise.resolve(addressInfo);
}
getAddressInfo() {
if (!this.arkProvider ||
!this.offchainTapscript ||
!this.boardingTapscript ||
!this.arkServerPublicKey) {
throw new Error("Ark provider not configured");
}
const offchainAddress = this.offchainAddress.encode();
const boardingAddress = this.boardingOnchainAddress;
return Promise.resolve({
offchain: {
address: offchainAddress,
scripts: {
exit: [this.offchainTapscript.exitScript],
forfeit: [this.offchainTapscript.forfeitScript],
},
},
boarding: {
address: boardingAddress,
scripts: {
exit: [this.boardingTapscript.exitScript],
forfeit: [this.boardingTapscript.forfeitScript],
},
},
});
}
async getBalance() {
// Get onchain coins
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;
// Get offchain coins if Ark provider is configured
let offchainSettled = 0;
let offchainPending = 0;
let offchainSwept = 0;
if (this.arkProvider) {
const vtxos = await this.getVirtualCoins();
offchainSettled = vtxos
.filter((coin) => coin.virtualStatus.state === "settled")
.reduce((sum, coin) => sum + coin.value, 0);
offchainPending = vtxos
.filter((coin) => coin.virtualStatus.state === "pending")
.reduce((sum, coin) => sum + coin.value, 0);
offchainSwept = vtxos
.filter((coin) => coin.virtualStatus.state === "swept")
.reduce((sum, coin) => sum + coin.value, 0);
}
const offchainTotal = offchainSettled + offchainPending;
return {
onchain: {
confirmed: onchainConfirmed,
unconfirmed: onchainUnconfirmed,
total: onchainTotal,
},
offchain: {
swept: offchainSwept,
settled: offchainSettled,
pending: offchainPending,
total: offchainTotal,
},
total: onchainTotal + offchainTotal,
};
}
async getCoins() {
// TODO: add caching logic to lower the number of requests to provider
const address = await this.getAddress();
return this.onchainProvider.getCoins(address.onchain);
}
async getVtxos() {
if (!this.arkProvider || !this.offchainTapscript) {
return [];
}
const address = await this.getAddress();
if (!address.offchain) {
return [];
}
const { spendableVtxos } = await this.arkProvider.getVirtualCoins(address.offchain);
const encodedOffchainTapscript = this.offchainTapscript.encode();
const forfeit = this.offchainTapscript.forfeit();
return spendableVtxos.map((vtxo) => ({
...vtxo,
tapLeafScript: forfeit,
scripts: encodedOffchainTapscript,
}));
}
async getVirtualCoins() {
if (!this.arkProvider) {
return [];
}
const address = await this.getAddress();
if (!address.offchain) {
return [];
}
return this.arkProvider
.getVirtualCoins(address.offchain)
.then(({ spendableVtxos }) => spendableVtxos);
}
async getTransactionHistory() {
if (!this.arkProvider) {
return [];
}
const { spendableVtxos, spentVtxos } = await this.arkProvider.getVirtualCoins(this.offchainAddress.encode());
const { boardingTxs, roundsToIgnore } = await this.getBoardingTxs();
// convert VTXOs to offchain transactions
const offchainTxs = (0, transactionHistory_1.vtxosToTxs)(spendableVtxos, spentVtxos, roundsToIgnore);
const txs = [...boardingTxs, ...offchainTxs];
// sort transactions by creation time in descending order (newest first)
txs.sort(
// place createdAt = 0 (unconfirmed txs) first, then descending
(a, b) => {
if (a.createdAt === 0)
return -1;
if (b.createdAt === 0)
return 1;
return b.createdAt - a.createdAt;
});
return txs;
}
async getBoardingTxs() {
if (!this.boardingAddress) {
return { boardingTxs: [], roundsToIgnore: new Set() };
}
const boardingAddress = this.boardingOnchainAddress;
const txs = await this.onchainProvider.getTransactions(boardingAddress);
const utxos = [];
const roundsToIgnore = new Set();
for (const tx of txs) {
for (let i = 0; i < tx.vout.length; i++) {
const vout = tx.vout[i];
if (vout.scriptpubkey_address === boardingAddress) {
const spentStatuses = await this.onchainProvider.getTxOutspends(tx.txid);
const spentStatus = spentStatuses[i];
if (spentStatus?.spent) {
roundsToIgnore.add(spentStatus.txid);
}
utxos.push({
txid: tx.txid,
vout: i,
value: Number(vout.value),
status: {
confirmed: tx.status.confirmed,
block_time: tx.status.block_time,
},
virtualStatus: {
state: spentStatus?.spent ? "swept" : "pending",
batchTxID: spentStatus?.spent
? spentStatus.txid
: undefined,
},
createdAt: tx.status.confirmed
? new Date(tx.status.block_time * 1000)
: new Date(0),
});
}
}
}
const unconfirmedTxs = [];
const confirmedTxs = [];
for (const utxo of utxos) {
const tx = {
key: {
boardingTxid: utxo.txid,
roundTxid: "",
redeemTxid: "",
},
amount: utxo.value,
type: _1.TxType.TxReceived,
settled: utxo.virtualStatus.state === "swept",
createdAt: utxo.status.block_time
? new Date(utxo.status.block_time * 1000).getTime()
: 0,
};
if (!utxo.status.block_time) {
unconfirmedTxs.push(tx);
}
else {
confirmedTxs.push(tx);
}
}
return {
boardingTxs: [...unconfirmedTxs, ...confirmedTxs],
roundsToIgnore,
};
}
async getBoardingUtxos() {
if (!this.boardingAddress || !this.boardingTapscript) {
throw new Error("Boarding address not configured");
}
const boardingUtxos = await this.onchainProvider.getCoins(this.boardingOnchainAddress);
const encodedBoardingTapscript = this.boardingTapscript.encode();
const forfeit = this.boardingTapscript.forfeit();
return boardingUtxos.map((utxo) => ({
...utxo,
tapLeafScript: forfeit,
scripts: encodedBoardingTapscript,
}));
}
async sendBitcoin(params, zeroFee = true) {
if (params.amount <= 0) {
throw new Error("Amount must be positive");
}
if (params.amount < Wallet.DUST_AMOUNT) {
throw new Error("Amount is below dust limit");
}
// If Ark is configured and amount is suitable, send via offchain
if (this.arkProvider && this.isOffchainSuitable(params.address)) {
return this.sendOffchain(params, zeroFee);
}
// Otherwise, send via onchain
return this.sendOnchain(params);
}
isOffchainSuitable(address) {
try {
address_1.ArkAddress.decode(address);
return true;
}
catch (e) {
return false;
}
}
async sendOnchain(params) {
const coins = await this.getCoins();
const feeRate = params.feeRate || Wallet.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 = (0, coinselect_1.selectCoins)(coins, totalNeeded);
if (!selected.inputs) {
throw new Error("Insufficient funds");
}
// 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,
tapMerkleRoot: this.onchainP2TR.tapMerkleRoot,
});
}
// Add payment output
tx.addOutputAddress(params.address, BigInt(params.amount), this.network);
// Add change output if needed
if (selected.changeAmount > 0) {
tx.addOutputAddress(this.onchainAddress, BigInt(selected.changeAmount), this.network);
}
// Sign inputs and Finalize
tx = await this.identity.sign(tx);
tx.finalize();
// Broadcast
const txid = await this.onchainProvider.broadcastTransaction(tx.hex);
return txid;
}
async sendOffchain(params, zeroFee = true) {
if (!this.arkProvider ||
!this.offchainAddress ||
!this.offchainTapscript) {
throw new Error("wallet not initialized");
}
const virtualCoins = await this.getVirtualCoins();
const estimatedFee = zeroFee
? 0
: Math.ceil(174 * (params.feeRate || Wallet.FEE_RATE));
const totalNeeded = params.amount + estimatedFee;
const selected = (0, coinselect_1.selectVirtualCoins)(virtualCoins, totalNeeded);
if (!selected || !selected.inputs) {
throw new Error("Insufficient funds");
}
const selectedLeaf = this.offchainTapscript.forfeit();
if (!selectedLeaf) {
throw new Error("Selected leaf not found");
}
const outputs = [
{
address: params.address,
amount: BigInt(params.amount),
},
];
// add change output if needed
if (selected.changeAmount > 0) {
outputs.push({
address: this.offchainAddress.encode(),
amount: BigInt(selected.changeAmount),
});
}
const scripts = this.offchainTapscript.encode();
let tx = (0, psbt_2.createVirtualTx)(selected.inputs.map((input) => ({
...input,
tapLeafScript: selectedLeaf,
scripts,
})), outputs);
tx = await this.identity.sign(tx);
const psbt = base_1.base64.encode(tx.toPSBT());
return this.arkProvider.submitVirtualTx(psbt);
}
async settle(params, eventCallback) {
if (!this.arkProvider) {
throw new Error("Ark provider not configured");
}
// validate arknotes inputs
if (params?.inputs) {
for (const input of params.inputs) {
if (typeof input === "string") {
try {
arknote_1.ArkNote.fromString(input);
}
catch (e) {
throw new Error(`Invalid arknote "${input}"`);
}
}
}
}
// if no params are provided, use all boarding and offchain utxos as inputs
// and send all to the offchain address
if (!params) {
if (!this.offchainAddress) {
throw new Error("Offchain address not configured");
}
let amount = 0;
const boardingUtxos = await this.getBoardingUtxos();
amount += boardingUtxos.reduce((sum, input) => sum + input.value, 0);
const vtxos = await this.getVtxos();
amount += vtxos.reduce((sum, input) => sum + input.value, 0);
const inputs = [...boardingUtxos, ...vtxos];
if (inputs.length === 0) {
throw new Error("No inputs found");
}
params = {
inputs,
outputs: [
{
address: this.offchainAddress.encode(),
amount: BigInt(amount),
},
],
};
}
// register inputs
const { requestId } = await this.arkProvider.registerInputsForNextRound(params.inputs.map((input) => {
if (typeof input === "string") {
return input;
}
return {
outpoint: input,
tapscripts: input.scripts,
};
}));
const hasOffchainOutputs = params.outputs.some((output) => this.isOffchainSuitable(output.address));
// session holds the state of the musig2 signing process of the vtxo tree
let session;
const signingPublicKeys = [];
if (hasOffchainOutputs) {
session = this.identity.signerSession();
signingPublicKeys.push(base_1.hex.encode(session.getPublicKey()));
}
// register outputs
await this.arkProvider.registerOutputsForNextRound(requestId, params.outputs, signingPublicKeys);
// start pinging every seconds
const interval = setInterval(() => {
this.arkProvider?.ping(requestId).catch(stopPing);
}, 1000);
let pingRunning = true;
const stopPing = () => {
if (pingRunning) {
pingRunning = false;
clearInterval(interval);
}
};
const abortController = new AbortController();
// listen to settlement events
try {
const settlementStream = this.arkProvider.getEventStream(abortController.signal);
let step;
if (!hasOffchainOutputs) {
// if there are no offchain outputs, we don't have to handle musig2 tree signatures
// we can directly advance to the finalization step
step = ark_1.SettlementEventType.SigningNoncesGenerated;
}
const info = await this.arkProvider.getInfo();
const sweepTapscript = tapscript_1.CSVMultisigTapscript.encode({
timelock: {
value: info.batchExpiry,
type: info.batchExpiry >= 512n ? "seconds" : "blocks",
},
pubkeys: [base_1.hex.decode(info.pubkey).slice(1)],
}).script;
const sweepTapTreeRoot = (0, payment_1.tapLeafHash)(sweepTapscript);
for await (const event of settlementStream) {
if (eventCallback) {
eventCallback(event);
}
switch (event.type) {
// the settlement failed
case ark_1.SettlementEventType.Failed:
if (step === undefined) {
continue;
}
stopPing();
throw new Error(event.reason);
// the server has started the signing process of the vtxo tree transactions
// the server expects the partial musig2 nonces for each tx
case ark_1.SettlementEventType.SigningStart:
if (step !== undefined) {
continue;
}
stopPing();
if (hasOffchainOutputs) {
if (!session) {
throw new Error("Signing session not found");
}
await this.handleSettlementSigningEvent(event, sweepTapTreeRoot, session);
}
break;
// the musig2 nonces of the vtxo tree transactions are generated
// the server expects now the partial musig2 signatures
case ark_1.SettlementEventType.SigningNoncesGenerated:
if (step !== ark_1.SettlementEventType.SigningStart) {
continue;
}
stopPing();
if (hasOffchainOutputs) {
if (!session) {
throw new Error("Signing session not found");
}
await this.handleSettlementSigningNoncesGeneratedEvent(event, session);
}
break;
// the vtxo tree is signed, craft, sign and submit forfeit transactions
// if any boarding utxos are involved, the settlement tx is also signed
case ark_1.SettlementEventType.Finalization:
if (step !== ark_1.SettlementEventType.SigningNoncesGenerated) {
continue;
}
stopPing();
await this.handleSettlementFinalizationEvent(event, params.inputs, info);
break;
// the settlement is done, last event to be received
case ark_1.SettlementEventType.Finalized:
if (step !== ark_1.SettlementEventType.Finalization) {
continue;
}
abortController.abort();
return event.roundTxid;
}
step = event.type;
}
}
catch (error) {
abortController.abort();
throw error;
}
throw new Error("Settlement failed");
}
// validates the vtxo tree, creates a signing session and generates the musig2 nonces
async handleSettlementSigningEvent(event, sweepTapTreeRoot, session) {
const vtxoTree = event.unsignedVtxoTree;
if (!this.arkProvider) {
throw new Error("Ark provider not configured");
}
// validate the unsigned vtxo tree
(0, validation_1.validateVtxoTree)(event.unsignedSettlementTx, vtxoTree, sweepTapTreeRoot);
// TODO check if our registered outputs are in the vtxo tree
const settlementPsbt = base_1.base64.decode(event.unsignedSettlementTx);
const settlementTx = btc_signer_1.Transaction.fromPSBT(settlementPsbt);
const sharedOutput = settlementTx.getOutput(0);
if (!sharedOutput?.amount) {
throw new Error("Shared output not found");
}
session.init(vtxoTree, sweepTapTreeRoot, sharedOutput.amount);
await this.arkProvider.submitTreeNonces(event.id, base_1.hex.encode(session.getPublicKey()), session.getNonces());
}
async handleSettlementSigningNoncesGeneratedEvent(event, session) {
if (!this.arkProvider) {
throw new Error("Ark provider not configured");
}
session.setAggregatedNonces(event.treeNonces);
const signatures = session.sign();
await this.arkProvider.submitTreeSignatures(event.id, base_1.hex.encode(session.getPublicKey()), signatures);
}
async handleSettlementFinalizationEvent(event, inputs, infos) {
if (!this.arkProvider) {
throw new Error("Ark provider not configured");
}
// parse the server forfeit address
// server is expecting funds to be sent to this address
const forfeitAddress = (0, payment_1.Address)(this.network).decode(infos.forfeitAddress);
const serverPkScript = payment_1.OutScript.encode(forfeitAddress);
// the signed forfeits transactions to submit
const signedForfeits = [];
const vtxos = await this.getVirtualCoins();
let settlementPsbt = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(event.roundTx));
let hasBoardingUtxos = false;
let connectorsTreeValid = false;
for (const input of inputs) {
if (typeof input === "string")
continue; // skip notes
// check if the input is an offchain "virtual" coin
const vtxo = vtxos.find((vtxo) => vtxo.txid === input.txid && vtxo.vout === input.vout);
// boarding utxo, we need to sign the settlement tx
if (!vtxo) {
hasBoardingUtxos = true;
const inputIndexes = [];
for (let i = 0; i < settlementPsbt.inputsLength; i++) {
const settlementInput = settlementPsbt.getInput(i);
if (!settlementInput.txid ||
settlementInput.index === undefined) {
throw new Error("The server returned incomplete data. No settlement input found in the PSBT");
}
const inputTxId = base_1.hex.encode(settlementInput.txid);
if (inputTxId !== input.txid)
continue;
if (settlementInput.index !== input.vout)
continue;
// input found in the settlement tx, sign it
settlementPsbt.updateInput(i, {
tapLeafScript: [input.tapLeafScript],
});
inputIndexes.push(i);
}
settlementPsbt = await this.identity.sign(settlementPsbt, inputIndexes);
continue;
}
if (!connectorsTreeValid) {
// validate that the connectors tree is valid and contains our expected connectors
(0, validation_1.validateConnectorsTree)(event.roundTx, event.connectors);
connectorsTreeValid = true;
}
const forfeitControlBlock = psbt_1.TaprootControlBlock.encode(input.tapLeafScript[0]);
const tapscript = (0, tapscript_1.decodeTapscript)((0, base_2.scriptFromTapLeafScript)(input.tapLeafScript));
const fees = txSizeEstimator_1.TxWeightEstimator.create()
.addKeySpendInput() // connector
.addTapscriptInput(tapscript.witnessSize(100), // TODO: handle conditional script
input.tapLeafScript[1].length - 1, forfeitControlBlock.length)
.addP2WKHOutput()
.vsize()
.fee(event.minRelayFeeRate);
const connectorsLeaves = event.connectors.leaves();
const connectorOutpoint = event.connectorsIndex.get(`${vtxo.txid}:${vtxo.vout}`);
if (!connectorOutpoint) {
throw new Error("Connector outpoint not found");
}
let connectorOutput;
for (const leaf of connectorsLeaves) {
if (leaf.txid === connectorOutpoint.txid) {
try {
const connectorTx = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(leaf.tx));
connectorOutput = connectorTx.getOutput(connectorOutpoint.vout);
break;
}
catch {
throw new Error("Invalid connector tx");
}
}
}
if (!connectorOutput ||
!connectorOutput.amount ||
!connectorOutput.script) {
throw new Error("Connector output not found");
}
let forfeitTx = (0, forfeit_1.buildForfeitTx)({
connectorInput: connectorOutpoint,
connectorAmount: connectorOutput.amount,
feeAmount: fees,
serverPkScript,
connectorPkScript: connectorOutput.script,
vtxoAmount: BigInt(vtxo.value),
vtxoInput: input,
vtxoPkScript: base_2.VtxoScript.decode(input.scripts).pkScript,
});
// add the tapscript
forfeitTx.updateInput(1, {
tapLeafScript: [input.tapLeafScript],
});
// do not sign the connector input
forfeitTx = await this.identity.sign(forfeitTx, [1]);
signedForfeits.push(base_1.base64.encode(forfeitTx.toPSBT()));
}
await this.arkProvider.submitSignedForfeitTxs(signedForfeits, hasBoardingUtxos
? base_1.base64.encode(settlementPsbt.toPSBT())
: undefined);
}
}
exports.Wallet = Wallet;
// TODO get dust from ark server?
Wallet.DUST_AMOUNT = BigInt(546); // Bitcoin dust limit in satoshis = 546
Wallet.FEE_RATE = 1; // sats/vbyte