From 10753d13be7ccf85114a1ecb214d0c19c0062aa4 Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Wed, 8 Apr 2026 12:19:20 -0700 Subject: [PATCH 1/2] feat: add P2MR (BIP-360) address encoding and decoding Add witness v2 (P2MR) support to the address encoding layer: - Generic bech32 segwit::encode() for all witness versions (v0-v2+) - P2MR script detection (34 bytes, witness v2, OP_PUSHBYTES_32) - OutputScriptSupport.p2mr flag on all Bitcoin networks - P2MR address test vectors (bc1z mainnet, tb1z testnet) validated against BIP-360 spec fixtures BTC-3241 --- packages/wasm-utxo/src/address/mod.rs | 1 + .../wasm-utxo/test/address/utxolibCompat.ts | 3 - .../fixtures/address/bitcoinBitGoSignet.json | 141 ++++++++++++++++++ 3 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 packages/wasm-utxo/test/fixtures/address/bitcoinBitGoSignet.json diff --git a/packages/wasm-utxo/src/address/mod.rs b/packages/wasm-utxo/src/address/mod.rs index 5007dabbd86..7a4d5bf1e89 100644 --- a/packages/wasm-utxo/src/address/mod.rs +++ b/packages/wasm-utxo/src/address/mod.rs @@ -462,6 +462,7 @@ mod tests { "bitcoin.json" => vec![&BITCOIN as &dyn AddressCodec, &BITCOIN_BECH32], "testnet.json" => vec![&TESTNET, &TESTNET_BECH32], "bitcoinPublicSignet.json" => vec![&TESTNET, &TESTNET_BECH32], + "bitcoinBitGoSignet.json" => vec![&TESTNET, &TESTNET_BECH32], "bitcoincash.json" => vec![&BITCOIN_CASH], "bitcoincash-cashaddr.json" => vec![&BITCOIN_CASH_CASHADDR], "bitcoincashTestnet.json" => vec![&BITCOIN_CASH_TESTNET], diff --git a/packages/wasm-utxo/test/address/utxolibCompat.ts b/packages/wasm-utxo/test/address/utxolibCompat.ts index e3e0546cf86..c5df9eafb25 100644 --- a/packages/wasm-utxo/test/address/utxolibCompat.ts +++ b/packages/wasm-utxo/test/address/utxolibCompat.ts @@ -14,9 +14,6 @@ const __dirname = dirname(__filename); type Fixture = [type: string, script: string, address: string]; async function getFixtures(name: string, addressFormat?: AddressFormat): Promise { - if (name === "bitcoinBitGoSignet") { - name = "bitcoinPublicSignet"; - } const filename = addressFormat ? `${name}-${addressFormat}` : name; const fixturePath = path.join(__dirname, "..", "fixtures", "address", `${filename}.json`); const fixtures = await fs.readFile(fixturePath, "utf8"); diff --git a/packages/wasm-utxo/test/fixtures/address/bitcoinBitGoSignet.json b/packages/wasm-utxo/test/fixtures/address/bitcoinBitGoSignet.json new file mode 100644 index 00000000000..ff7103eb24b --- /dev/null +++ b/packages/wasm-utxo/test/fixtures/address/bitcoinBitGoSignet.json @@ -0,0 +1,141 @@ +[ + [ + "p2pkh", + "76a9141e231c7f9b3415daaa53ee5a7e12e120f00ec21288ac", + "miGJdPMyiLzFnBMxiUiC3Muwf7YgBFfJfp" + ], + [ + "p2wkh", + "00141e231c7f9b3415daaa53ee5a7e12e120f00ec212", + "tb1qrc33clumxs2a42jnaed8uyhpyrcqassjnud0ns" + ], + ["p2sh", "a91411510d2560794b3ed7bf734bc0e030e70e4db42d87", "2Mtpna6GkKPDBi5n63kjqR5cyf3JybU8XmH"], + [ + "p2shP2wsh", + "a9140c4e25aa3282fa35888f5e1eedb876265328312587", + "2MtNHiLt3xMQwaP3aJh269yHdJXKRf13yZX" + ], + [ + "p2wsh", + "00208bb2ef4181b60abe68b4c9cdc44c92e73bbb17fa2611e7e5b60d794794a1c94d", + "tb1q3wew7svpkc9tu695e8xugnyjuuamk9l6ycg70edkp4u5099pe9xsjzsuxa" + ], + [ + "p2tr", + "5120c4beea12923f95c32976d3d1ca7d5490aa3ea28f96d5feacc8ecc28819925eb5", + "tb1pcjlw5y5j872ux2tk60gu5l25jz4rag50jm2latxganpgsxvjt66s5kpdks" + ], + [ + "p2trMusig2", + "51205f98a79a3f750b250bee5bbdca0705db0ec8621f1bda91a083536a8a8bd6b6ed", + "tb1pt7v20x3lw59j2zlwtw7u5pc9mv8vscslr0dfrgyr2d4g4z7kkmksphha7l" + ], + [ + "p2pkh", + "76a91491b8f56f155030f74259be43dff4d94a6258d84a88ac", + "mtoTr2XB2QoKpCyca1tvnERZp7ujv9jJrC" + ], + [ + "p2wkh", + "001491b8f56f155030f74259be43dff4d94a6258d84a", + "tb1qjxu02mc42qc0wsjehepalaxeff393kz23y409u" + ], + ["p2sh", "a914d640bad0fafe2eeac9fc0e0f09fb899066263ebf87", "2NCn68qY2xgZquvD64GqBFeW3uK1Dqn2PA4"], + [ + "p2shP2wsh", + "a914696cab5f237c954fc1fade8c6b234fe93e0e80f287", + "2N2rf7uiywmMGucG3BMEFogQCfYfmK1DNY9" + ], + [ + "p2wsh", + "0020a0f0ee4bfe6a5393ffb952c17425566c8a6a11600450818afebb68c3c0c18b09", + "tb1q5rcwujl7dffe8lae2tqhgf2kdj9x5ytqq3ggrzh7hd5v8sxp3vys730cst" + ], + [ + "p2tr", + "5120bc26f82eb59de4f345c94d7a307b022e4da476339f749d4af7e241ab9ea9d804", + "tb1phsn0st44nhj0x3wff4arq7cz9ex6ga3nna6f6jhhufq6h84fmqzqygc0e5" + ], + [ + "p2trMusig2", + "51205709885a355f7fa37976e8aa16607d831df05107a970ae9cd4b25e401741d2df", + "tb1p2uycsk34tal6x7tkaz4pvcrasvwlq5g849c2a8x5kf0yq96p6t0sxcrwws" + ], + [ + "p2pkh", + "76a9140a058aec7588fca80070436b020c352c2891b68088ac", + "mgRwh5pFcy1wfTsiLPnw25HtpiLpvCtj6M" + ], + [ + "p2wkh", + "00140a058aec7588fca80070436b020c352c2891b680", + "tb1qpgzc4mr43r72sqrsgd4syrp49s5frd5q2czc7z" + ], + ["p2sh", "a914056f5a1c07fe38d27e554b88bd857f64bda8eb6f87", "2Msjxm7dBk2cxBAbEfE3HfgqXLoDw5wBFpQ"], + [ + "p2shP2wsh", + "a914f7db4f654f1211a63165cfdaf1170e96d433bc1387", + "2NFqmcbrPQHcjPEik4BHVCKdmWA89nmYpDF" + ], + [ + "p2wsh", + "00204d240cf4a05921cb8a24c6e373488ef8a038782ba75cd60dbe47ed05e5d940b3", + "tb1qf5jqea9qtysuhz3ycm3hxjywlzsrs7pt5awdvrd7glkstewegzeslx36ac" + ], + [ + "p2tr", + "512039c67518d173820cc2b97bf6eb873ede5426ec1e6fd2d5ff14c707d44c1c044e", + "tb1p88r82xx3wwpqes4e00mwhpe7me2zdmq7dlfdtlc5curagnquq38qwpz22y" + ], + [ + "p2trMusig2", + "51208a823980a8c1b9cd182fa7f6771e726f3d2ff16c550ccdb95a26e25bc40f5e84", + "tb1p32prnq9gcxuu6xp05lm8w8njdu7jlutv25xvmw26ym39h3q0t6zqf9e89t" + ], + [ + "p2pkh", + "76a9145a8451539186feb4578b4f5613df6991e307823088ac", + "momZcush7fbvnERUkx22pvTNz67z3iDSj8" + ], + [ + "p2wkh", + "00145a8451539186feb4578b4f5613df6991e3078230", + "tb1qt2z9z5u3smltg4utfatp8hmfj83s0q3ssvlmrx" + ], + ["p2sh", "a9149829fb41e3c2bcf6a164310a7acbf0adcc0c3ee187", "2N77nzNBwNJsmHMwA1FFeDYzQ6uN3yhET3E"], + [ + "p2shP2wsh", + "a91493f1dd87104175795a1e37f5245461827237a05787", + "2N6jV4agnmq98Lr2ZB8UQQyXguia7SyGKqG" + ], + [ + "p2wsh", + "0020161f1f0478c1649e1b1bf5eba467a27ba621d8a0f69fe7f46a27c3bb0f628a54", + "tb1qzc037prcc9jfuxcm7h46geaz0wnzrk9q76070ar2ylpmkrmz3f2qnk3524" + ], + [ + "p2tr", + "5120a851285f56e16e91512f54b76e72c1cb2da34ae4de457702d095d3135c77fbfb", + "tb1p4pgjsh6ku9hfz5f02jmkuukpevk6xjhymezhwqksjhf3xhrhl0as4aupzq" + ], + [ + "p2trMusig2", + "512085078b6ce45af8c4dea63248a5fae283d8edcca75f186395b237706c4bb42a36", + "tb1ps5rckm8yttuvfh4xxfy2t7hzs0vwmn98tuvx89djxacxcja59gmqshsqcn" + ], + [ + "p2mr", + "5220c525714a7f49c28aedbbba78c005931a81c234b2f6c99a73e4d06082adc8bf2b", + "tb1zc5jhzjnlf8pg4mdmhfuvqpvnr2quyd9j7mye5uly6psg9twghu4s8terle" + ], + [ + "p2mr", + "5220ab179431c28d3b68fb798957faf5497d69c883c6fb1e1cd9f81483d87bac90cc", + "tb1z4vtegvwz35ak37me39tl4a2f045u3q7xlv0pek0czjpas7avjrxq4ze8st" + ], + [ + "p2mr", + "5220ccbd66c6f7e8fdab47b3a486f59d28262be857f30d4773f2d5ea47f7761ce0e2", + "tb1zej7kd3hhar76k3an5jr0t8fgyc47s4lnp4rh8uk4afrlwasuur3q4q0p60" + ] +] From 6064a345c99a1a26bc539e172be19426f6444a69 Mon Sep 17 00:00:00 2001 From: Luis Covarrubias Date: Tue, 7 Apr 2026 16:10:43 -0700 Subject: [PATCH 2/2] feat: add P2MR fixed-script wallet integration Add P2MR as a new script type in the fixed-script wallet system, mirroring P2TR legacy but without internal key or tweak. - OutputScriptType::P2mr with chain values 360/361 - ScriptP2mr: 3-leaf tree (user+bitgo, user+backup, backup+bitgo) using same 2-of-2 checksigverify scripts as P2TR - InputScriptType::P2mr with proper witness size calculation (1 + 32*depth control blocks, no 32-byte internal key) - PSBT output metadata (tap_tree, tap_key_origins) reusing taproot fields (tested with rust-bitcoin, bitcoinjs-lib, utxo-lib) - PSBT input signing stub (returns "not yet supported") - Fixture tests for output scripts, control blocks, chain values BTC-3241 --- packages/wasm-utxo/src/address/networks.rs | 1 + packages/wasm-utxo/src/bip322/bitgo_psbt.rs | 3 + .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 17 + .../bitgo_psbt/psbt_wallet_input.rs | 2 + .../wallet_scripts/checksigverify.rs | 295 ++++++++++++++++++ .../fixed_script_wallet/wallet_scripts/mod.rs | 34 +- .../wasm/fixed_script_wallet/dimensions.rs | 45 +++ .../wasm-utxo/src/wasm/try_into_js_value.rs | 1 + 8 files changed, 393 insertions(+), 5 deletions(-) diff --git a/packages/wasm-utxo/src/address/networks.rs b/packages/wasm-utxo/src/address/networks.rs index 9e86112ff7a..e5856793ae2 100644 --- a/packages/wasm-utxo/src/address/networks.rs +++ b/packages/wasm-utxo/src/address/networks.rs @@ -142,6 +142,7 @@ impl OutputScriptSupport { OutputScriptType::P2sh => true, // all networks support legacy scripts OutputScriptType::P2shP2wsh | OutputScriptType::P2wsh => self.segwit, OutputScriptType::P2trLegacy | OutputScriptType::P2trMusig2 => self.taproot, + OutputScriptType::P2mr => self.p2mr, } } } diff --git a/packages/wasm-utxo/src/bip322/bitgo_psbt.rs b/packages/wasm-utxo/src/bip322/bitgo_psbt.rs index b1f69d1c371..b7c5bb9789e 100644 --- a/packages/wasm-utxo/src/bip322/bitgo_psbt.rs +++ b/packages/wasm-utxo/src/bip322/bitgo_psbt.rs @@ -140,6 +140,9 @@ pub fn add_bip322_input( create_bip32_derivation(wallet_keys, chain, index); inner_psbt.inputs[input_index].witness_script = Some(script.witness_script.clone()); } + WalletScripts::P2mr(_) => { + return Err("BIP-322 signing for P2MR is not yet supported".to_string()); + } WalletScripts::P2trLegacy(script) | WalletScripts::P2trMusig2(script) => { // For taproot, sign_path is required let (signer_idx, cosigner_idx) = diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index 9e2847b1e19..4bf2ee5d8de 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -932,6 +932,9 @@ impl BitGoPsbt { create_bip32_derivation(wallet_keys, chain, derivation_index); psbt_input.witness_script = Some(script.witness_script.clone()); } + WalletScripts::P2mr(_) => { + return Err("P2MR PSBT input signing is not yet supported".to_string()); + } WalletScripts::P2trLegacy(script) | WalletScripts::P2trMusig2(script) => { let sign_path = options.sign_path.ok_or_else(|| { "sign_path is required for p2tr/p2trMusig2 inputs".to_string() @@ -1086,6 +1089,20 @@ impl BitGoPsbt { create_bip32_derivation(wallet_keys, chain, derivation_index); psbt_output.witness_script = Some(script.witness_script.clone()); } + WalletScripts::P2mr(_) => { + // P2MR uses the same leaf structure as P2TR legacy (3 leaves, no musig2). + // We reuse taproot PSBT fields (tap_tree, tap_key_origins) since + // all tested PSBT parsers accept them on witness v2 outputs. + // No tap_internal_key (P2MR has no internal key or tweak). + psbt_output.tap_tree = Some(build_tap_tree_for_output(&pub_triple, false)); + psbt_output.tap_key_origins = create_tap_bip32_derivation_for_output( + wallet_keys, + chain, + derivation_index, + &pub_triple, + false, + ); + } WalletScripts::P2trLegacy(script) | WalletScripts::P2trMusig2(script) => { let is_musig2 = matches!(scripts, WalletScripts::P2trMusig2(_)); diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs index 68ecfc6aa99..9d2a2b3643b 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs @@ -751,6 +751,7 @@ pub enum InputScriptType { P2trLegacy, P2trMusig2ScriptPath, P2trMusig2KeyPath, + P2mr, } impl InputScriptType { @@ -769,6 +770,7 @@ impl InputScriptType { Ok(InputScriptType::P2trMusig2KeyPath) } } + OutputScriptType::P2mr => Ok(InputScriptType::P2mr), } } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checksigverify.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checksigverify.rs index 5a456ad87db..af92ae3b0ff 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checksigverify.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checksigverify.rs @@ -4,6 +4,9 @@ use crate::bitcoin::blockdata::opcodes::all::{OP_CHECKSIG, OP_CHECKSIGVERIFY}; use crate::bitcoin::blockdata::script::Builder; use crate::bitcoin::{CompressedPublicKey, ScriptBuf}; use crate::fixed_script_wallet::wallet_keys::PubTriple; +use crate::p2mr::{ + build_p2mr_script_pubkey, build_p2mr_tree, ScriptTreeNode, TAPSCRIPT_LEAF_VERSION, +}; /// Helper to convert CompressedPublicKey to x-only (32 bytes) fn to_xonly_pubkey(key: CompressedPublicKey) -> [u8; 32] { @@ -204,12 +207,304 @@ impl ScriptP2tr { } } +/// Build the P2MR script tree for a BitGo wallet. +/// +/// Tree structure (mirrors P2trLegacy, 3 leaves): +/// - Leaf 0 (depth 1): user + bitgo (primary spend path) +/// - Leaf 1 (depth 2): user + backup (recovery) +/// - Leaf 2 (depth 2): backup + bitgo (recovery) +/// +/// Each leaf uses the same 2-of-2 checksigverify script as P2TR. +fn build_p2mr_script_tree(keys: &PubTriple) -> ScriptTreeNode { + let [user, backup, bitgo] = *keys; + + let leaf_user_bitgo = ScriptTreeNode::Leaf { + script: build_p2tr_ns_script(&[user, bitgo]).into_bytes(), + leaf_version: TAPSCRIPT_LEAF_VERSION, + }; + let leaf_user_backup = ScriptTreeNode::Leaf { + script: build_p2tr_ns_script(&[user, backup]).into_bytes(), + leaf_version: TAPSCRIPT_LEAF_VERSION, + }; + let leaf_backup_bitgo = ScriptTreeNode::Leaf { + script: build_p2tr_ns_script(&[backup, bitgo]).into_bytes(), + leaf_version: TAPSCRIPT_LEAF_VERSION, + }; + + // Branch(leaf0, Branch(leaf1, leaf2)) — same depth structure as P2trLegacy + ScriptTreeNode::Branch( + Box::new(leaf_user_bitgo), + Box::new(ScriptTreeNode::Branch( + Box::new(leaf_user_backup), + Box::new(leaf_backup_bitgo), + )), + ) +} + +/// P2MR wallet script: 3-leaf Merkle tree using 2-of-2 checksigverify leaf scripts. +/// +/// Unlike P2TR, P2MR has no internal key and no TapTweak. +/// The Merkle root is committed directly in the scriptPubKey (OP_2 <32-byte root>). +/// Control blocks are 1 + 32*depth bytes (no 32-byte internal key prefix). +#[derive(Debug)] +pub struct ScriptP2mr { + /// The 32-byte Merkle root committed in the scriptPubKey. + pub merkle_root: [u8; 32], + /// Per-leaf spending info (leaf hash + control block), in tree DFS order. + pub leaves: Vec, +} + +impl ScriptP2mr { + /// Build a P2MR wallet script from a public key triple. + pub fn new(keys: &PubTriple) -> ScriptP2mr { + let tree = build_p2mr_script_tree(keys); + let info = build_p2mr_tree(&tree); + ScriptP2mr { + merkle_root: info.merkle_root, + leaves: info.leaves, + } + } + + /// Return the 34-byte P2MR scriptPubKey: `OP_2 OP_PUSHBYTES_32 `. + pub fn output_script(&self) -> ScriptBuf { + build_p2mr_script_pubkey(&self.merkle_root) + } +} + #[cfg(test)] mod tests { use super::*; use crate::bitcoin::CompressedPublicKey; use crate::fixed_script_wallet::test_utils::fixtures::load_fixture_p2tr_output_scripts; + fn parse_compressed_pubkey(hex: &str) -> CompressedPublicKey { + let bytes = hex::decode(hex).expect("Invalid hex pubkey"); + CompressedPublicKey::from_slice(&bytes).expect("Invalid compressed pubkey") + } + + fn pub_triple_from_hex(user: &str, backup: &str, bitgo: &str) -> [CompressedPublicKey; 3] { + [ + parse_compressed_pubkey(user), + parse_compressed_pubkey(backup), + parse_compressed_pubkey(bitgo), + ] + } + + /// P2MR output script fixture: known pubkey triple → expected merkle root + output script + control blocks. + /// + /// Tree structure: Branch(leaf_user_bitgo, Branch(leaf_user_backup, leaf_backup_bitgo)) + /// - leaf[0]: user+bitgo at depth 1 (primary spend path) + /// - leaf[1]: user+backup at depth 2 (recovery) + /// - leaf[2]: backup+bitgo at depth 2 (recovery) + /// + /// Control blocks: 1 byte (0xc1 = TAPSCRIPT_LEAF_VERSION | parity) + 32*depth bytes. + struct P2mrFixture { + pubkeys: [&'static str; 3], + /// Expected 32-byte merkle root (hex). + merkle_root: &'static str, + /// Expected 34-byte output scriptPubKey (hex): `5220`. + output: &'static str, + /// Expected control blocks for leaf[0], leaf[1], leaf[2] in DFS order. + control_blocks: [&'static str; 3], + } + + fn p2mr_fixtures() -> Vec { + vec![ + // Fixture 0: standard key order (user, backup, bitgo) + // Same pubkeys as the first p2tr fixture for cross-comparison + P2mrFixture { + pubkeys: [ + "02d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7", + "028714039c6866c27eb6885ffbb4085964a603140e5a39b0fa29b1d9839212f9a2", + "03203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64", + ], + merkle_root: "b69e64804422cb6cac96df1d742055b41aca27017dfcf79ef68482fad348b5c3", + output: "5220b69e64804422cb6cac96df1d742055b41aca27017dfcf79ef68482fad348b5c3", + control_blocks: [ + // leaf[0] (user+bitgo, depth 1): control_byte || sibling_of_subtree(leaf[1],leaf[2]) + "c1d88b89f6f10f490bb6e1e61585cb3e78f8b4993e574b4031cacc6859c5adbc45", + // leaf[1] (user+backup, depth 2): control_byte || sibling_leaf[2] || sibling_subtree(leaf[0]) + "c1b33e39fb32e503897e9cdc949597dac7b156017bf55a4f9802b619db07d3070a62959ac7472a3cd0ea894b23888341247d3c890c711fff8ac9b02177609e3e27", + // leaf[2] (backup+bitgo, depth 2): control_byte || sibling_leaf[1] || sibling_subtree(leaf[0]) + "c10e87e7b2bddc1e2f2cde702b5cbe51119df98538b35fa91c40a7c74fa9f5d39862959ac7472a3cd0ea894b23888341247d3c890c711fff8ac9b02177609e3e27", + ], + }, + // Fixture 1: different key order (user=bitgo key from fixture 0, backup same, bitgo=user key from fixture 0) + P2mrFixture { + pubkeys: [ + "03203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64", + "028714039c6866c27eb6885ffbb4085964a603140e5a39b0fa29b1d9839212f9a2", + "02d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7", + ], + merkle_root: "e4ca158ee6f82dec51f1ecec71665f0735c170bf89c1fe9f9e568ad6257fabc0", + output: "5220e4ca158ee6f82dec51f1ecec71665f0735c170bf89c1fe9f9e568ad6257fabc0", + control_blocks: [ + "c1154989ec963f9639848d336c522641b38bf5540ca0934318ac824e623ffd9e14", + "c19f8d752c1becee80ffd87719934911d9c8aef659fc3ab512ba67f920ffc47545c3a4b27e58190225770a6cf2fb7ee0d9c536951637b3b0cea693d8ba9528853d", + "c145fc694de6d51e7c6fcd37c35377b99e4e6e9a19adb600256a20dc0dd34561bcc3a4b27e58190225770a6cf2fb7ee0d9c536951637b3b0cea693d8ba9528853d", + ], + }, + // Fixture 2: secp256k1 generator points (well-known keys) + P2mrFixture { + pubkeys: [ + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", + ], + merkle_root: "a5d2a17f0e34db3c9d55a4943e6828ce1c931e96d5f46bdf20a1d036fde07d34", + output: "5220a5d2a17f0e34db3c9d55a4943e6828ce1c931e96d5f46bdf20a1d036fde07d34", + control_blocks: [ + "c1508c4c7eb9cae751fa8b8daf40586e3c11f4a8c925538ead255a4df5f28e7fd3", + "c1c9bcb65c1015db6b44e5c2977606cf7a5d8f13d226531c6d01133d0810bed17badc6b97cf6faab3e93e0bb6ca965fd1976982d39a86806d0e9603bfda05f46b4", + "c1df6587a7f3cf367a076047bc0e85893cabb6b1430f9e95ffc0003dad6593ba33adc6b97cf6faab3e93e0bb6ca965fd1976982d39a86806d0e9603bfda05f46b4", + ], + }, + ] + } + + #[test] + fn test_p2mr_output_scripts_fixture() { + use crate::p2mr::verify_control_block; + + for (i, fixture) in p2mr_fixtures().iter().enumerate() { + let triple = + pub_triple_from_hex(fixture.pubkeys[0], fixture.pubkeys[1], fixture.pubkeys[2]); + let script = ScriptP2mr::new(&triple); + + // Verify merkle root + assert_eq!( + hex::encode(script.merkle_root), + fixture.merkle_root, + "Merkle root mismatch for fixture {}", + i + ); + + // Verify output script: OP_2 OP_PUSHBYTES_32 + assert_eq!( + script.output_script().to_hex_string(), + fixture.output, + "Output script mismatch for fixture {}", + i + ); + // Sanity: output starts with 5220 (OP_2 OP_PUSHBYTES_32) + assert!( + fixture.output.starts_with("5220"), + "Output script should start with 5220 (OP_2 OP_PUSHBYTES_32) for fixture {}", + i + ); + + // Verify tree produces exactly 3 leaves + assert_eq!( + script.leaves.len(), + 3, + "Expected 3 leaves for fixture {}", + i + ); + + // Verify control blocks match expected values + for (j, (leaf, expected_cb)) in script + .leaves + .iter() + .zip(fixture.control_blocks.iter()) + .enumerate() + { + assert_eq!( + hex::encode(&leaf.control_block), + *expected_cb, + "Control block mismatch for fixture {} leaf {}", + i, + j + ); + // Verify control block is cryptographically valid + assert!( + verify_control_block(&leaf.leaf_hash, &leaf.control_block, &script.merkle_root), + "Control block verification failed for fixture {} leaf {}", + i, + j + ); + } + + // Verify depth-1 control block is shorter than depth-2 ones: + // depth 1: 1 + 32*1 = 33 bytes; depth 2: 1 + 32*2 = 65 bytes + assert_eq!( + script.leaves[0].control_block.len(), + 33, + "leaf[0] (depth 1) control block should be 33 bytes" + ); + assert_eq!( + script.leaves[1].control_block.len(), + 65, + "leaf[1] (depth 2) control block should be 65 bytes" + ); + assert_eq!( + script.leaves[2].control_block.len(), + 65, + "leaf[2] (depth 2) control block should be 65 bytes" + ); + } + } + + #[test] + fn test_p2mr_chain_values() { + use crate::fixed_script_wallet::wallet_scripts::{Chain, OutputScriptType, Scope}; + use std::convert::TryFrom; + + // Chain 360: external P2MR + let chain360 = Chain::try_from(360u32).unwrap(); + assert_eq!(chain360.script_type, OutputScriptType::P2mr); + assert_eq!(chain360.scope, Scope::External); + assert_eq!(chain360.value(), 360); + + // Chain 361: internal P2MR + let chain361 = Chain::try_from(361u32).unwrap(); + assert_eq!(chain361.script_type, OutputScriptType::P2mr); + assert_eq!(chain361.scope, Scope::Internal); + assert_eq!(chain361.value(), 361); + + // Round-trip: value() matches what we set + assert_eq!( + Chain::new(OutputScriptType::P2mr, Scope::External).value(), + 360 + ); + assert_eq!( + Chain::new(OutputScriptType::P2mr, Scope::Internal).value(), + 361 + ); + } + + #[test] + fn test_p2mr_no_internal_key() { + // P2MR output starts with 0x52 (OP_2), not 0x51 (OP_1 / taproot). + // This verifies P2MR is distinguishable from P2TR at the scriptPubKey level. + let triple = pub_triple_from_hex( + "02d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7", + "028714039c6866c27eb6885ffbb4085964a603140e5a39b0fa29b1d9839212f9a2", + "03203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64", + ); + let script = ScriptP2mr::new(&triple); + let spk_bytes = script.output_script().to_bytes(); + assert_eq!( + spk_bytes[0], 0x52, + "P2MR scriptPubKey must start with OP_2 (0x52)" + ); + assert_eq!(spk_bytes.len(), 34, "P2MR scriptPubKey must be 34 bytes"); + + // Compare: P2TR for same keys would start with 0x51 + let p2tr = ScriptP2tr::new(&triple, false); + assert_eq!( + p2tr.output_script().to_bytes()[0], + 0x51, + "P2TR scriptPubKey must start with OP_1 (0x51)" + ); + + // P2MR and P2TR produce different output scripts + assert_ne!( + script.output_script().to_hex_string(), + p2tr.output_script().to_hex_string(), + "P2MR and P2TR must produce different output scripts" + ); + } + fn test_p2tr_output_scripts_helper(script_type: &str, use_musig2: bool) { let fixtures = load_fixture_p2tr_output_scripts(script_type) .unwrap_or_else(|_| panic!("Failed to load {} output script fixtures", script_type)); diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs index ce01fd988e5..d2443b62352 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs @@ -11,7 +11,7 @@ pub use checkmultisig::{ }; pub use checksigverify::{ build_p2tr_ns_script, build_tap_tree_for_output, create_tap_bip32_derivation_for_output, - ScriptP2tr, + ScriptP2mr, ScriptP2tr, }; pub use singlesig::{build_p2pk_script, ScriptP2shP2pk}; @@ -38,6 +38,8 @@ pub enum WalletScripts { P2trLegacy(ScriptP2tr), /// Chains 40 and 41. Taproot with Musig2 key-path spend support. P2trMusig2(ScriptP2tr), + /// Chains 360 and 361. BIP-360 Pay-to-Merkle-Root (P2MR). + P2mr(ScriptP2mr), } impl std::fmt::Display for WalletScripts { @@ -51,6 +53,7 @@ impl std::fmt::Display for WalletScripts { WalletScripts::P2wsh(_) => "P2wsh".to_string(), WalletScripts::P2trLegacy(_) => "P2trLegacy".to_string(), WalletScripts::P2trMusig2(_) => "P2trMusig2".to_string(), + WalletScripts::P2mr(_) => "P2mr".to_string(), } ) } @@ -93,6 +96,10 @@ impl WalletScripts { script_support.assert_taproot()?; Ok(WalletScripts::P2trMusig2(ScriptP2tr::new(keys, true))) } + OutputScriptType::P2mr => { + script_support.assert_p2mr()?; + Ok(WalletScripts::P2mr(ScriptP2mr::new(keys))) + } } } @@ -115,6 +122,7 @@ impl WalletScripts { WalletScripts::P2wsh(script) => script.witness_script.to_p2wsh(), WalletScripts::P2trLegacy(script) => script.output_script(), WalletScripts::P2trMusig2(script) => script.output_script(), + WalletScripts::P2mr(script) => script.output_script(), } } } @@ -154,6 +162,7 @@ impl Chain { OutputScriptType::P2wsh => 20, OutputScriptType::P2trLegacy => 30, OutputScriptType::P2trMusig2 => 40, + OutputScriptType::P2mr => 360, }) + match self.scope { Scope::External => 0, Scope::Internal => 1, @@ -176,6 +185,8 @@ impl TryFrom for Chain { 31 => (OutputScriptType::P2trLegacy, Scope::Internal), 40 => (OutputScriptType::P2trMusig2, Scope::External), 41 => (OutputScriptType::P2trMusig2, Scope::Internal), + 360 => (OutputScriptType::P2mr, Scope::External), + 361 => (OutputScriptType::P2mr, Scope::Internal), _ => return Err(format!("no chain for {}", value)), }; Ok(Chain::new(script_type, scope)) @@ -207,15 +218,18 @@ pub enum OutputScriptType { P2trLegacy, /// Taproot with MuSig2 key-path support (chains 40, 41) P2trMusig2, + /// BIP-360 Pay-to-Merkle-Root (chains 360, 361) + P2mr, } /// All OutputScriptType variants for iteration. -const ALL_SCRIPT_TYPES: [OutputScriptType; 5] = [ +const ALL_SCRIPT_TYPES: [OutputScriptType; 6] = [ OutputScriptType::P2sh, OutputScriptType::P2shP2wsh, OutputScriptType::P2wsh, OutputScriptType::P2trLegacy, OutputScriptType::P2trMusig2, + OutputScriptType::P2mr, ]; impl FromStr for OutputScriptType { @@ -235,11 +249,12 @@ impl FromStr for OutputScriptType { // "p2tr" is kept as alias for backwards compatibility "p2tr" | "p2trLegacy" => Ok(OutputScriptType::P2trLegacy), "p2trMusig2" => Ok(OutputScriptType::P2trMusig2), + "p2mr" => Ok(OutputScriptType::P2mr), // Input script types (normalized to output types) "p2shP2pk" => Ok(OutputScriptType::P2sh), "p2trMusig2ScriptPath" | "p2trMusig2KeyPath" => Ok(OutputScriptType::P2trMusig2), _ => Err(format!( - "Unknown script type '{}'. Expected: p2sh, p2shP2wsh, p2wsh, p2trLegacy, p2trMusig2", + "Unknown script type '{}'. Expected: p2sh, p2shP2wsh, p2wsh, p2trLegacy, p2trMusig2, p2mr", s )), } @@ -248,7 +263,7 @@ impl FromStr for OutputScriptType { impl OutputScriptType { /// Returns all possible OutputScriptType values. - pub fn all() -> &'static [OutputScriptType; 5] { + pub fn all() -> &'static [OutputScriptType; 6] { &ALL_SCRIPT_TYPES } @@ -260,6 +275,7 @@ impl OutputScriptType { OutputScriptType::P2wsh => "p2wsh", OutputScriptType::P2trLegacy => "p2trLegacy", OutputScriptType::P2trMusig2 => "p2trMusig2", + OutputScriptType::P2mr => "p2mr", } } } @@ -305,7 +321,7 @@ mod tests { use crate::fixed_script_wallet::wallet_keys::tests::get_test_wallet_keys; use crate::Network; - const ALL_CHAINS: [Chain; 10] = [ + const ALL_CHAINS: [Chain; 12] = [ Chain::new(OutputScriptType::P2sh, Scope::External), Chain::new(OutputScriptType::P2sh, Scope::Internal), Chain::new(OutputScriptType::P2shP2wsh, Scope::External), @@ -316,6 +332,8 @@ mod tests { Chain::new(OutputScriptType::P2trLegacy, Scope::Internal), Chain::new(OutputScriptType::P2trMusig2, Scope::External), Chain::new(OutputScriptType::P2trMusig2, Scope::Internal), + Chain::new(OutputScriptType::P2mr, Scope::External), + Chain::new(OutputScriptType::P2mr, Scope::Internal), ]; fn assert_output_script(keys: &RootWalletKeys, chain: Chain, expected_script: &str) { @@ -357,6 +375,12 @@ mod tests { (P2trMusig2, Internal) => { "51202629eea5dbef6841160a0b752dedd4b8e206f046835ee944848679d6dea2ac2c" } + (P2mr, External) => { + "5220e66329f43aaf6a473df4f49636836c651a410f51be31b54069922f0f71613140" + } + (P2mr, Internal) => { + "5220d60831a20b0f5b1ccb9bec86527714199ffd8c00a344c195fa0de8184fbd80e8" + } }; assert_output_script(keys, chain, expected); } diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs index 98513129647..0bfaf6189ac 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs @@ -146,6 +146,24 @@ fn get_p2tr_script_path_components(level: usize) -> (Vec, Vec) { ) } +/// Get P2MR script path spend witness components at the given merkle tree depth. +/// +/// P2MR control blocks are `1 + 32 * depth` bytes (no 32-byte internal key, +/// unlike P2TR which is `1 + 32 + 32 * depth`). +fn get_p2mr_script_path_components(depth: usize) -> (Vec, Vec) { + let leaf_script = OP_PUSH_SIZE + + SCHNORR_PUBKEY_SIZE + + OP_CHECKSIG_SIZE + + OP_PUSH_SIZE + + SCHNORR_PUBKEY_SIZE + + OP_CHECKSIGVERIFY_SIZE; + let control_block = 1 + 32 * depth; // header(1) + path(32 * depth) — no internal key + ( + vec![], + vec![SCHNORR_SIG, SCHNORR_SIG, leaf_script, control_block], + ) +} + /// Get p2tr keypath spend components (single aggregated Schnorr signature) fn get_p2tr_keypath_components() -> (Vec, Vec) { (vec![], vec![SCHNORR_SIG]) @@ -241,6 +259,16 @@ fn get_input_weights_for_type(script_type: InputScriptType, compat: bool) -> Inp is_segwit: true, } } + InputScriptType::P2mr => { + // P2MR script path at depth 1 (primary user+bitgo spend) + let (script, witness) = get_p2mr_script_path_components(1); + let w = compute_input_weight(&script, &witness); + InputWeights { + min: w, + max: w, + is_segwit: true, + } + } InputScriptType::P2shP2pk => { let min = compute_input_weight(&get_p2sh_p2pk_components(ECDSA_SIG_MIN, false), &[]); let max = compute_input_weight(&get_p2sh_p2pk_components(sig_max, compat), &[]); @@ -282,6 +310,19 @@ fn get_input_weights_for_chain( is_segwit: true, }) } + OutputScriptType::P2mr => { + // P2MR - script path only (no key-path spend). + // Primary spend (user+bitgo) is at depth 1, recovery paths at depth 2. + let is_recovery = cosigner == Some("backup"); + let depth = if is_recovery { 2 } else { 1 }; + let (script, witness) = get_p2mr_script_path_components(depth); + let w = compute_input_weight(&script, &witness); + Ok(InputWeights { + min: w, + max: w, + is_segwit: true, + }) + } OutputScriptType::P2trMusig2 => { // p2trMusig2 - keypath for user+bitgo, scriptpath for user+backup let is_recovery = cosigner == Some("backup"); @@ -316,6 +357,7 @@ fn parse_script_type(script_type: &str) -> Result { "p2trMusig2KeyPath" => Ok(InputScriptType::P2trMusig2KeyPath), "p2trMusig2ScriptPath" => Ok(InputScriptType::P2trMusig2ScriptPath), "p2shP2pk" => Ok(InputScriptType::P2shP2pk), + "p2mr" => Ok(InputScriptType::P2mr), _ => Err(format!("Unknown script type: {}", script_type)), } } @@ -400,6 +442,7 @@ impl WasmDimensions { InputScriptType::P2trMusig2KeyPath } } + OutputScriptType::P2mr => InputScriptType::P2mr, }; get_input_weights_for_type(script_type, false) @@ -505,6 +548,8 @@ impl WasmDimensions { OutputScriptType::P2wsh => 34, // P2TR: OP_1 [32 bytes] = 34 bytes OutputScriptType::P2trLegacy | OutputScriptType::P2trMusig2 => 34, + // P2MR: OP_2 [32 bytes] = 34 bytes + OutputScriptType::P2mr => 34, }; Ok(Self::from_output_script_length(length)) } 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 713f010a7c5..5592b45e506 100644 --- a/packages/wasm-utxo/src/wasm/try_into_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_into_js_value.rs @@ -349,6 +349,7 @@ impl TryIntoJsValue for crate::fixed_script_wallet::bitgo_psbt::InputScriptType InputScriptType::P2trLegacy => "p2trLegacy", InputScriptType::P2trMusig2ScriptPath => "p2trMusig2ScriptPath", InputScriptType::P2trMusig2KeyPath => "p2trMusig2KeyPath", + InputScriptType::P2mr => "p2mr", }; Ok(JsValue::from_str(script_type)) }