Skip to content

Commit 25fba86

Browse files
Merge pull request #272 from BitGo/BTC-3357.bump-rust-miniscript-for-sbtc
build(wasm-utxo): add support for sBTC address types
2 parents 77b3545 + 5457b17 commit 25fba86

7 files changed

Lines changed: 258 additions & 5 deletions

File tree

packages/wasm-utxo/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/wasm-utxo/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ inspect = ["dep:num-bigint", "dep:serde", "dep:serde_json", "dep:hex"]
2727
[dependencies]
2828
wasm-bindgen = "0.2"
2929
js-sys = "0.3"
30-
miniscript = { git = "https://github.com/BitGo/rust-miniscript", tag = "miniscript-13.0.0-bitgo.2" }
30+
miniscript = { git = "https://github.com/BitGo/rust-miniscript", tag = "miniscript-13.0.0-bitgo.5" }
3131
bech32 = "0.11"
3232
musig2 = { version = "0.3.1", default-features = false, features = ["k256"] }
3333
getrandom = { version = "0.2", features = ["js"] }

packages/wasm-utxo/js/index.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ export type DescriptorPkType = "derivable" | "definite" | "string";
3838

3939
export type ScriptContext = "tap" | "segwitv0" | "legacy";
4040

41+
export interface ExtParamsConfig {
42+
drop?: boolean;
43+
topUnsafe?: boolean;
44+
resourceLimitations?: boolean;
45+
timelockMixing?: boolean;
46+
malleability?: boolean;
47+
repeatedPk?: boolean;
48+
rawPkh?: boolean;
49+
}
50+
4151
declare module "./wasm/wasm_utxo.js" {
4252
interface WrapDescriptor {
4353
/** These are not the same types of nodes as in the ast module */
@@ -48,6 +58,11 @@ declare module "./wasm/wasm_utxo.js" {
4858
namespace WrapDescriptor {
4959
function fromString(descriptor: string, pkType: DescriptorPkType): WrapDescriptor;
5060
function fromStringDetectType(descriptor: string): WrapDescriptor;
61+
function fromStringExt(
62+
descriptor: string,
63+
pkType: "definite",
64+
extParams: ExtParamsConfig,
65+
): WrapDescriptor;
5166
}
5267

5368
interface WrapMiniscript {

packages/wasm-utxo/src/wasm/descriptor.rs

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
use crate::error::WasmUtxoError;
2+
use crate::wasm::try_from_js_value::get_field;
23
use crate::wasm::try_into_js_value::TryIntoJsValue;
34
use miniscript::bitcoin::secp256k1::{Secp256k1, Signing};
45
use miniscript::bitcoin::ScriptBuf;
56
use miniscript::descriptor::KeyMap;
7+
use miniscript::miniscript::analyzable::ExtParams;
68
use miniscript::{DefiniteDescriptorKey, Descriptor, DescriptorPublicKey};
79
use std::fmt;
810
use std::str::FromStr;
@@ -111,12 +113,16 @@ impl WrapDescriptor {
111113
secp: &Secp256k1<C>,
112114
descriptor: &str,
113115
) -> Result<WrapDescriptor, WasmUtxoError> {
114-
let (desc, keys) = Descriptor::parse_descriptor(secp, descriptor)?;
116+
let (desc, keys) =
117+
Descriptor::parse_descriptor_ext(secp, descriptor, &ExtParams::sane().drop())?;
115118
Ok(WrapDescriptor(WrapDescriptorEnum::Derivable(desc, keys)))
116119
}
117120

118121
fn from_string_definite(descriptor: &str) -> Result<WrapDescriptor, WasmUtxoError> {
119-
let desc = Descriptor::<DefiniteDescriptorKey>::from_str(descriptor)?;
122+
let desc = Descriptor::<DefiniteDescriptorKey>::from_str_ext(
123+
descriptor,
124+
&ExtParams::sane().drop(),
125+
)?;
120126
Ok(WrapDescriptor(WrapDescriptorEnum::Definite(desc)))
121127
}
122128

@@ -149,13 +155,77 @@ impl WrapDescriptor {
149155
"derivable" => WrapDescriptor::from_string_derivable(&Secp256k1::new(), descriptor),
150156
"definite" => WrapDescriptor::from_string_definite(descriptor),
151157
"string" => {
152-
let desc = Descriptor::<String>::from_str(descriptor)?;
158+
let desc =
159+
Descriptor::<String>::from_str_ext(descriptor, &ExtParams::sane().drop())?;
153160
Ok(WrapDescriptor(WrapDescriptorEnum::String(desc)))
154161
}
155162
_ => Err(WasmUtxoError::new("Invalid descriptor type")),
156163
}
157164
}
158165

166+
/// Parse a descriptor string with custom ExtParams for taproot leaf validation.
167+
///
168+
/// This allows control over which miniscript analysis checks are applied to
169+
/// taproot leaves. The `drop` flag is always enabled; other flags default to false.
170+
///
171+
/// # Arguments
172+
/// * `descriptor` - A string containing the descriptor to parse
173+
/// * `pk_type` - The type of public key ("definite" only for now)
174+
/// * `ext_params_config` - JavaScript object with optional boolean flags:
175+
/// - `drop`: Allow drop operations (r: wrapper) — always enabled
176+
/// - `topUnsafe`: Allow scripts without signatures on all paths
177+
/// - `resourceLimitations`: Allow scripts exceeding resource limits
178+
/// - `timelockMixing`: Allow CSV + CLTV mixing
179+
/// - `malleability`: Allow malleable scripts
180+
/// - `repeatedPk`: Allow repeated public keys
181+
/// - `rawPkh`: Allow raw pubkey hash fragments
182+
///
183+
/// # Example
184+
/// ```javascript
185+
/// // r:older() is always allowed; add extra flags as needed
186+
/// Descriptor.fromStringExt(desc, "definite", { malleability: true })
187+
/// ```
188+
#[wasm_bindgen(js_name = fromStringExt, skip_typescript)]
189+
pub fn from_string_ext(
190+
descriptor: &str,
191+
pk_type: &str,
192+
ext_params_config: JsValue,
193+
) -> Result<WrapDescriptor, WasmUtxoError> {
194+
let flag = |key| -> Result<bool, WasmUtxoError> {
195+
Ok(get_field::<Option<bool>>(&ext_params_config, key)?.unwrap_or(false))
196+
};
197+
198+
let mut params = ExtParams::sane().drop();
199+
if flag("topUnsafe")? {
200+
params = params.top_unsafe();
201+
}
202+
if flag("resourceLimitations")? {
203+
params = params.exceed_resource_limitations();
204+
}
205+
if flag("timelockMixing")? {
206+
params = params.timelock_mixing();
207+
}
208+
if flag("malleability")? {
209+
params = params.malleability();
210+
}
211+
if flag("repeatedPk")? {
212+
params = params.repeated_pk();
213+
}
214+
if flag("rawPkh")? {
215+
params = params.raw_pkh();
216+
}
217+
218+
match pk_type {
219+
"definite" => {
220+
let desc = Descriptor::<DefiniteDescriptorKey>::from_str_ext(descriptor, &params)?;
221+
Ok(WrapDescriptor(WrapDescriptorEnum::Definite(desc)))
222+
}
223+
_ => Err(WasmUtxoError::new(
224+
"fromStringExt only supports 'definite' pk_type",
225+
)),
226+
}
227+
}
228+
159229
/// Parse a descriptor string, automatically detecting the appropriate public key type.
160230
/// This will check if the descriptor contains wildcards to determine if it should be
161231
/// parsed as derivable or definite.

packages/wasm-utxo/src/wasm/try_from_js_value.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ impl TryFromJsValue for u8 {
7878
}
7979
}
8080

81+
impl TryFromJsValue for bool {
82+
fn try_from_js_value(value: &JsValue) -> Result<Self, WasmUtxoError> {
83+
value
84+
.as_bool()
85+
.ok_or_else(|| WasmUtxoError::new("Expected a boolean"))
86+
}
87+
}
88+
8189
impl TryFromJsValue for u32 {
8290
fn try_from_js_value(value: &JsValue) -> Result<Self, WasmUtxoError> {
8391
value

packages/wasm-utxo/src/wasm/try_into_js_value.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,9 @@ impl<Pk: MiniscriptKey + TryIntoJsValue, Ctx: ScriptContext> TryIntoJsValue for
203203
Terminal::Thresh(t) => js_obj!("Thresh" => t),
204204
Terminal::Multi(pks) => js_obj!("Multi" => pks),
205205
Terminal::MultiA(pks) => js_obj!("MultiA" => pks),
206+
Terminal::PayloadDrop(payload) => {
207+
js_obj!("PayloadDrop" => payload.iter().map(|b| format!("{:02x}", b)).collect::<String>())
208+
}
206209
}
207210
}
208211
}

packages/wasm-utxo/test/sbtc.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import * as assert from "assert";
2+
import * as crypto from "crypto";
3+
import { Descriptor } from "../js/index.js";
4+
import { getUnspendableKey } from "../js/testutils/descriptor/descriptors.js";
5+
6+
// sBTC protocol uses two taproot script leaves:
7+
// 1. Deposit leaf: allows the signers to spend with a protocol payload
8+
// 2. Reclaim leaf: allows the depositors to reclaim after a timelock
9+
10+
const SIGNERS_KEY = "c9c2312ca406dcb8eed50b829b5292f5fb3e846db0a556af61cc53834ce75421";
11+
12+
// BIP341 "nothing up my sleeve" unspendable internal key — used so the taproot address
13+
// can only be spent via script path (no key-path spend).
14+
const UNSPENDABLE_KEY = getUnspendableKey();
15+
16+
const DEPOSIT_LEAF =
17+
"c:and_v(payload_drop(" +
18+
"0000000000013880051ad206838b7981a116c334e8cb1b950afb73eb54a5" +
19+
"),pk_k(" +
20+
SIGNERS_KEY +
21+
"))";
22+
23+
const RECLAIM_LEAF =
24+
"and_v(r:older(1),multi_a(2," +
25+
"4d838759b2a74616a2298e0580ca815874f5e5a9d2dd1b2f0203b68c66fc6c1e," +
26+
"639779c4b700dc51ece012a0e20325fcafada22a4a122ffaa04d0c0ccae83943," +
27+
"d1d6084eac98303e9d28e082bfd9eadf0b8be033e223a17ad01df81bdaa8c7b2))";
28+
29+
// Reference vectors from rust-miniscript test_payload_drop_stacks_vectors.
30+
// Deposit leaf: OP_PUSHBYTES_30 <metadata> OP_DROP OP_PUSHBYTES_32 <key> OP_CHECKSIG
31+
const DEPOSIT_SCRIPT_HEX =
32+
"1e0000000000013880051ad206838b7981a116c334e8cb1b950afb73eb54a5" +
33+
"7520c9c2312ca406dcb8eed50b829b5292f5fb3e846db0a556af61cc53834ce75421ac";
34+
const DEPOSIT_LEAF_HASH = "b14bbf1c6699b64429be4f11e1d4df7b75f16f68e7a86cb91c58daf024d0b379";
35+
// Reclaim leaf: OP_1 OP_CSV OP_DROP + 2-of-3 multi_a
36+
const RECLAIM_SCRIPT_HEX =
37+
"51b275" +
38+
"204d838759b2a74616a2298e0580ca815874f5e5a9d2dd1b2f0203b68c66fc6c1eac" +
39+
"20639779c4b700dc51ece012a0e20325fcafada22a4a122ffaa04d0c0ccae83943ba" +
40+
"20d1d6084eac98303e9d28e082bfd9eadf0b8be033e223a17ad01df81bdaa8c7b2ba529c";
41+
const RECLAIM_LEAF_HASH = "1e379caf8335dc3bd0af785d32d8135647ffa2ee76dd2c1bcc663ff424602ac0";
42+
// P2TR output: OP_1 OP_PUSHBYTES_32 <tweaked-x-only-pubkey>
43+
const SCRIPT_PUBKEY_HEX = "5120f3b3930e1e7103753b62e5cfee821b5bfa942eacb868e1d625243df606882dff";
44+
45+
// BIP341 tagged hash: SHA256(SHA256(tag) || SHA256(tag) || data)
46+
function taggedHash(tag: string, data: Buffer): Buffer {
47+
const tagHash = crypto.createHash("sha256").update(tag).digest();
48+
return crypto
49+
.createHash("sha256")
50+
.update(Buffer.concat([tagHash, tagHash, data]))
51+
.digest();
52+
}
53+
54+
// BIP341 tap leaf hash: tagged_hash("TapLeaf", version || compact_size(len) || script)
55+
// version 0xc0 = TapScript; compact_size is a single byte for scripts shorter than 253 bytes.
56+
function tapLeafHash(scriptHex: string): string {
57+
const script = Buffer.from(scriptHex, "hex");
58+
const data = Buffer.concat([Buffer.from([0xc0, script.length]), script]);
59+
return taggedHash("TapLeaf", data).toString("hex");
60+
}
61+
62+
function getSbtcDescriptor(depositLeaf: string, reclaimLeaf: string) {
63+
return `tr(${UNSPENDABLE_KEY},{${depositLeaf},${reclaimLeaf}})`;
64+
}
65+
66+
// Types matching the node() structure for the sBTC taproot descriptor
67+
type DefiniteKey = { Single: string };
68+
69+
type SbtcDepositLeaf = {
70+
Check: {
71+
AndV: [{ PayloadDrop: string }, { PkK: DefiniteKey }];
72+
};
73+
};
74+
75+
type SbtcReclaimLeaf = {
76+
AndV: [{ Drop: { Older: { relLockTime: number } } }, { MultiA: DefiniteKey[] }];
77+
};
78+
79+
type SbtcDescriptorNode = {
80+
Tr: [DefiniteKey, { Tree: [SbtcDepositLeaf, SbtcReclaimLeaf] }];
81+
};
82+
83+
describe("sBTC taproot descriptor", function () {
84+
// Use fromStringExt with { drop: true } to enable r:older() in taproot
85+
const descriptor = Descriptor.fromString(
86+
getSbtcDescriptor(DEPOSIT_LEAF, RECLAIM_LEAF),
87+
"definite",
88+
);
89+
90+
it("parses successfully with fromStringExt", () => {
91+
// Key test: Descriptor.fromStringExt({ drop: true }) handles r:older() with targeted drop permission
92+
assert.ok(descriptor, "Descriptor should parse successfully");
93+
});
94+
95+
it("has expected taproot structure", () => {
96+
const node = descriptor.node() as SbtcDescriptorNode;
97+
// Definite descriptors wrap keys in { Single: "..." }
98+
assert.deepStrictEqual(
99+
node.Tr[0],
100+
{ Single: UNSPENDABLE_KEY },
101+
"Should have correct internal key",
102+
);
103+
assert.ok(node.Tr[1].Tree, "Should have taproot tree structure");
104+
assert.strictEqual(node.Tr[1].Tree.length, 2, "Should have two leaves");
105+
});
106+
107+
describe("deposit leaf", function () {
108+
it("has correct structure with payload_drop", () => {
109+
const node = descriptor.node() as SbtcDescriptorNode;
110+
const depositLeaf = node.Tr[1].Tree[0];
111+
112+
assert.deepStrictEqual(depositLeaf, {
113+
Check: {
114+
AndV: [
115+
{ PayloadDrop: "0000000000013880051ad206838b7981a116c334e8cb1b950afb73eb54a5" },
116+
{ PkK: { Single: "c9c2312ca406dcb8eed50b829b5292f5fb3e846db0a556af61cc53834ce75421" } },
117+
],
118+
},
119+
});
120+
});
121+
122+
it("has correct script hex and tap leaf hash", () => {
123+
assert.strictEqual(tapLeafHash(DEPOSIT_SCRIPT_HEX), DEPOSIT_LEAF_HASH);
124+
});
125+
});
126+
127+
describe("reclaim leaf", function () {
128+
it("has correct structure with r:older (Drop wrapper)", () => {
129+
const node = descriptor.node() as SbtcDescriptorNode;
130+
const reclaimLeaf = node.Tr[1].Tree[1];
131+
132+
// Verify the r:older pattern creates a Drop wrapper
133+
assert.ok(reclaimLeaf.AndV, "Should have AndV structure");
134+
assert.ok(reclaimLeaf.AndV[0].Drop, "Should have Drop wrapper for r:older");
135+
assert.ok(reclaimLeaf.AndV[0].Drop.Older, "Should contain Older inside Drop");
136+
assert.strictEqual(
137+
reclaimLeaf.AndV[0].Drop.Older.relLockTime,
138+
1,
139+
"Should have locktime of 1",
140+
);
141+
142+
// Verify the multi_a is the second part
143+
assert.ok(reclaimLeaf.AndV[1].MultiA, "Should have MultiA as second element");
144+
});
145+
146+
it("has correct script hex and tap leaf hash", () => {
147+
assert.strictEqual(tapLeafHash(RECLAIM_SCRIPT_HEX), RECLAIM_LEAF_HASH);
148+
});
149+
});
150+
151+
describe("P2TR output", function () {
152+
it("produces correct script pubkey", () => {
153+
const scriptPubkeyBytes = descriptor.scriptPubkey();
154+
assert.strictEqual(Buffer.from(scriptPubkeyBytes).toString("hex"), SCRIPT_PUBKEY_HEX);
155+
});
156+
});
157+
});

0 commit comments

Comments
 (0)