UNPKG

@arklabs/wallet-sdk

Version:

Bitcoin wallet SDK with Taproot and Ark integration

726 lines (725 loc) 31.5 kB
"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