UNPKG

@arkade-os/sdk

Version:

Bitcoin wallet SDK with Taproot and Ark integration

269 lines (268 loc) 11.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Unroll = void 0; const base_1 = require("@scure/base"); const btc_signer_1 = require("@scure/btc-signer"); const indexer_1 = require("../providers/indexer"); const base_2 = require("../script/base"); const txSizeEstimator_1 = require("../utils/txSizeEstimator"); const wallet_1 = require("./wallet"); const transaction_1 = require("../utils/transaction"); var Unroll; (function (Unroll) { let StepType; (function (StepType) { StepType[StepType["UNROLL"] = 0] = "UNROLL"; StepType[StepType["WAIT"] = 1] = "WAIT"; StepType[StepType["DONE"] = 2] = "DONE"; })(StepType = Unroll.StepType || (Unroll.StepType = {})); /** * Manages the unrolling process of a VTXO back to the Bitcoin blockchain. * * The Session class implements an async iterator that processes the unrolling steps: * 1. **WAIT**: Waits for a transaction to be confirmed onchain (if it's in mempool) * 2. **UNROLL**: Broadcasts the next transaction in the chain to the blockchain * 3. **DONE**: Indicates the unrolling process is complete * * The unrolling process works by traversing the transaction chain from the root (most recent) * to the leaf (oldest), broadcasting each transaction that isn't already onchain. * * @example * ```typescript * const session = await Unroll.Session.create(vtxoOutpoint, bumper, explorer, indexer); * * // iterate over the steps * for await (const doneStep of session) { * switch (doneStep.type) { * case Unroll.StepType.WAIT: * console.log(`Transaction ${doneStep.txid} confirmed`); * break; * case Unroll.StepType.UNROLL: * console.log(`Broadcasting transaction ${doneStep.tx.id}`); * break; * case Unroll.StepType.DONE: * console.log(`Unrolling complete for VTXO ${doneStep.vtxoTxid}`); * break; * } * } * ``` **/ class Session { constructor(toUnroll, bumper, explorer, indexer) { this.toUnroll = toUnroll; this.bumper = bumper; this.explorer = explorer; this.indexer = indexer; } static async create(toUnroll, bumper, explorer, indexer) { const { chain } = await indexer.getVtxoChain(toUnroll); return new Session({ ...toUnroll, chain }, bumper, explorer, indexer); } /** * Get the next step to be executed * @returns The next step to be executed + the function to execute it */ async next() { let nextTxToBroadcast; const chain = this.toUnroll.chain; // Iterate through the chain from the end (root) to the beginning (leaf) for (let i = chain.length - 1; i >= 0; i--) { const chainTx = chain[i]; // Skip commitment transactions as they are always onchain if (chainTx.type === indexer_1.ChainTxType.COMMITMENT || chainTx.type === indexer_1.ChainTxType.UNSPECIFIED) { continue; } try { // Check if the transaction is confirmed onchain const txInfo = await this.explorer.getTxStatus(chainTx.txid); // If found but not confirmed, it means the tx is in the mempool // An unilateral exit is running, we must wait for it to be confirmed if (!txInfo.confirmed) { return { type: StepType.WAIT, txid: chainTx.txid, do: doWait(this.explorer, chainTx.txid), }; } } catch (e) { // If the tx is not found, it's offchain, let's break nextTxToBroadcast = chainTx; break; } } if (!nextTxToBroadcast) { return { type: StepType.DONE, vtxoTxid: this.toUnroll.txid, do: () => Promise.resolve(), }; } // Get the virtual transaction data const virtualTxs = await this.indexer.getVirtualTxs([ nextTxToBroadcast.txid, ]); if (virtualTxs.txs.length === 0) { throw new Error(`Tx ${nextTxToBroadcast.txid} not found`); } const tx = transaction_1.Transaction.fromPSBT(base_1.base64.decode(virtualTxs.txs[0])); // finalize the tree transaction if (nextTxToBroadcast.type === indexer_1.ChainTxType.TREE) { const input = tx.getInput(0); if (!input) { throw new Error("Input not found"); } const tapKeySig = input.tapKeySig; if (!tapKeySig) { throw new Error("Tap key sig not found"); } tx.updateInput(0, { finalScriptWitness: [tapKeySig], }); } else { // finalize ark transaction tx.finalize(); } return { type: StepType.UNROLL, tx, do: doUnroll(this.bumper, this.explorer, tx), }; } /** * Iterate over the steps to be executed and execute them * @returns An async iterator over the executed steps */ async *[Symbol.asyncIterator]() { let lastStep; do { if (lastStep !== undefined) { // wait 1 second before trying the next step in order to give time to the // explorer to update the tx status await sleep(1000); } const step = await this.next(); await step.do(); yield step; lastStep = step.type; } while (lastStep !== StepType.DONE); } } Unroll.Session = Session; /** * Complete the unroll of a VTXO by broadcasting the transaction that spends the CSV path. * @param wallet the wallet owning the VTXO(s) * @param vtxoTxids the txids of the VTXO(s) to complete unroll * @param outputAddress the address to send the unrolled funds to * @throws if the VTXO(s) are not fully unrolled, if the txids are not found, if the tx is not confirmed, if no exit path is found or not available * @returns the txid of the transaction spending the unrolled funds */ async function completeUnroll(wallet, vtxoTxids, outputAddress) { const chainTip = await wallet.onchainProvider.getChainTip(); let vtxos = await wallet.getVtxos({ withUnrolled: true }); vtxos = vtxos.filter((vtxo) => vtxoTxids.includes(vtxo.txid)); if (vtxos.length === 0) { throw new Error("No vtxos to complete unroll"); } const inputs = []; let totalAmount = 0n; const txWeightEstimator = txSizeEstimator_1.TxWeightEstimator.create(); for (const vtxo of vtxos) { if (!vtxo.isUnrolled) { throw new Error(`Vtxo ${vtxo.txid}:${vtxo.vout} is not fully unrolled, use unroll first`); } const txStatus = await wallet.onchainProvider.getTxStatus(vtxo.txid); if (!txStatus.confirmed) { throw new Error(`tx ${vtxo.txid} is not confirmed`); } const exit = availableExitPath({ height: txStatus.blockHeight, time: txStatus.blockTime }, chainTip, vtxo); if (!exit) { throw new Error(`no available exit path found for vtxo ${vtxo.txid}:${vtxo.vout}`); } const spendingLeaf = base_2.VtxoScript.decode(vtxo.tapTree).findLeaf(base_1.hex.encode(exit.script)); if (!spendingLeaf) { throw new Error(`spending leaf not found for vtxo ${vtxo.txid}:${vtxo.vout}`); } totalAmount += BigInt(vtxo.value); inputs.push({ txid: vtxo.txid, index: vtxo.vout, tapLeafScript: [spendingLeaf], sequence: 0xffffffff - 1, witnessUtxo: { amount: BigInt(vtxo.value), script: base_2.VtxoScript.decode(vtxo.tapTree).pkScript, }, sighashType: btc_signer_1.SigHash.DEFAULT, }); txWeightEstimator.addTapscriptInput(64, spendingLeaf[1].length, btc_signer_1.TaprootControlBlock.encode(spendingLeaf[0]).length); } const tx = new transaction_1.Transaction({ version: 2 }); for (const input of inputs) { tx.addInput(input); } txWeightEstimator.addP2TROutput(); let feeRate = await wallet.onchainProvider.getFeeRate(); if (!feeRate || feeRate < wallet_1.Wallet.MIN_FEE_RATE) { feeRate = wallet_1.Wallet.MIN_FEE_RATE; } const feeAmount = txWeightEstimator.vsize().fee(BigInt(feeRate)); if (feeAmount > totalAmount) { throw new Error("fee amount is greater than the total amount"); } tx.addOutputAddress(outputAddress, totalAmount - feeAmount); const signedTx = await wallet.identity.sign(tx); signedTx.finalize(); await wallet.onchainProvider.broadcastTransaction(signedTx.hex); return signedTx.id; } Unroll.completeUnroll = completeUnroll; })(Unroll || (exports.Unroll = Unroll = {})); function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function doUnroll(bumper, onchainProvider, tx) { return async () => { const [parent, child] = await bumper.bumpP2A(tx); await onchainProvider.broadcastTransaction(parent, child); }; } function doWait(onchainProvider, txid) { return () => { return new Promise((resolve, reject) => { const interval = setInterval(async () => { try { const txInfo = await onchainProvider.getTxStatus(txid); if (txInfo.confirmed) { clearInterval(interval); resolve(); } } catch (e) { clearInterval(interval); reject(e); } }, 5000); }); }; } function availableExitPath(confirmedAt, current, vtxo) { const exits = base_2.VtxoScript.decode(vtxo.tapTree).exitPaths(); for (const exit of exits) { if (exit.params.timelock.type === "blocks") { if (current.height >= confirmedAt.height + Number(exit.params.timelock.value)) { return exit; } } else { if (current.time >= confirmedAt.time + Number(exit.params.timelock.value)) { return exit; } } } return undefined; }