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..5711407e1ad 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 @@ -769,6 +769,7 @@ impl InputScriptType { Ok(InputScriptType::P2trMusig2KeyPath) } } + OutputScriptType::P2mr => Err("P2MR PSBT signing is not yet supported".to_string()), } } 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..7666e87ce15 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", } } } @@ -357,6 +373,7 @@ mod tests { (P2trMusig2, Internal) => { "51202629eea5dbef6841160a0b752dedd4b8e206f046835ee944848679d6dea2ac2c" } + (P2mr, _) => unreachable!("P2mr not included in ALL_CHAINS"), }; 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..f7297653b40 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]) @@ -282,6 +300,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"); @@ -400,6 +431,12 @@ impl WasmDimensions { InputScriptType::P2trMusig2KeyPath } } + OutputScriptType::P2mr => { + // P2MR signing is not yet supported in PSBT flow. + // Fall back to P2trLegacy weight estimate as a conservative proxy + // (same script structure: 2-of-2 checksigverify leaves). + InputScriptType::P2trLegacy + } }; get_input_weights_for_type(script_type, false) @@ -505,6 +542,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)) }