Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/wasm-utxo/src/address/networks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions packages/wasm-utxo/src/bip322/bitgo_psbt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) =
Expand Down
17 changes: 17 additions & 0 deletions packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(_));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,7 @@ impl InputScriptType {
Ok(InputScriptType::P2trMusig2KeyPath)
}
}
OutputScriptType::P2mr => Err("P2MR PSBT signing is not yet supported".to_string()),
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -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<crate::p2mr::P2mrLeafInfo>,
}

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 <merkle_root>`.
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<merkle_root>`.
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<P2mrFixture> {
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 <merkle_root>
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));
Expand Down
Loading