UNPKG

@arkade-os/sdk

Version:

Bitcoin wallet SDK with Taproot and Ark integration

966 lines (965 loc) 41.3 kB
import { base64, hex } from "@scure/base"; import * as bip68 from "bip68"; import { Address, OutScript, tapLeafHash } from "@scure/btc-signer/payment"; import { SigHash, Transaction } from "@scure/btc-signer"; import { TaprootControlBlock, } from "@scure/btc-signer/psbt"; import { vtxosToTxs } from '../utils/transactionHistory.js'; import { ArkAddress } from '../script/address.js'; import { DefaultVtxo } from '../script/default.js'; import { getNetwork } from '../networks.js'; import { ESPLORA_URL, EsploraProvider, } from '../providers/onchain.js'; import { SettlementEventType, RestArkProvider, } from '../providers/ark.js'; import { buildForfeitTx } from '../forfeit.js'; import { validateConnectorsTxGraph, validateVtxoTxGraph, } from '../tree/validation.js'; import { isRecoverable, isSpendable, isSubdust, TxType, } from './index.js'; import { sha256, sha256x2 } from "@scure/btc-signer/utils"; import { VtxoScript } from '../script/base.js'; import { CSVMultisigTapscript } from '../script/tapscript.js'; import { buildOffchainTx } from '../utils/arkTransaction.js'; import { ArkNote } from '../arknote/index.js'; import { BIP322 } from '../bip322/index.js'; import { RestIndexerProvider } from '../providers/indexer.js'; import { TxTree } from '../tree/txTree.js'; /** * 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 * const wallet = await Wallet.create({ * identity: SingleKey.fromHex('your_private_key'), * arkServerUrl: '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 * }); * ``` */ export class Wallet { constructor(identity, network, networkName, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, dustAmount) { this.identity = identity; this.network = network; this.networkName = networkName; this.onchainProvider = onchainProvider; this.arkProvider = arkProvider; this.indexerProvider = indexerProvider; this.arkServerPublicKey = arkServerPublicKey; this.offchainTapscript = offchainTapscript; this.boardingTapscript = boardingTapscript; this.serverUnrollScript = serverUnrollScript; this.forfeitOutputScript = forfeitOutputScript; this.dustAmount = dustAmount; } static async create(config) { const pubkey = config.identity.xOnlyPublicKey(); if (!pubkey) { throw new Error("Invalid configured public key"); } const arkProvider = new RestArkProvider(config.arkServerUrl); const indexerProvider = new RestIndexerProvider(config.arkServerUrl); const info = await arkProvider.getInfo(); const network = getNetwork(info.network); const onchainProvider = new EsploraProvider(config.esploraUrl || ESPLORA_URL[info.network]); const exitTimelock = { value: info.unilateralExitDelay, type: info.unilateralExitDelay < 512n ? "blocks" : "seconds", }; const boardingTimelock = { value: info.boardingExitDelay, type: info.boardingExitDelay < 512n ? "blocks" : "seconds", }; // Generate tapscripts for offchain and boarding address const serverPubKey = hex.decode(info.signerPubkey).slice(1); const bareVtxoTapscript = new DefaultVtxo.Script({ pubKey: pubkey, serverPubKey, csvTimelock: exitTimelock, }); const boardingTapscript = new DefaultVtxo.Script({ pubKey: pubkey, serverPubKey, csvTimelock: boardingTimelock, }); // Save tapscripts const offchainTapscript = bareVtxoTapscript; // the serverUnrollScript is the one used to create output scripts of the checkpoint transactions const serverUnrollScript = CSVMultisigTapscript.encode({ timelock: exitTimelock, pubkeys: [serverPubKey], }); // parse the server forfeit address // server is expecting funds to be sent to this address const forfeitAddress = Address(network).decode(info.forfeitAddress); const forfeitOutputScript = OutScript.encode(forfeitAddress); return new Wallet(config.identity, network, info.network, onchainProvider, arkProvider, indexerProvider, serverPubKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, info.dust); } 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) => 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 spendableVtxos = await this.getVirtualCoins(filter); const encodedOffchainTapscript = this.offchainTapscript.encode(); const forfeit = this.offchainTapscript.forfeit(); const exit = this.offchainTapscript.exit(); return spendableVtxos.map((vtxo) => ({ ...vtxo, forfeitTapLeafScript: forfeit, intentTapLeafScript: exit, tapTree: encodedOffchainTapscript, })); } async getVirtualCoins(filter = { withRecoverable: true, withUnrolled: false }) { const scripts = [hex.encode(this.offchainTapscript.pkScript)]; const response = await this.indexerProvider.getVtxos({ scripts, spendableOnly: true, }); const vtxos = response.vtxos; if (filter.withRecoverable) { const response = await this.indexerProvider.getVtxos({ scripts, recoverableOnly: true, }); vtxos.push(...response.vtxos); } if (filter.withUnrolled) { const response = await this.indexerProvider.getVtxos({ scripts, spentOnly: true, }); vtxos.push(...response.vtxos.filter((vtxo) => vtxo.isUnrolled)); } return vtxos; } async getTransactionHistory() { if (!this.indexerProvider) { return []; } const response = await this.indexerProvider.getVtxos({ scripts: [hex.encode(this.offchainTapscript.pkScript)], }); const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs(); const spendableVtxos = []; const spentVtxos = []; for (const vtxo of response.vtxos) { if (isSpendable(vtxo)) { spendableVtxos.push(vtxo); } else { spentVtxos.push(vtxo); } } // convert VTXOs to offchain transactions const offchainTxs = 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 boardingAddress = await this.getBoardingAddress(); const txs = await this.onchainProvider.getTransactions(boardingAddress); const utxos = []; const commitmentsToIgnore = 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) { 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: 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 encodedBoardingTapscript = this.boardingTapscript.encode(); const forfeit = this.boardingTapscript.forfeit(); const exit = this.boardingTapscript.exit(); return boardingUtxos.map((utxo) => ({ ...utxo, forfeitTapLeafScript: forfeit, intentTapLeafScript: exit, tapTree: encodedBoardingTapscript, })); } 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 = 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(); let offchainTx = 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(base64.encode(signedVirtualTx.toPSBT()), offchainTx.checkpoints.map((c) => base64.encode(c.toPSBT()))); // TODO persist final virtual tx and checkpoints to repository // sign the checkpoints const finalCheckpoints = await Promise.all(signedCheckpointTxs.map(async (c) => { const tx = Transaction.fromPSBT(base64.decode(c)); const signedCheckpoint = await this.identity.sign(tx); return base64.encode(signedCheckpoint.toPSBT()); })); await this.arkProvider.finalizeTx(arkTxid, finalCheckpoints); return arkTxid; } async settle(params, eventCallback) { if (params?.inputs) { for (const input of params.inputs) { // validate arknotes inputs if (typeof input === "string") { try { 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) { 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: 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 = ArkAddress.decode(output.address); script = addr.pkScript; hasOffchainOutputs = true; } catch { // onchain const addr = Address(this.network).decode(output.address); script = 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(hex.encode(session.getPublicKey())); } const [intent, deleteIntent] = await Promise.all([ this.makeRegisterIntentSignature(params.inputs, outputs, onchainOutputIndexes, signingPublicKeys), this.makeDeleteIntentSignature(params.inputs), ]); const intentId = await this.arkProvider.registerIntent(intent); const abortController = new AbortController(); // listen to settlement events try { let step; const topics = [ ...signingPublicKeys, ...params.inputs.map((input) => `${input.txid}:${input.vout}`), ]; const settlementStream = this.arkProvider.getEventStream(abortController.signal, topics); // roundId, sweepTapTreeRoot and forfeitOutputScript are set once the BatchStarted event is received let roundId; let sweepTapTreeRoot; const vtxoChunks = []; const connectorsChunks = []; let vtxoGraph; let connectorsGraph; for await (const event of settlementStream) { if (eventCallback) { eventCallback(event); } switch (event.type) { // the settlement failed case SettlementEventType.BatchFailed: // fail if the roundId is the one joined if (event.id === roundId) { throw new Error(event.reason); } break; case SettlementEventType.BatchStarted: if (step !== undefined) { continue; } const res = await this.handleBatchStartedEvent(event, intentId, this.arkServerPublicKey, this.forfeitOutputScript); if (!res.skip) { step = event.type; sweepTapTreeRoot = res.sweepTapTreeRoot; roundId = res.roundId; 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 = SettlementEventType.TreeNoncesAggregated; } } break; case SettlementEventType.TreeTx: if (step !== SettlementEventType.BatchStarted && step !== SettlementEventType.TreeNoncesAggregated) { continue; } // index 0 = vtxo tree if (event.batchIndex === 0) { vtxoChunks.push(event.chunk); // index 1 = connectors tree } else if (event.batchIndex === 1) { connectorsChunks.push(event.chunk); } else { throw new Error(`Invalid batch index: ${event.batchIndex}`); } break; case SettlementEventType.TreeSignature: if (step !== SettlementEventType.TreeNoncesAggregated) { continue; } if (!hasOffchainOutputs) { continue; } if (!vtxoGraph) { throw new Error("Vtxo graph not set, something went wrong"); } // index 0 = vtxo graph if (event.batchIndex === 0) { const tapKeySig = hex.decode(event.signature); vtxoGraph.update(event.txid, (tx) => { tx.updateInput(0, { tapKeySig, }); }); } break; // the server has started the signing process of the vtxo tree transactions // the server expects the partial musig2 nonces for each tx case SettlementEventType.TreeSigningStarted: if (step !== SettlementEventType.BatchStarted) { continue; } if (hasOffchainOutputs) { if (!session) { throw new Error("Signing session not set"); } if (!sweepTapTreeRoot) { throw new Error("Sweep tap tree root not set"); } if (vtxoChunks.length === 0) { throw new Error("unsigned vtxo graph not received"); } vtxoGraph = TxTree.create(vtxoChunks); await this.handleSettlementSigningEvent(event, sweepTapTreeRoot, session, vtxoGraph); } step = event.type; break; // the musig2 nonces of the vtxo tree transactions are generated // the server expects now the partial musig2 signatures case SettlementEventType.TreeNoncesAggregated: if (step !== SettlementEventType.TreeSigningStarted) { continue; } if (hasOffchainOutputs) { if (!session) { throw new Error("Signing session not set"); } await this.handleSettlementSigningNoncesGeneratedEvent(event, session); } step = event.type; 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 SettlementEventType.BatchFinalization: if (step !== SettlementEventType.TreeNoncesAggregated) { continue; } if (!this.forfeitOutputScript) { throw new Error("Forfeit output script not set"); } if (connectorsChunks.length > 0) { connectorsGraph = TxTree.create(connectorsChunks); validateConnectorsTxGraph(event.commitmentTx, connectorsGraph); } await this.handleSettlementFinalizationEvent(event, params.inputs, this.forfeitOutputScript, connectorsGraph); step = event.type; break; // the settlement is done, last event to be received case SettlementEventType.BatchFinalized: if (step !== SettlementEventType.BatchFinalization) { continue; } abortController.abort(); return event.commitmentTxid; } } } catch (error) { // close the stream abortController.abort(); try { // delete the intent to not be stuck in the queue await this.arkProvider.deleteIntent(deleteIntent); } catch { } throw error; } throw new Error("Settlement failed"); } async notifyIncomingFunds(eventCallback) { const arkAddress = await this.getAddress(); const boardingAddress = await this.getBoardingAddress(); let onchainStopFunc; let indexerStopFunc; if (this.onchainProvider && boardingAddress) { onchainStopFunc = await this.onchainProvider.watchAddresses([boardingAddress], (txs) => { const coins = txs .map((tx) => { const vout = tx.vout.findIndex((v) => v.scriptpubkey_address === boardingAddress); if (vout === -1) { console.warn(`No vout found for address ${boardingAddress} in transaction ${tx.txid}`); return null; } return { txid: tx.txid, vout, value: Number(tx.vout[vout].value), status: tx.status, }; }) .filter((coin) => coin !== null); eventCallback({ type: "utxo", coins, }); }); } if (this.indexerProvider && arkAddress) { const offchainScript = this.offchainTapscript; const subscriptionId = await this.indexerProvider.subscribeForScripts([ 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) { eventCallback({ type: "vtxo", vtxos: update.newVtxos, }); } } } catch (error) { console.error("Subscription error:", error); } })(); } const stopFunc = () => { onchainStopFunc?.(); indexerStopFunc?.(); }; return stopFunc; } async handleBatchStartedEvent(event, intentId, serverPubKey, forfeitOutputScript) { const utf8IntentId = new TextEncoder().encode(intentId); const intentIdHash = sha256(utf8IntentId); const intentIdHashStr = hex.encode(new Uint8Array(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 = CSVMultisigTapscript.encode({ timelock: { value: event.batchExpiry, type: event.batchExpiry >= 512n ? "seconds" : "blocks", }, pubkeys: [serverPubKey], }).script; const sweepTapTreeRoot = tapLeafHash(sweepTapscript); return { roundId: event.id, sweepTapTreeRoot, forfeitOutputScript, skip: false, }; } // validates the vtxo tree, creates a signing session and generates the musig2 nonces async handleSettlementSigningEvent(event, sweepTapTreeRoot, session, vtxoGraph) { // validate the unsigned vtxo tree const commitmentTx = Transaction.fromPSBT(base64.decode(event.unsignedCommitmentTx)); validateVtxoTxGraph(vtxoGraph, 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"); } session.init(vtxoGraph, sweepTapTreeRoot, sharedOutput.amount); await this.arkProvider.submitTreeNonces(event.id, hex.encode(session.getPublicKey()), session.getNonces()); } async handleSettlementSigningNoncesGeneratedEvent(event, session) { session.setAggregatedNonces(event.treeNonces); const signatures = session.sign(); await this.arkProvider.submitTreeSignatures(event.id, hex.encode(session.getPublicKey()), signatures); } async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) { // the signed forfeits transactions to submit const signedForfeits = []; const vtxos = await this.getVirtualCoins(); let settlementPsbt = Transaction.fromPSBT(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) { 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 = 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], }); inputIndexes.push(i); } settlementPsbt = await this.identity.sign(settlementPsbt, inputIndexes); continue; } if (isRecoverable(vtxo) || 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 = hex.encode(sha256x2(connectorLeaf.toBytes(true)).reverse()); 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 = buildForfeitTx([ { txid: input.txid, index: input.vout, witnessUtxo: { amount: BigInt(vtxo.value), script: VtxoScript.decode(input.tapTree).pkScript, }, sighashType: 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(base64.encode(forfeitTx.toPSBT())); } if (signedForfeits.length > 0 || hasBoardingUtxos) { await this.arkProvider.submitSignedForfeitTxs(signedForfeits, hasBoardingUtxos ? base64.encode(settlementPsbt.toPSBT()) : undefined); } } async makeRegisterIntentSignature(bip322Inputs, outputs, onchainOutputsIndexes, cosignerPubKeys) { const nowSeconds = Math.floor(Date.now() / 1000); const { inputs, inputTapTrees, finalizer } = this.prepareBIP322Inputs(bip322Inputs); const message = { type: "register", input_tap_trees: inputTapTrees, onchain_output_indexes: onchainOutputsIndexes, valid_at: nowSeconds, expire_at: nowSeconds + 2 * 60, // valid for 2 minutes cosigners_public_keys: cosignerPubKeys, }; const encodedMessage = JSON.stringify(message, null, 0); const signature = await this.makeBIP322Signature(encodedMessage, inputs, finalizer, outputs); return { signature, message: encodedMessage, }; } async makeDeleteIntentSignature(bip322Inputs) { const nowSeconds = Math.floor(Date.now() / 1000); const { inputs, finalizer } = this.prepareBIP322Inputs(bip322Inputs); const message = { type: "delete", expire_at: nowSeconds + 2 * 60, // valid for 2 minutes }; const encodedMessage = JSON.stringify(message, null, 0); const signature = await this.makeBIP322Signature(encodedMessage, inputs, finalizer); return { signature, message: encodedMessage, }; } prepareBIP322Inputs(bip322Inputs) { const inputs = []; const inputTapTrees = []; const inputExtraWitnesses = []; for (const bip322Input of bip322Inputs) { const vtxoScript = VtxoScript.decode(bip322Input.tapTree); const sequence = getSequence(bip322Input); inputs.push({ txid: hex.decode(bip322Input.txid), index: bip322Input.vout, witnessUtxo: { amount: BigInt(bip322Input.value), script: vtxoScript.pkScript, }, sequence, tapLeafScript: [bip322Input.intentTapLeafScript], }); inputTapTrees.push(hex.encode(bip322Input.tapTree)); inputExtraWitnesses.push(bip322Input.extraWitness || []); } return { inputs, inputTapTrees, finalizer: finalizeWithExtraWitnesses(inputExtraWitnesses), }; } async makeBIP322Signature(message, inputs, finalizer, outputs) { const proof = BIP322.create(message, inputs, outputs); const signedProof = await this.identity.sign(proof); return BIP322.signature(signedProof, finalizer); } } Wallet.MIN_FEE_RATE = 1; // sats/vbyte function finalizeWithExtraWitnesses(inputExtraWitnesses) { return function (tx) { for (let i = 0; i < tx.inputsLength; i++) { try { tx.finalizeIdx(i); } catch (e) { // handle empty witness error if (e instanceof Error && e.message.includes("finalize/taproot: empty witness")) { const tapLeaves = tx.getInput(i).tapLeafScript; if (!tapLeaves || tapLeaves.length <= 0) throw e; const [cb, s] = tapLeaves[0]; const script = s.slice(0, -1); tx.updateInput(i, { finalScriptWitness: [ script, TaprootControlBlock.encode(cb), ], }); } } const finalScriptWitness = tx.getInput(i).finalScriptWitness; if (!finalScriptWitness) throw new Error("input not finalized"); // input 0 and 1 spend the same pkscript const extra = inputExtraWitnesses[i === 0 ? 0 : i - 1]; if (extra && extra.length > 0) { tx.updateInput(i, { finalScriptWitness: [...extra, ...finalScriptWitness], }); } } }; } function getSequence(bip322Input) { let sequence = undefined; try { const scriptWithLeafVersion = bip322Input.intentTapLeafScript[1]; const script = scriptWithLeafVersion.subarray(0, scriptWithLeafVersion.length - 1); const params = CSVMultisigTapscript.decode(script).params; sequence = bip68.encode(params.timelock.type === "blocks" ? { blocks: Number(params.timelock.value) } : { seconds: Number(params.timelock.value) }); } catch { } return sequence; } function isValidArkAddress(address) { try { 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; const expiryB = b.virtualStatus.batchExpiry || Number.MAX_SAFE_INTEGER; if (expiryA !== expiryB) { return expiryA - expiryB; // Earlier expiry first } // Then sort by amount return b.value - a.value; // Larger amount first }); const selectedCoins = []; let selectedAmount = 0; // Select coins until we have enough for (const coin of sortedCoins) { selectedCoins.push(coin); selectedAmount += coin.value; if (selectedAmount >= targetAmount) { break; } } if (selectedAmount === targetAmount) { return { inputs: selectedCoins, changeAmount: 0n }; } // Check if we have enough if (selectedAmount < targetAmount) { throw new Error("Insufficient funds"); } const changeAmount = BigInt(selectedAmount - targetAmount); return { inputs: selectedCoins, changeAmount, }; } /** * Wait for incoming funds to the wallet * @param wallet - The wallet to wait for incoming funds * @returns A promise that resolves the next new coins received by the wallet's address */ export async function waitForIncomingFunds(wallet) { let stopFunc; const promise = new Promise((resolve) => { wallet .notifyIncomingFunds((coins) => { resolve(coins); if (stopFunc) stopFunc(); }) .then((stop) => { stopFunc = stop; }); }); return promise; }