UNPKG

@arkade-os/sdk

Version:

Bitcoin wallet SDK with Taproot and Ark integration

241 lines (240 loc) 10.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.buildOffchainTx = buildOffchainTx; exports.hasBoardingTxExpired = hasBoardingTxExpired; exports.verifyTapscriptSignatures = verifyTapscriptSignatures; exports.combineTapscriptSigs = combineTapscriptSigs; const secp256k1_js_1 = require("@noble/curves/secp256k1.js"); const base_1 = require("@scure/base"); const btc_signer_1 = require("@scure/btc-signer"); const payment_js_1 = require("@scure/btc-signer/payment.js"); const tapscript_1 = require("../script/tapscript"); const base_2 = require("../script/base"); const anchor_1 = require("./anchor"); const unknownFields_1 = require("./unknownFields"); const transaction_1 = require("./transaction"); /** * Builds an offchain transaction with checkpoint transactions. * * Creates one checkpoint transaction per input and a virtual transaction that * combines all the checkpoints, sending to the specified outputs. This is the * core function for creating Ark transactions. * * @param inputs - Array of virtual transaction inputs * @param outputs - Array of transaction outputs * @param serverUnrollScript - Server unroll script for checkpoint transactions * @returns Object containing the virtual transaction and checkpoint transactions */ function buildOffchainTx(inputs, outputs, serverUnrollScript) { let hasOpReturn = false; for (const [index, output] of outputs.entries()) { if (!output.script) throw new Error(`missing output script ${index}`); const isOpReturn = btc_signer_1.Script.decode(output.script)[0] === "RETURN"; if (!isOpReturn) continue; if (hasOpReturn) throw new Error("multiple OP_RETURN outputs"); hasOpReturn = true; } const checkpoints = inputs.map((input) => buildCheckpointTx(input, serverUnrollScript)); const arkTx = buildVirtualTx(checkpoints.map((c) => c.input), outputs); return { arkTx, checkpoints: checkpoints.map((c) => c.tx), }; } function buildVirtualTx(inputs, outputs) { let lockTime = 0n; for (const input of inputs) { const tapscript = (0, tapscript_1.decodeTapscript)((0, base_2.scriptFromTapLeafScript)(input.tapLeafScript)); if (tapscript_1.CLTVMultisigTapscript.is(tapscript)) { if (lockTime !== 0n) { // if a locktime is already set, check if the new locktime is in the same unit if (isSeconds(lockTime) !== isSeconds(tapscript.params.absoluteTimelock)) { throw new Error("cannot mix seconds and blocks locktime"); } } if (tapscript.params.absoluteTimelock > lockTime) { lockTime = tapscript.params.absoluteTimelock; } } } const tx = new transaction_1.Transaction({ version: 3, lockTime: Number(lockTime), }); for (const [i, input] of inputs.entries()) { tx.addInput({ txid: input.txid, index: input.vout, sequence: lockTime ? btc_signer_1.DEFAULT_SEQUENCE - 1 : undefined, witnessUtxo: { script: base_2.VtxoScript.decode(input.tapTree).pkScript, amount: BigInt(input.value), }, tapLeafScript: [input.tapLeafScript], }); (0, unknownFields_1.setArkPsbtField)(tx, i, unknownFields_1.VtxoTaprootTree, input.tapTree); } for (const output of outputs) { tx.addOutput(output); } // add the anchor output tx.addOutput(anchor_1.P2A); return tx; } function buildCheckpointTx(vtxo, serverUnrollScript) { // create the checkpoint vtxo script from collaborative closure const collaborativeClosure = (0, tapscript_1.decodeTapscript)((0, base_2.scriptFromTapLeafScript)(vtxo.tapLeafScript)); // create the checkpoint vtxo script combining collaborative closure and server unroll script const checkpointVtxoScript = new base_2.VtxoScript([ serverUnrollScript.script, collaborativeClosure.script, ]); // build the checkpoint virtual tx const checkpointTx = buildVirtualTx([vtxo], [ { amount: BigInt(vtxo.value), script: checkpointVtxoScript.pkScript, }, ]); // get the collaborative leaf proof const collaborativeLeafProof = checkpointVtxoScript.findLeaf(base_1.hex.encode(collaborativeClosure.script)); // create the checkpoint input that will be used as input of the virtual tx const checkpointInput = { txid: checkpointTx.id, vout: 0, value: vtxo.value, tapLeafScript: collaborativeLeafProof, tapTree: checkpointVtxoScript.encode(), }; return { tx: checkpointTx, input: checkpointInput, }; } const nLocktimeMinSeconds = 500000000n; function isSeconds(locktime) { return locktime >= nLocktimeMinSeconds; } function hasBoardingTxExpired(coin, boardingTimelock) { if (!coin.status.block_time) return false; if (boardingTimelock.value === 0n) return true; if (boardingTimelock.type === "blocks") return false; // TODO: handle get chain tip // validate expiry in terms of seconds const now = BigInt(Math.floor(Date.now() / 1000)); const blockTime = BigInt(Math.floor(coin.status.block_time)); return blockTime + boardingTimelock.value <= now; } /** * Formats a sighash type as a hex string (e.g., 0x01) */ function formatSighash(type) { return `0x${type.toString(16).padStart(2, "0")}`; } /** * Verify tapscript signatures on a transaction input * @param tx Transaction to verify * @param inputIndex Index of the input to verify * @param requiredSigners List of required signer pubkeys (hex encoded) * @param excludePubkeys List of pubkeys to exclude from verification (hex encoded, e.g., server key not yet signed) * @param allowedSighashTypes List of allowed sighash types (defaults to [SigHash.DEFAULT]) * @throws Error if verification fails */ function verifyTapscriptSignatures(tx, inputIndex, requiredSigners, excludePubkeys = [], allowedSighashTypes = [btc_signer_1.SigHash.DEFAULT]) { const input = tx.getInput(inputIndex); // Collect prevout scripts and amounts for ALL inputs (required for preimageWitnessV1) const prevoutScripts = []; const prevoutAmounts = []; for (let i = 0; i < tx.inputsLength; i++) { const inp = tx.getInput(i); if (!inp.witnessUtxo) { throw new Error(`Input ${i} is missing witnessUtxo`); } prevoutScripts.push(inp.witnessUtxo.script); prevoutAmounts.push(inp.witnessUtxo.amount); } // Verify tapScriptSig signatures if (!input.tapScriptSig || input.tapScriptSig.length === 0) { throw new Error(`Input ${inputIndex} is missing tapScriptSig`); } // Verify each signature in tapScriptSig for (const [tapScriptSigData, signature] of input.tapScriptSig) { const pubKey = tapScriptSigData.pubKey; const pubKeyHex = base_1.hex.encode(pubKey); // Skip verification for excluded pubkeys if (excludePubkeys.includes(pubKeyHex)) { continue; } // Extract sighash type from signature // Schnorr signatures are 64 bytes, with optional 1-byte sighash appended const sighashType = signature.length === 65 ? signature[64] : btc_signer_1.SigHash.DEFAULT; const sig = signature.subarray(0, 64); // Verify sighash type is allowed if (!allowedSighashTypes.includes(sighashType)) { const sighashName = formatSighash(sighashType); throw new Error(`Unallowed sighash type ${sighashName} for input ${inputIndex}, pubkey ${pubKeyHex}.`); } // Find the tapLeafScript that matches this signature's leafHash if (!input.tapLeafScript || input.tapLeafScript.length === 0) { throw new Error(); } // Search for the leaf that matches the leafHash in tapScriptSigData const leafHash = tapScriptSigData.leafHash; const leafHashHex = base_1.hex.encode(leafHash); let matchingScript; let matchingVersion; for (const [_, scriptWithVersion] of input.tapLeafScript) { const script = scriptWithVersion.subarray(0, -1); const version = scriptWithVersion[scriptWithVersion.length - 1]; // Compute the leaf hash for this script and compare as hex strings const computedLeafHash = (0, payment_js_1.tapLeafHash)(script, version); const computedHex = base_1.hex.encode(computedLeafHash); if (computedHex === leafHashHex) { matchingScript = script; matchingVersion = version; break; } } if (!matchingScript || matchingVersion === undefined) { throw new Error(`Input ${inputIndex}: No tapLeafScript found matching leafHash ${base_1.hex.encode(leafHash)}`); } // Reconstruct the message that was signed // Note: preimageWitnessV1 requires ALL input prevout scripts and amounts const message = tx.preimageWitnessV1(inputIndex, prevoutScripts, sighashType, prevoutAmounts, undefined, matchingScript, matchingVersion); // Verify the schnorr signature const isValid = secp256k1_js_1.schnorr.verify(sig, message, pubKey); if (!isValid) { throw new Error(`Invalid signature for input ${inputIndex}, pubkey ${pubKeyHex}`); } } // Verify we have signatures from all required signers (excluding those we're skipping) const signedPubkeys = input.tapScriptSig.map(([data]) => base_1.hex.encode(data.pubKey)); const requiredNotExcluded = requiredSigners.filter((pk) => !excludePubkeys.includes(pk)); const missingSigners = requiredNotExcluded.filter((pk) => !signedPubkeys.includes(pk)); if (missingSigners.length > 0) { throw new Error(`Missing signatures from: ${missingSigners.map((pk) => pk.slice(0, 16)).join(", ")}...`); } } /** * Merges the signed transaction with the original transaction * @param signedTx signed transaction * @param originalTx original transaction */ function combineTapscriptSigs(signedTx, originalTx) { for (let i = 0; i < signedTx.inputsLength; i++) { const input = originalTx.getInput(i); const signedInput = signedTx.getInput(i); if (!input.tapScriptSig) throw new Error("No tapScriptSig"); originalTx.updateInput(i, { tapScriptSig: input.tapScriptSig?.concat(signedInput.tapScriptSig), }); } return originalTx; }