@arkade-os/sdk
Version:
Bitcoin wallet SDK with Taproot and Ark integration
241 lines (240 loc) • 10.7 kB
JavaScript
;
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;
}