Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion packages/wasm-utxo/js/fixedScriptWallet/chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { FixedScriptWalletNamespace } from "../wasm/wasm_utxo.js";
import type { OutputScriptType } from "./scriptType.js";

/** All valid chain codes as a const tuple */
export const chainCodes = [0, 1, 10, 11, 20, 21, 30, 31, 40, 41] as const;
export const chainCodes = [0, 1, 10, 11, 20, 21, 30, 31, 40, 41, 360, 361] as const;

/** A valid chain code value */
export type ChainCode = (typeof chainCodes)[number];
Expand Down
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());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's add it in this PR

}
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 @@ -1014,6 +1014,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 @@ -1168,6 +1171,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 @@ -751,6 +751,7 @@ pub enum InputScriptType {
P2trLegacy,
P2trMusig2ScriptPath,
P2trMusig2KeyPath,
P2mr,
}

impl InputScriptType {
Expand All @@ -769,6 +770,7 @@ impl InputScriptType {
Ok(InputScriptType::P2trMusig2KeyPath)
}
}
OutputScriptType::P2mr => Ok(InputScriptType::P2mr),
}
}

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
Loading