UNPKG

@arkade-os/sdk

Version:

Bitcoin wallet SDK with Taproot and Ark integration

1,116 lines (1,115 loc) 51.5 kB
"use strict"; 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; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.Wallet = exports.ReadonlyWallet = void 0; exports.getSequence = getSequence; exports.waitForIncomingFunds = waitForIncomingFunds; const base_1 = require("@scure/base"); const bip68 = __importStar(require("bip68")); const payment_js_1 = require("@scure/btc-signer/payment.js"); const btc_signer_1 = require("@scure/btc-signer"); const utils_js_1 = require("@scure/btc-signer/utils.js"); const transactionHistory_1 = require("../utils/transactionHistory"); const address_1 = require("../script/address"); const default_1 = require("../script/default"); const networks_1 = require("../networks"); const onchain_1 = require("../providers/onchain"); const ark_1 = require("../providers/ark"); const forfeit_1 = require("../forfeit"); const validation_1 = require("../tree/validation"); const _1 = require("."); const base_2 = require("../script/base"); const tapscript_1 = require("../script/tapscript"); const arkTransaction_1 = require("../utils/arkTransaction"); const vtxo_manager_1 = require("./vtxo-manager"); const arknote_1 = require("../arknote"); const intent_1 = require("../intent"); const indexer_1 = require("../providers/indexer"); const unknownFields_1 = require("../utils/unknownFields"); const inMemory_1 = require("../storage/inMemory"); const walletRepository_1 = require("../repositories/walletRepository"); const contractRepository_1 = require("../repositories/contractRepository"); const utils_1 = require("./utils"); const errors_1 = require("../providers/errors"); const batch_1 = require("./batch"); /** * Type guard function to check if an identity has a toReadonly method. */ function hasToReadonly(identity) { return (typeof identity === "object" && identity !== null && "toReadonly" in identity && typeof identity.toReadonly === "function"); } class ReadonlyWallet { constructor(identity, network, onchainProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, dustAmount, walletRepository, contractRepository) { this.identity = identity; this.network = network; this.onchainProvider = onchainProvider; this.indexerProvider = indexerProvider; this.arkServerPublicKey = arkServerPublicKey; this.offchainTapscript = offchainTapscript; this.boardingTapscript = boardingTapscript; this.dustAmount = dustAmount; this.walletRepository = walletRepository; this.contractRepository = contractRepository; } /** * Protected helper to set up shared wallet configuration. * Extracts common logic used by both ReadonlyWallet.create() and Wallet.create(). */ static async setupWalletConfig(config, pubkey) { // Use provided arkProvider instance or create a new one from arkServerUrl const arkProvider = config.arkProvider || (() => { if (!config.arkServerUrl) { throw new Error("Either arkProvider or arkServerUrl must be provided"); } return new ark_1.RestArkProvider(config.arkServerUrl); })(); // Extract arkServerUrl from provider if not explicitly provided const arkServerUrl = config.arkServerUrl || arkProvider.serverUrl; if (!arkServerUrl) { throw new Error("Could not determine arkServerUrl from provider"); } // Use provided indexerProvider instance or create a new one // indexerUrl defaults to arkServerUrl if not provided const indexerUrl = config.indexerUrl || arkServerUrl; const indexerProvider = config.indexerProvider || new indexer_1.RestIndexerProvider(indexerUrl); const info = await arkProvider.getInfo(); const network = (0, networks_1.getNetwork)(info.network); // Extract esploraUrl from provider if not explicitly provided const esploraUrl = config.esploraUrl || onchain_1.ESPLORA_URL[info.network]; // Use provided onchainProvider instance or create a new one const onchainProvider = config.onchainProvider || new onchain_1.EsploraProvider(esploraUrl); // validate unilateral exit timelock passed in config if any if (config.exitTimelock) { const { value, type } = config.exitTimelock; if ((value < 512n && type !== "blocks") || (value >= 512n && type !== "seconds")) { throw new Error("invalid exitTimelock"); } } // create unilateral exit timelock const exitTimelock = config.exitTimelock ?? { value: info.unilateralExitDelay, type: info.unilateralExitDelay < 512n ? "blocks" : "seconds", }; // validate boarding timelock passed in config if any if (config.boardingTimelock) { const { value, type } = config.boardingTimelock; if ((value < 512n && type !== "blocks") || (value >= 512n && type !== "seconds")) { throw new Error("invalid boardingTimelock"); } } // create boarding timelock const boardingTimelock = config.boardingTimelock ?? { value: info.boardingExitDelay, type: info.boardingExitDelay < 512n ? "blocks" : "seconds", }; // Generate tapscripts for offchain and boarding address const serverPubKey = base_1.hex.decode(info.signerPubkey).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; // Set up storage and repositories const storage = config.storage || new inMemory_1.InMemoryStorageAdapter(); const walletRepository = new walletRepository_1.WalletRepositoryImpl(storage); const contractRepository = new contractRepository_1.ContractRepositoryImpl(storage); return { arkProvider, indexerProvider, onchainProvider, network, networkName: info.network, serverPubKey, offchainTapscript, boardingTapscript, dustAmount: info.dust, walletRepository, contractRepository, info, }; } static async create(config) { const pubkey = await config.identity.xOnlyPublicKey(); if (!pubkey) { throw new Error("Invalid configured public key"); } const setup = await ReadonlyWallet.setupWalletConfig(config, pubkey); return new ReadonlyWallet(config.identity, setup.network, setup.onchainProvider, setup.indexerProvider, setup.serverPubKey, setup.offchainTapscript, setup.boardingTapscript, setup.dustAmount, setup.walletRepository, setup.contractRepository); } get arkAddress() { return this.offchainTapscript.address(this.network.hrp, this.arkServerPublicKey); } async getAddress() { return this.arkAddress.encode(); } async getBoardingAddress() { return this.boardingTapscript.onchainAddress(this.network); } async getBalance() { const [boardingUtxos, vtxos] = await Promise.all([ this.getBoardingUtxos(), this.getVtxos(), ]); // boarding let confirmed = 0; let unconfirmed = 0; for (const utxo of boardingUtxos) { if (utxo.status.confirmed) { confirmed += utxo.value; } else { unconfirmed += utxo.value; } } // offchain let settled = 0; let preconfirmed = 0; let recoverable = 0; settled = vtxos .filter((coin) => coin.virtualStatus.state === "settled") .reduce((sum, coin) => sum + coin.value, 0); preconfirmed = vtxos .filter((coin) => coin.virtualStatus.state === "preconfirmed") .reduce((sum, coin) => sum + coin.value, 0); recoverable = vtxos .filter((coin) => (0, _1.isSpendable)(coin) && coin.virtualStatus.state === "swept") .reduce((sum, coin) => sum + coin.value, 0); const totalBoarding = confirmed + unconfirmed; const totalOffchain = settled + preconfirmed + recoverable; return { boarding: { confirmed, unconfirmed, total: totalBoarding, }, settled, preconfirmed, available: settled + preconfirmed, recoverable, total: totalBoarding + totalOffchain, }; } async getVtxos(filter) { const address = await this.getAddress(); // Try to get from cache first first (optional fast path) // const cachedVtxos = await this.walletRepository.getVtxos(address); // if (cachedVtxos.length) return cachedVtxos; // For now, always fetch fresh data from provider and update cache // In future, we can add cache invalidation logic based on timestamps const vtxos = await this.getVirtualCoins(filter); const extendedVtxos = vtxos.map((vtxo) => (0, utils_1.extendVirtualCoin)(this, vtxo)); // Update cache with fresh data await this.walletRepository.saveVtxos(address, extendedVtxos); return extendedVtxos; } async getVirtualCoins(filter = { withRecoverable: true, withUnrolled: false }) { const scripts = [base_1.hex.encode(this.offchainTapscript.pkScript)]; const response = await this.indexerProvider.getVtxos({ scripts }); const allVtxos = response.vtxos; let vtxos = allVtxos.filter(_1.isSpendable); // all recoverable vtxos are spendable by definition if (!filter.withRecoverable) { vtxos = vtxos.filter((vtxo) => !(0, _1.isRecoverable)(vtxo) && !(0, _1.isExpired)(vtxo)); } if (filter.withUnrolled) { const spentVtxos = allVtxos.filter((vtxo) => !(0, _1.isSpendable)(vtxo)); vtxos.push(...spentVtxos.filter((vtxo) => vtxo.isUnrolled)); } return vtxos; } async getTransactionHistory() { const response = await this.indexerProvider.getVtxos({ scripts: [base_1.hex.encode(this.offchainTapscript.pkScript)], }); const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs(); const spendableVtxos = []; const spentVtxos = []; for (const vtxo of response.vtxos) { if ((0, _1.isSpendable)(vtxo)) { spendableVtxos.push(vtxo); } else { spentVtxos.push(vtxo); } } // convert VTXOs to offchain transactions const offchainTxs = (0, transactionHistory_1.vtxosToTxs)(spendableVtxos, spentVtxos, commitmentsToIgnore); 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() { const utxos = []; const commitmentsToIgnore = new Set(); const boardingAddress = await this.getBoardingAddress(); const txs = await this.onchainProvider.getTransactions(boardingAddress); 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) { commitmentsToIgnore.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, }, isUnrolled: true, virtualStatus: { state: spentStatus?.spent ? "spent" : "settled", commitmentTxIds: 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, commitmentTxid: "", arkTxid: "", }, amount: utxo.value, type: _1.TxType.TxReceived, settled: utxo.virtualStatus.state === "spent", 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], commitmentsToIgnore, }; } async getBoardingUtxos() { const boardingAddress = await this.getBoardingAddress(); const boardingUtxos = await this.onchainProvider.getCoins(boardingAddress); const utxos = boardingUtxos.map((utxo) => { return (0, utils_1.extendCoin)(this, utxo); }); // Save boardingUtxos using unified repository await this.walletRepository.saveUtxos(boardingAddress, utxos); return utxos; } async notifyIncomingFunds(eventCallback) { const arkAddress = await this.getAddress(); const boardingAddress = await this.getBoardingAddress(); let onchainStopFunc; let indexerStopFunc; if (this.onchainProvider && boardingAddress) { const findVoutOnTx = (tx) => { return tx.vout.findIndex((v) => v.scriptpubkey_address === boardingAddress); }; onchainStopFunc = await this.onchainProvider.watchAddresses([boardingAddress], (txs) => { // find all utxos belonging to our boarding address const coins = txs // filter txs where address is in output .filter((tx) => findVoutOnTx(tx) !== -1) // return utxo as Coin .map((tx) => { const { txid, status } = tx; const vout = findVoutOnTx(tx); const value = Number(tx.vout[vout].value); return { txid, vout, value, status }; }); // and notify via callback eventCallback({ type: "utxo", coins, }); }); } if (this.indexerProvider && arkAddress) { const offchainScript = this.offchainTapscript; const subscriptionId = await this.indexerProvider.subscribeForScripts([ base_1.hex.encode(offchainScript.pkScript), ]); const abortController = new AbortController(); const subscription = this.indexerProvider.getSubscription(subscriptionId, abortController.signal); indexerStopFunc = async () => { abortController.abort(); await this.indexerProvider?.unsubscribeForScripts(subscriptionId); }; // Handle subscription updates asynchronously without blocking (async () => { try { for await (const update of subscription) { if (update.newVtxos?.length > 0 || update.spentVtxos?.length > 0) { eventCallback({ type: "vtxo", newVtxos: update.newVtxos.map((vtxo) => (0, utils_1.extendVirtualCoin)(this, vtxo)), spentVtxos: update.spentVtxos.map((vtxo) => (0, utils_1.extendVirtualCoin)(this, vtxo)), }); } } } catch (error) { console.error("Subscription error:", error); } })(); } const stopFunc = () => { onchainStopFunc?.(); indexerStopFunc?.(); }; return stopFunc; } async fetchPendingTxs() { // get non-swept VTXOs, rely on the indexer only in case DB doesn't have the right state const scripts = [base_1.hex.encode(this.offchainTapscript.pkScript)]; let { vtxos } = await this.indexerProvider.getVtxos({ scripts, }); return vtxos .filter((vtxo) => vtxo.virtualStatus.state !== "swept" && vtxo.virtualStatus.state !== "settled" && vtxo.arkTxId !== undefined) .map((_) => _.arkTxId); } } exports.ReadonlyWallet = ReadonlyWallet; /** * Main wallet implementation for Bitcoin transactions with Ark protocol support. * The wallet does not store any data locally and relies on Ark and onchain * providers to fetch UTXOs and VTXOs. * * @example * ```typescript * // Create a wallet with URL configuration * const wallet = await Wallet.create({ * identity: SingleKey.fromHex('your_private_key'), * arkServerUrl: 'https://ark.example.com', * esploraUrl: 'https://mempool.space/api' * }); * * // Or with custom provider instances (e.g., for Expo/React Native) * const wallet = await Wallet.create({ * identity: SingleKey.fromHex('your_private_key'), * arkProvider: new ExpoArkProvider('https://ark.example.com'), * indexerProvider: new ExpoIndexerProvider('https://ark.example.com'), * esploraUrl: 'https://mempool.space/api' * }); * * // Get addresses * const arkAddress = await wallet.getAddress(); * const boardingAddress = await wallet.getBoardingAddress(); * * // Send bitcoin * const txid = await wallet.sendBitcoin({ * address: 'tb1...', * amount: 50000 * }); * ``` */ class Wallet extends ReadonlyWallet { constructor(identity, network, networkName, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository, renewalConfig) { super(identity, network, onchainProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, dustAmount, walletRepository, contractRepository); this.networkName = networkName; this.arkProvider = arkProvider; this.serverUnrollScript = serverUnrollScript; this.forfeitOutputScript = forfeitOutputScript; this.forfeitPubkey = forfeitPubkey; this.identity = identity; this.renewalConfig = { enabled: renewalConfig?.enabled ?? false, ...vtxo_manager_1.DEFAULT_RENEWAL_CONFIG, ...renewalConfig, }; } static async create(config) { const pubkey = await config.identity.xOnlyPublicKey(); if (!pubkey) { throw new Error("Invalid configured public key"); } const setup = await ReadonlyWallet.setupWalletConfig(config, pubkey); // Compute Wallet-specific forfeit and unroll scripts // the serverUnrollScript is the one used to create output scripts of the checkpoint transactions let serverUnrollScript; try { const raw = base_1.hex.decode(setup.info.checkpointTapscript); serverUnrollScript = tapscript_1.CSVMultisigTapscript.decode(raw); } catch (e) { throw new Error("Invalid checkpointTapscript from server"); } // parse the server forfeit address // server is expecting funds to be sent to this address const forfeitPubkey = base_1.hex.decode(setup.info.forfeitPubkey).slice(1); const forfeitAddress = (0, btc_signer_1.Address)(setup.network).decode(setup.info.forfeitAddress); const forfeitOutputScript = btc_signer_1.OutScript.encode(forfeitAddress); return new Wallet(config.identity, setup.network, setup.networkName, setup.onchainProvider, setup.arkProvider, setup.indexerProvider, setup.serverPubKey, setup.offchainTapscript, setup.boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, setup.dustAmount, setup.walletRepository, setup.contractRepository, config.renewalConfig); } /** * Convert this wallet to a readonly wallet. * * @returns A readonly wallet with the same configuration but readonly identity * @example * ```typescript * const wallet = await Wallet.create({ identity: SingleKey.fromHex('...'), ... }); * const readonlyWallet = await wallet.toReadonly(); * * // Can query balance and addresses * const balance = await readonlyWallet.getBalance(); * const address = await readonlyWallet.getAddress(); * * // But cannot send transactions (type error) * // readonlyWallet.sendBitcoin(...); // TypeScript error * ``` */ async toReadonly() { // Check if the identity has a toReadonly method using type guard const readonlyIdentity = hasToReadonly(this.identity) ? await this.identity.toReadonly() : this.identity; // Identity extends ReadonlyIdentity, so this is safe return new ReadonlyWallet(readonlyIdentity, this.network, this.onchainProvider, this.indexerProvider, this.arkServerPublicKey, this.offchainTapscript, this.boardingTapscript, this.dustAmount, this.walletRepository, this.contractRepository); } async sendBitcoin(params) { if (params.amount <= 0) { throw new Error("Amount must be positive"); } if (!isValidArkAddress(params.address)) { throw new Error("Invalid Ark address " + params.address); } // recoverable and subdust coins can't be spent in offchain tx const virtualCoins = await this.getVirtualCoins({ withRecoverable: false, }); const selected = selectVirtualCoins(virtualCoins, params.amount); const selectedLeaf = this.offchainTapscript.forfeit(); if (!selectedLeaf) { throw new Error("Selected leaf not found"); } const outputAddress = address_1.ArkAddress.decode(params.address); const outputScript = BigInt(params.amount) < this.dustAmount ? outputAddress.subdustPkScript : outputAddress.pkScript; const outputs = [ { script: outputScript, amount: BigInt(params.amount), }, ]; // add change output if needed if (selected.changeAmount > 0n) { const changeOutputScript = selected.changeAmount < this.dustAmount ? this.arkAddress.subdustPkScript : this.arkAddress.pkScript; outputs.push({ script: changeOutputScript, amount: BigInt(selected.changeAmount), }); } const tapTree = this.offchainTapscript.encode(); const offchainTx = (0, arkTransaction_1.buildOffchainTx)(selected.inputs.map((input) => ({ ...input, tapLeafScript: selectedLeaf, tapTree, })), outputs, this.serverUnrollScript); const signedVirtualTx = await this.identity.sign(offchainTx.arkTx); const { arkTxid, signedCheckpointTxs } = await this.arkProvider.submitTx(base_1.base64.encode(signedVirtualTx.toPSBT()), offchainTx.checkpoints.map((c) => base_1.base64.encode(c.toPSBT()))); // sign the checkpoints const finalCheckpoints = await Promise.all(signedCheckpointTxs.map(async (c) => { const tx = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(c)); const signedCheckpoint = await this.identity.sign(tx); return base_1.base64.encode(signedCheckpoint.toPSBT()); })); await this.arkProvider.finalizeTx(arkTxid, finalCheckpoints); try { // mark VTXOs as spent and optionally add the change VTXO const spentVtxos = []; const commitmentTxIds = new Set(); let batchExpiry = Number.MAX_SAFE_INTEGER; for (const [inputIndex, input] of selected.inputs.entries()) { const vtxo = (0, utils_1.extendVirtualCoin)(this, input); const checkpointB64 = signedCheckpointTxs[inputIndex]; const checkpoint = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(checkpointB64)); spentVtxos.push({ ...vtxo, virtualStatus: { ...vtxo.virtualStatus, state: "spent" }, spentBy: checkpoint.id, arkTxId: arkTxid, isSpent: true, }); if (vtxo.virtualStatus.commitmentTxIds) { for (const commitmentTxId of vtxo.virtualStatus .commitmentTxIds) { commitmentTxIds.add(commitmentTxId); } } if (vtxo.virtualStatus.batchExpiry) { batchExpiry = Math.min(batchExpiry, vtxo.virtualStatus.batchExpiry); } } const createdAt = Date.now(); const addr = this.arkAddress.encode(); if (selected.changeAmount > 0n && batchExpiry !== Number.MAX_SAFE_INTEGER) { const changeVtxo = { txid: arkTxid, vout: outputs.length - 1, createdAt: new Date(createdAt), forfeitTapLeafScript: this.offchainTapscript.forfeit(), intentTapLeafScript: this.offchainTapscript.forfeit(), isUnrolled: false, isSpent: false, tapTree: this.offchainTapscript.encode(), value: Number(selected.changeAmount), virtualStatus: { state: "preconfirmed", commitmentTxIds: Array.from(commitmentTxIds), batchExpiry, }, status: { confirmed: false, }, }; await this.walletRepository.saveVtxos(addr, [changeVtxo]); } await this.walletRepository.saveVtxos(addr, spentVtxos); await this.walletRepository.saveTransactions(addr, [ { key: { boardingTxid: "", commitmentTxid: "", arkTxid: arkTxid, }, amount: params.amount, type: _1.TxType.TxSent, settled: false, createdAt: Date.now(), }, ]); } catch (e) { console.warn("error saving offchain tx to repository", e); } finally { return arkTxid; } } async settle(params, eventCallback) { if (params?.inputs) { for (const input of params.inputs) { // validate arknotes 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 non expired boarding utxos and offchain vtxos as inputs // and send all to the offchain address if (!params) { let amount = 0; const exitScript = tapscript_1.CSVMultisigTapscript.decode(base_1.hex.decode(this.boardingTapscript.exitScript)); const boardingTimelock = exitScript.params.timelock; const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => !(0, arkTransaction_1.hasBoardingTxExpired)(utxo, boardingTimelock)); amount += boardingUtxos.reduce((sum, input) => sum + input.value, 0); const vtxos = await this.getVtxos({ withRecoverable: true }); 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: await this.getAddress(), amount: BigInt(amount), }, ], }; } const onchainOutputIndexes = []; const outputs = []; let hasOffchainOutputs = false; for (const [index, output] of params.outputs.entries()) { let script; try { // offchain const addr = address_1.ArkAddress.decode(output.address); script = addr.pkScript; hasOffchainOutputs = true; } catch { // onchain const addr = (0, btc_signer_1.Address)(this.network).decode(output.address); script = btc_signer_1.OutScript.encode(addr); onchainOutputIndexes.push(index); } outputs.push({ amount: output.amount, script, }); } // 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(await session.getPublicKey())); } const [intent, deleteIntent] = await Promise.all([ this.makeRegisterIntentSignature(params.inputs, outputs, onchainOutputIndexes, signingPublicKeys), this.makeDeleteIntentSignature(params.inputs), ]); const intentId = await this.safeRegisterIntent(intent); const topics = [ ...signingPublicKeys, ...params.inputs.map((input) => `${input.txid}:${input.vout}`), ]; const handler = this.createBatchHandler(intentId, params.inputs, session); const abortController = new AbortController(); try { const stream = this.arkProvider.getEventStream(abortController.signal, topics); return await batch_1.Batch.join(stream, handler, { abortController, skipVtxoTreeSigning: !hasOffchainOutputs, eventCallback: eventCallback ? (event) => Promise.resolve(eventCallback(event)) : undefined, }); } catch (error) { // delete the intent to not be stuck in the queue await this.arkProvider.deleteIntent(deleteIntent).catch(() => { }); throw error; } finally { // close the stream abortController.abort(); } } async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) { // 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.commitmentTx)); let hasBoardingUtxos = false; let connectorIndex = 0; const connectorsLeaves = connectorsGraph?.leaves() || []; for (const input of inputs) { // 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) { 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.forfeitTapLeafScript], }); settlementPsbt = await this.identity.sign(settlementPsbt, [ i, ]); hasBoardingUtxos = true; break; } continue; } if ((0, _1.isRecoverable)(vtxo) || (0, _1.isSubdust)(vtxo, this.dustAmount)) { // recoverable or subdust coin, we don't need to create a forfeit tx continue; } if (connectorsLeaves.length === 0) { throw new Error("connectors not received"); } if (connectorIndex >= connectorsLeaves.length) { throw new Error("not enough connectors received"); } const connectorLeaf = connectorsLeaves[connectorIndex]; const connectorTxId = connectorLeaf.id; const connectorOutput = connectorLeaf.getOutput(0); if (!connectorOutput) { throw new Error("connector output not found"); } const connectorAmount = connectorOutput.amount; const connectorPkScript = connectorOutput.script; if (!connectorAmount || !connectorPkScript) { throw new Error("invalid connector output"); } connectorIndex++; let forfeitTx = (0, forfeit_1.buildForfeitTx)([ { txid: input.txid, index: input.vout, witnessUtxo: { amount: BigInt(vtxo.value), script: base_2.VtxoScript.decode(input.tapTree).pkScript, }, sighashType: btc_signer_1.SigHash.DEFAULT, tapLeafScript: [input.forfeitTapLeafScript], }, { txid: connectorTxId, index: 0, witnessUtxo: { amount: connectorAmount, script: connectorPkScript, }, }, ], forfeitOutputScript); // do not sign the connector input forfeitTx = await this.identity.sign(forfeitTx, [0]); signedForfeits.push(base_1.base64.encode(forfeitTx.toPSBT())); } if (signedForfeits.length > 0 || hasBoardingUtxos) { await this.arkProvider.submitSignedForfeitTxs(signedForfeits, hasBoardingUtxos ? base_1.base64.encode(settlementPsbt.toPSBT()) : undefined); } } /** * @implements Batch.Handler interface. * @param intentId - The intent ID. * @param inputs - The inputs of the intent. * @param session - The musig2 signing session, if not provided, the signing will be skipped. */ createBatchHandler(intentId, inputs, session) { let sweepTapTreeRoot; return { onBatchStarted: async (event) => { const utf8IntentId = new TextEncoder().encode(intentId); const intentIdHash = (0, utils_js_1.sha256)(utf8IntentId); const intentIdHashStr = base_1.hex.encode(intentIdHash); let skip = true; // check if our intent ID hash matches any in the event for (const idHash of event.intentIdHashes) { if (idHash === intentIdHashStr) { if (!this.arkProvider) { throw new Error("Ark provider not configured"); } await this.arkProvider.confirmRegistration(intentId); skip = false; } } if (skip) { return { skip }; } const sweepTapscript = tapscript_1.CSVMultisigTapscript.encode({ timelock: { value: event.batchExpiry, type: event.batchExpiry >= 512n ? "seconds" : "blocks", }, pubkeys: [this.forfeitPubkey], }).script; sweepTapTreeRoot = (0, payment_js_1.tapLeafHash)(sweepTapscript); return { skip: false }; }, onTreeSigningStarted: async (event, vtxoTree) => { if (!session) { return { skip: true }; } if (!sweepTapTreeRoot) { throw new Error("Sweep tap tree root not set"); } const xOnlyPublicKeys = event.cosignersPublicKeys.map((k) => k.slice(2)); const signerPublicKey = await session.getPublicKey(); const xonlySignerPublicKey = signerPublicKey.subarray(1); if (!xOnlyPublicKeys.includes(base_1.hex.encode(xonlySignerPublicKey))) { // not a cosigner, skip the signing return { skip: true }; } // validate the unsigned vtxo tree const commitmentTx = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(event.unsignedCommitmentTx)); (0, validation_1.validateVtxoTxGraph)(vtxoTree, commitmentTx, sweepTapTreeRoot); // TODO check if our registered outputs are in the vtxo tree const sharedOutput = commitmentTx.getOutput(0); if (!sharedOutput?.amount) { throw new Error("Shared output not found"); } await session.init(vtxoTree, sweepTapTreeRoot, sharedOutput.amount); const pubkey = base_1.hex.encode(await session.getPublicKey()); const nonces = await session.getNonces(); await this.arkProvider.submitTreeNonces(event.id, pubkey, nonces); return { skip: false }; }, onTreeNonces: async (event) => { if (!session) { return { fullySigned: true }; // Signing complete (no signing needed) } const { hasAllNonces } = await session.aggregatedNonces(event.txid, event.nonces); // wait to receive and aggregate all nonces before sending signatures if (!hasAllNonces) return { fullySigned: false }; const signatures = await session.sign(); const pubkey = base_1.hex.encode(await session.getPublicKey()); await this.arkProvider.submitTreeSignatures(event.id, pubkey, signatures); return { fullySigned: true }; }, onBatchFinalization: async (event, _, connectorTree) => { if (!this.forfeitOutputScript) { throw new Error("Forfeit output script not set"); } if (connectorTree) { (0, validation_1.validateConnectorsTxGraph)(event.commitmentTx, connectorTree); } await this.handleSettlementFinalizationEvent(event, inputs, this.forfeitOutputScript, connectorTree); }, }; } async safeRegisterIntent(intent) { try { return await this.arkProvider.registerIntent(intent); } catch (error) { // catch the "already registered by another intent" error if (error instanceof errors_1.ArkError && error.code === 0 && error.message.includes("duplicated input")) { // delete all intents spending one of the wallet coins const allSpendableCoins = await this.getVtxos({ withRecoverable: true, }); const deleteIntent = await this.makeDeleteIntentSignature(allSpendableCoins); await this.arkProvider.deleteIntent(deleteIntent); // try again return this.arkProvider.registerIntent(intent); } throw error; } } async makeRegisterIntentSignature(coins, outputs, onchainOutputsIndexes, cosignerPubKeys) { const inputs = this.prepareIntentProofInputs(coins); const message = { type: "register", onchain_output_indexes: onchainOutputsIndexes, valid_at: 0, expire_at: 0, cosigners_public_keys: cosignerPubKeys, }; const proof = intent_1.Intent.create(message, inputs, outputs); const signedProof = await this.identity.sign(proof); return { proof: base_1.base64.encode(signedProof.toPSBT()), message, }; } async makeDeleteIntentSignature(coins) { const inputs = this.prepareIntentProofInputs(coins); const message = { type: "delete", expire_at: 0, }; const proof = intent_1.Intent.create(message, inputs, []); const signedProof = await this.identity.sign(proof); return { proof: base_1.base64.encode(signedProof.toPSBT()), message, }; } async makeGetPendingTxIntentSignature(vtxos) { const inputs = this.prepareIntentProofInputs(vtxos); const message = { type: "get-pending-tx", expire_at: 0, }; const proof = intent_1.Intent.create(message, inputs, []); const signedProof = await this.identity.sign(proof); return { proof: base_1.base64.encode(signedProof.toPSBT()), message, }; } /** * Finalizes pending transactions by retrieving them from the server and finalizing each one. * @param vtxos - Optional list of VTXOs to use instead of retrieving them from the server * @returns Array of transaction IDs that were finalized */ async finalizePendingTxs(vtxos) { const MAX_INPUTS_PER_INTENT = 20; if (!vtxos || vtxos.length === 0) { // get non-swept VTXOs, rely on the indexer only in case DB doesn't have the right state const scripts = [base_1.hex.encode(this.offchainTapscript.pkScript)]; let { vtxos: fetchedVtxos } = await this.indexerProvider.getVtxos({ scripts, }); fetchedVtxos = fetchedVtxos.filter((vtxo) => vtxo.virtualStatus.state !== "swept" && vtxo.virtualStatus.state !== "settled"); if (fetchedVtxos.length === 0) { return { finalized: [], pending: [] }; } vtxos = fetchedVtxos.map((v) => (0, utils_1.extendVirtualCoin)(this, v)); } const finalized = []; const pending = []; for (let i = 0; i < vtxos.length; i += MAX_INPUTS_PER_INTENT) { const batch = vtxos.slice(i, i + MAX_INPUTS_PER_INTENT); const intent = await this.makeGetPendingTxIntentSignature(batch); const pendingTxs = await this.arkProvider.getPendingTxs(intent); // finalize each transaction by signing the checkpoints for (const pendingTx of pendingTxs) { pending.push(pendingTx.arkTxid); try { // sign the checkpoints const finalCheckpoints = await Promise.all(pendingTx.signedCheckpointTxs.map(async (c) => { const tx = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(c)); const signedCheckpoint = await this.identity.sign(tx); return base_1.base64.encode(signedCheckpoint.toPSBT()); })); await this.arkProvider.finalizeTx(pendingTx.arkTxid, finalCheckpoints); finalized.push(pendingTx.arkTxid); } catch (error) { console.error(`Failed to finalize transaction ${pendingTx.arkTxid}:`, error); // continue with other transactions even if one fails } } } return { finalized, pending }; } prepareIntentProofInputs(coins) { const inputs = []; for (const input of coins) { const vtxoScript = base_2.VtxoScript.decode(input.tapTree); const sequence = getSequence(input.intentTapLeafScript); const unknown = [unknownFields_1.VtxoTaprootTree.encode(input.tapTree)]; if (input.extraWitness) { unknown.push(unknownFields_1.ConditionWitness.encode(input.extraWitness)); } inputs.push({ txid: base_1.hex.decode(input.txid), index: input.vout, witnessUtxo: { amount: BigInt(input.value), script: vtxoScript.pkScript, }, sequence, tapLeafScript: [input.intentTapLeafScript], unknown, }); } return inputs; } } exports.Wallet = Wallet; Wallet.MIN_FEE_RATE = 1; // sats/vbyte function getSequence(tapLeafScript) { let sequence = undefined; try { const scriptWithLeafVersion = tapLeafScript[1]; const script = scriptWithLeafVersion.subarray(0, scriptWithLeafVersion.length - 1); try { const params = tapscript_1.CSVMultisigTapscript.decode(script).params; sequence = bip68.encode(params.timelock.type === "blocks" ? { blocks: Number(params.timelock.value) } : { seconds: Number(params.timelock.value) }); } catch { const params = tapscript_1.CLTVMultisigTapscript.decode(script).params; sequence = Number(params.absoluteTimelock); } } catch { } return sequence; } function isValidArkAddress(address) { try { address_1.ArkAddress.decode(address); return true; } catch (e) { return false; } } /** * Select virtual coins to reach a target amount, prioritizing those closer to expiry * @param coins List of virtual coins to select from * @param targetAmount Target amount to reach in satoshis * @returns Selected coins and change amount */ function selectVirtualCoins(coins, targetAmount) { // Sort VTXOs by expiry (ascending) and amount (descending) const sortedCoins = [...coins].sort((a, b) => { // First sort by expiry if available const expiryA = a.virtualStatus.batchExpiry || Number.MAX_SAFE_INTEGER;