diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index 890a6b32a50..e69b5548502 100644 --- a/packages/wasm-utxo/Cargo.lock +++ b/packages/wasm-utxo/Cargo.lock @@ -1675,7 +1675,7 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniscript" version = "13.0.0" -source = "git+https://github.com/BitGo/rust-miniscript?tag=miniscript-13.0.0-bitgo.2#25595dc04a51240cf8dfb6df21d3c6bef84089b9" +source = "git+https://github.com/BitGo/rust-miniscript?tag=miniscript-13.0.0-bitgo.5#d4f80008dc31f6a8512ccce260709808cc34d483" dependencies = [ "bech32", "bitcoin", diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index 20e1bd727f5..f5a0b21d4c7 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -27,7 +27,7 @@ inspect = ["dep:num-bigint", "dep:serde", "dep:serde_json", "dep:hex"] [dependencies] wasm-bindgen = "0.2" js-sys = "0.3" -miniscript = { git = "https://github.com/BitGo/rust-miniscript", tag = "miniscript-13.0.0-bitgo.2" } +miniscript = { git = "https://github.com/BitGo/rust-miniscript", tag = "miniscript-13.0.0-bitgo.5" } bech32 = "0.11" musig2 = { version = "0.3.1", default-features = false, features = ["k256"] } getrandom = { version = "0.2", features = ["js"] } diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index c9c1ea1ad22..2db503805b1 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -38,6 +38,16 @@ export type DescriptorPkType = "derivable" | "definite" | "string"; export type ScriptContext = "tap" | "segwitv0" | "legacy"; +export interface ExtParamsConfig { + drop?: boolean; + topUnsafe?: boolean; + resourceLimitations?: boolean; + timelockMixing?: boolean; + malleability?: boolean; + repeatedPk?: boolean; + rawPkh?: boolean; +} + declare module "./wasm/wasm_utxo.js" { interface WrapDescriptor { /** These are not the same types of nodes as in the ast module */ @@ -48,6 +58,11 @@ declare module "./wasm/wasm_utxo.js" { namespace WrapDescriptor { function fromString(descriptor: string, pkType: DescriptorPkType): WrapDescriptor; function fromStringDetectType(descriptor: string): WrapDescriptor; + function fromStringExt( + descriptor: string, + pkType: "definite", + extParams: ExtParamsConfig, + ): WrapDescriptor; } interface WrapMiniscript { diff --git a/packages/wasm-utxo/src/wasm/descriptor.rs b/packages/wasm-utxo/src/wasm/descriptor.rs index ae876e25e18..7c0eb19bbc8 100644 --- a/packages/wasm-utxo/src/wasm/descriptor.rs +++ b/packages/wasm-utxo/src/wasm/descriptor.rs @@ -1,8 +1,10 @@ use crate::error::WasmUtxoError; +use crate::wasm::try_from_js_value::get_field; use crate::wasm::try_into_js_value::TryIntoJsValue; use miniscript::bitcoin::secp256k1::{Secp256k1, Signing}; use miniscript::bitcoin::ScriptBuf; use miniscript::descriptor::KeyMap; +use miniscript::miniscript::analyzable::ExtParams; use miniscript::{DefiniteDescriptorKey, Descriptor, DescriptorPublicKey}; use std::fmt; use std::str::FromStr; @@ -111,12 +113,16 @@ impl WrapDescriptor { secp: &Secp256k1, descriptor: &str, ) -> Result { - let (desc, keys) = Descriptor::parse_descriptor(secp, descriptor)?; + let (desc, keys) = + Descriptor::parse_descriptor_ext(secp, descriptor, &ExtParams::sane().drop())?; Ok(WrapDescriptor(WrapDescriptorEnum::Derivable(desc, keys))) } fn from_string_definite(descriptor: &str) -> Result { - let desc = Descriptor::::from_str(descriptor)?; + let desc = Descriptor::::from_str_ext( + descriptor, + &ExtParams::sane().drop(), + )?; Ok(WrapDescriptor(WrapDescriptorEnum::Definite(desc))) } @@ -149,13 +155,77 @@ impl WrapDescriptor { "derivable" => WrapDescriptor::from_string_derivable(&Secp256k1::new(), descriptor), "definite" => WrapDescriptor::from_string_definite(descriptor), "string" => { - let desc = Descriptor::::from_str(descriptor)?; + let desc = + Descriptor::::from_str_ext(descriptor, &ExtParams::sane().drop())?; Ok(WrapDescriptor(WrapDescriptorEnum::String(desc))) } _ => Err(WasmUtxoError::new("Invalid descriptor type")), } } + /// Parse a descriptor string with custom ExtParams for taproot leaf validation. + /// + /// This allows control over which miniscript analysis checks are applied to + /// taproot leaves. The `drop` flag is always enabled; other flags default to false. + /// + /// # Arguments + /// * `descriptor` - A string containing the descriptor to parse + /// * `pk_type` - The type of public key ("definite" only for now) + /// * `ext_params_config` - JavaScript object with optional boolean flags: + /// - `drop`: Allow drop operations (r: wrapper) — always enabled + /// - `topUnsafe`: Allow scripts without signatures on all paths + /// - `resourceLimitations`: Allow scripts exceeding resource limits + /// - `timelockMixing`: Allow CSV + CLTV mixing + /// - `malleability`: Allow malleable scripts + /// - `repeatedPk`: Allow repeated public keys + /// - `rawPkh`: Allow raw pubkey hash fragments + /// + /// # Example + /// ```javascript + /// // r:older() is always allowed; add extra flags as needed + /// Descriptor.fromStringExt(desc, "definite", { malleability: true }) + /// ``` + #[wasm_bindgen(js_name = fromStringExt, skip_typescript)] + pub fn from_string_ext( + descriptor: &str, + pk_type: &str, + ext_params_config: JsValue, + ) -> Result { + let flag = |key| -> Result { + Ok(get_field::>(&ext_params_config, key)?.unwrap_or(false)) + }; + + let mut params = ExtParams::sane().drop(); + if flag("topUnsafe")? { + params = params.top_unsafe(); + } + if flag("resourceLimitations")? { + params = params.exceed_resource_limitations(); + } + if flag("timelockMixing")? { + params = params.timelock_mixing(); + } + if flag("malleability")? { + params = params.malleability(); + } + if flag("repeatedPk")? { + params = params.repeated_pk(); + } + if flag("rawPkh")? { + params = params.raw_pkh(); + } + + match pk_type { + "definite" => { + let desc = Descriptor::::from_str_ext(descriptor, ¶ms)?; + Ok(WrapDescriptor(WrapDescriptorEnum::Definite(desc))) + } + _ => Err(WasmUtxoError::new( + "fromStringExt only supports 'definite' pk_type", + )), + } + } + /// Parse a descriptor string, automatically detecting the appropriate public key type. /// This will check if the descriptor contains wildcards to determine if it should be /// parsed as derivable or definite. diff --git a/packages/wasm-utxo/src/wasm/try_from_js_value.rs b/packages/wasm-utxo/src/wasm/try_from_js_value.rs index 0e26894e503..cb6bb426125 100644 --- a/packages/wasm-utxo/src/wasm/try_from_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_from_js_value.rs @@ -78,6 +78,14 @@ impl TryFromJsValue for u8 { } } +impl TryFromJsValue for bool { + fn try_from_js_value(value: &JsValue) -> Result { + value + .as_bool() + .ok_or_else(|| WasmUtxoError::new("Expected a boolean")) + } +} + impl TryFromJsValue for u32 { fn try_from_js_value(value: &JsValue) -> Result { value diff --git a/packages/wasm-utxo/src/wasm/try_into_js_value.rs b/packages/wasm-utxo/src/wasm/try_into_js_value.rs index 5592b45e506..9ba6bba355f 100644 --- a/packages/wasm-utxo/src/wasm/try_into_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_into_js_value.rs @@ -203,6 +203,9 @@ impl TryIntoJsValue for Terminal::Thresh(t) => js_obj!("Thresh" => t), Terminal::Multi(pks) => js_obj!("Multi" => pks), Terminal::MultiA(pks) => js_obj!("MultiA" => pks), + Terminal::PayloadDrop(payload) => { + js_obj!("PayloadDrop" => payload.iter().map(|b| format!("{:02x}", b)).collect::()) + } } } } diff --git a/packages/wasm-utxo/test/sbtc.ts b/packages/wasm-utxo/test/sbtc.ts new file mode 100644 index 00000000000..c08bf7918c5 --- /dev/null +++ b/packages/wasm-utxo/test/sbtc.ts @@ -0,0 +1,157 @@ +import * as assert from "assert"; +import * as crypto from "crypto"; +import { Descriptor } from "../js/index.js"; +import { getUnspendableKey } from "../js/testutils/descriptor/descriptors.js"; + +// sBTC protocol uses two taproot script leaves: +// 1. Deposit leaf: allows the signers to spend with a protocol payload +// 2. Reclaim leaf: allows the depositors to reclaim after a timelock + +const SIGNERS_KEY = "c9c2312ca406dcb8eed50b829b5292f5fb3e846db0a556af61cc53834ce75421"; + +// BIP341 "nothing up my sleeve" unspendable internal key — used so the taproot address +// can only be spent via script path (no key-path spend). +const UNSPENDABLE_KEY = getUnspendableKey(); + +const DEPOSIT_LEAF = + "c:and_v(payload_drop(" + + "0000000000013880051ad206838b7981a116c334e8cb1b950afb73eb54a5" + + "),pk_k(" + + SIGNERS_KEY + + "))"; + +const RECLAIM_LEAF = + "and_v(r:older(1),multi_a(2," + + "4d838759b2a74616a2298e0580ca815874f5e5a9d2dd1b2f0203b68c66fc6c1e," + + "639779c4b700dc51ece012a0e20325fcafada22a4a122ffaa04d0c0ccae83943," + + "d1d6084eac98303e9d28e082bfd9eadf0b8be033e223a17ad01df81bdaa8c7b2))"; + +// Reference vectors from rust-miniscript test_payload_drop_stacks_vectors. +// Deposit leaf: OP_PUSHBYTES_30 OP_DROP OP_PUSHBYTES_32 OP_CHECKSIG +const DEPOSIT_SCRIPT_HEX = + "1e0000000000013880051ad206838b7981a116c334e8cb1b950afb73eb54a5" + + "7520c9c2312ca406dcb8eed50b829b5292f5fb3e846db0a556af61cc53834ce75421ac"; +const DEPOSIT_LEAF_HASH = "b14bbf1c6699b64429be4f11e1d4df7b75f16f68e7a86cb91c58daf024d0b379"; +// Reclaim leaf: OP_1 OP_CSV OP_DROP + 2-of-3 multi_a +const RECLAIM_SCRIPT_HEX = + "51b275" + + "204d838759b2a74616a2298e0580ca815874f5e5a9d2dd1b2f0203b68c66fc6c1eac" + + "20639779c4b700dc51ece012a0e20325fcafada22a4a122ffaa04d0c0ccae83943ba" + + "20d1d6084eac98303e9d28e082bfd9eadf0b8be033e223a17ad01df81bdaa8c7b2ba529c"; +const RECLAIM_LEAF_HASH = "1e379caf8335dc3bd0af785d32d8135647ffa2ee76dd2c1bcc663ff424602ac0"; +// P2TR output: OP_1 OP_PUSHBYTES_32 +const SCRIPT_PUBKEY_HEX = "5120f3b3930e1e7103753b62e5cfee821b5bfa942eacb868e1d625243df606882dff"; + +// BIP341 tagged hash: SHA256(SHA256(tag) || SHA256(tag) || data) +function taggedHash(tag: string, data: Buffer): Buffer { + const tagHash = crypto.createHash("sha256").update(tag).digest(); + return crypto + .createHash("sha256") + .update(Buffer.concat([tagHash, tagHash, data])) + .digest(); +} + +// BIP341 tap leaf hash: tagged_hash("TapLeaf", version || compact_size(len) || script) +// version 0xc0 = TapScript; compact_size is a single byte for scripts shorter than 253 bytes. +function tapLeafHash(scriptHex: string): string { + const script = Buffer.from(scriptHex, "hex"); + const data = Buffer.concat([Buffer.from([0xc0, script.length]), script]); + return taggedHash("TapLeaf", data).toString("hex"); +} + +function getSbtcDescriptor(depositLeaf: string, reclaimLeaf: string) { + return `tr(${UNSPENDABLE_KEY},{${depositLeaf},${reclaimLeaf}})`; +} + +// Types matching the node() structure for the sBTC taproot descriptor +type DefiniteKey = { Single: string }; + +type SbtcDepositLeaf = { + Check: { + AndV: [{ PayloadDrop: string }, { PkK: DefiniteKey }]; + }; +}; + +type SbtcReclaimLeaf = { + AndV: [{ Drop: { Older: { relLockTime: number } } }, { MultiA: DefiniteKey[] }]; +}; + +type SbtcDescriptorNode = { + Tr: [DefiniteKey, { Tree: [SbtcDepositLeaf, SbtcReclaimLeaf] }]; +}; + +describe("sBTC taproot descriptor", function () { + // Use fromStringExt with { drop: true } to enable r:older() in taproot + const descriptor = Descriptor.fromString( + getSbtcDescriptor(DEPOSIT_LEAF, RECLAIM_LEAF), + "definite", + ); + + it("parses successfully with fromStringExt", () => { + // Key test: Descriptor.fromStringExt({ drop: true }) handles r:older() with targeted drop permission + assert.ok(descriptor, "Descriptor should parse successfully"); + }); + + it("has expected taproot structure", () => { + const node = descriptor.node() as SbtcDescriptorNode; + // Definite descriptors wrap keys in { Single: "..." } + assert.deepStrictEqual( + node.Tr[0], + { Single: UNSPENDABLE_KEY }, + "Should have correct internal key", + ); + assert.ok(node.Tr[1].Tree, "Should have taproot tree structure"); + assert.strictEqual(node.Tr[1].Tree.length, 2, "Should have two leaves"); + }); + + describe("deposit leaf", function () { + it("has correct structure with payload_drop", () => { + const node = descriptor.node() as SbtcDescriptorNode; + const depositLeaf = node.Tr[1].Tree[0]; + + assert.deepStrictEqual(depositLeaf, { + Check: { + AndV: [ + { PayloadDrop: "0000000000013880051ad206838b7981a116c334e8cb1b950afb73eb54a5" }, + { PkK: { Single: "c9c2312ca406dcb8eed50b829b5292f5fb3e846db0a556af61cc53834ce75421" } }, + ], + }, + }); + }); + + it("has correct script hex and tap leaf hash", () => { + assert.strictEqual(tapLeafHash(DEPOSIT_SCRIPT_HEX), DEPOSIT_LEAF_HASH); + }); + }); + + describe("reclaim leaf", function () { + it("has correct structure with r:older (Drop wrapper)", () => { + const node = descriptor.node() as SbtcDescriptorNode; + const reclaimLeaf = node.Tr[1].Tree[1]; + + // Verify the r:older pattern creates a Drop wrapper + assert.ok(reclaimLeaf.AndV, "Should have AndV structure"); + assert.ok(reclaimLeaf.AndV[0].Drop, "Should have Drop wrapper for r:older"); + assert.ok(reclaimLeaf.AndV[0].Drop.Older, "Should contain Older inside Drop"); + assert.strictEqual( + reclaimLeaf.AndV[0].Drop.Older.relLockTime, + 1, + "Should have locktime of 1", + ); + + // Verify the multi_a is the second part + assert.ok(reclaimLeaf.AndV[1].MultiA, "Should have MultiA as second element"); + }); + + it("has correct script hex and tap leaf hash", () => { + assert.strictEqual(tapLeafHash(RECLAIM_SCRIPT_HEX), RECLAIM_LEAF_HASH); + }); + }); + + describe("P2TR output", function () { + it("produces correct script pubkey", () => { + const scriptPubkeyBytes = descriptor.scriptPubkey(); + assert.strictEqual(Buffer.from(scriptPubkeyBytes).toString("hex"), SCRIPT_PUBKEY_HEX); + }); + }); +});