Skip to content

Commit be276f1

Browse files
authored
Merge pull request #246 from BitGo/BTC-3241-bip360-p2mr-support
feat: add BIP-360 P2MR address encoding and Merkle tree support
2 parents 2e80e26 + 7a36520 commit be276f1

16 files changed

Lines changed: 1477 additions & 85 deletions

File tree

packages/wasm-utxo/bips/bip-0360/bip-0360.mediawiki

Lines changed: 430 additions & 0 deletions
Large diffs are not rendered by default.

packages/wasm-utxo/src/address/bech32.rs

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@
22
//!
33
//! Implements BIP 173 (Bech32) and BIP 350 (Bech32m) encoding schemes using the bitcoin crate.
44
//! - Bech32 is used for witness version 0 (P2WPKH, P2WSH)
5-
//! - Bech32m is used for witness version 1+ (P2TR)
5+
//! - Bech32m is used for witness version 1+ (P2TR, P2MR)
66
77
use super::{AddressCodec, AddressError, Result};
88
use crate::bitcoin::{Script, ScriptBuf, WitnessVersion};
99

10+
/// Check if a script is a P2MR (BIP-360) witness v2 program.
11+
/// P2MR: OP_2 (0x52) | OP_PUSHBYTES_32 (0x20) | <32-byte merkle root> = 34 bytes
12+
pub(crate) fn is_p2mr(script: &Script) -> bool {
13+
script.len() == 34
14+
&& script.witness_version() == Some(WitnessVersion::V2)
15+
&& script.as_bytes()[1] == 0x20
16+
}
17+
1018
/// Bech32/Bech32m codec for witness addresses
1119
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1220
pub struct Bech32Codec {
@@ -35,16 +43,12 @@ pub fn encode_witness_with_custom_hrp(
3543
let hrp = Hrp::parse(hrp_str)
3644
.map_err(|e| AddressError::Bech32Error(format!("Invalid HRP '{}': {}", hrp_str, e)))?;
3745

38-
// Encode based on witness version
39-
let address = if version == WitnessVersion::V0 {
40-
// Use Bech32 for witness version 0
41-
bech32::segwit::encode_v0(hrp, program)
42-
.map_err(|e| AddressError::Bech32Error(format!("Bech32 encoding failed: {}", e)))?
43-
} else {
44-
// Use Bech32m for witness version 1+
45-
bech32::segwit::encode_v1(hrp, program)
46-
.map_err(|e| AddressError::Bech32Error(format!("Bech32m encoding failed: {}", e)))?
47-
};
46+
// Encode using generic segwit encode which handles any witness version.
47+
// v0 uses Bech32, v1+ uses Bech32m (BIP 350).
48+
let version_fe32 = bech32::Fe32::try_from(version.to_num())
49+
.map_err(|e| AddressError::Bech32Error(format!("Invalid witness version: {}", e)))?;
50+
let address = bech32::segwit::encode(hrp, version_fe32, program)
51+
.map_err(|e| AddressError::Bech32Error(format!("Bech32 encoding failed: {}", e)))?;
4852

4953
Ok(address)
5054
}
@@ -72,9 +76,11 @@ pub fn extract_witness_program(script: &Script) -> Result<(WitnessVersion, &[u8]
7276
));
7377
}
7478
Ok((WitnessVersion::V1, &script.as_bytes()[2..34]))
79+
} else if is_p2mr(script) {
80+
Ok((WitnessVersion::V2, &script.as_bytes()[2..34]))
7581
} else {
7682
Err(AddressError::UnsupportedScriptType(
77-
"Bech32 only supports witness programs (P2WPKH, P2WSH, P2TR)".to_string(),
83+
"Bech32 only supports witness programs (P2WPKH, P2WSH, P2TR, P2MR)".to_string(),
7884
))
7985
}
8086
}

packages/wasm-utxo/src/address/mod.rs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,11 @@ mod tests {
290290
let script_obj = Script::from_bytes(script);
291291
if script_obj.is_p2pkh() || script_obj.is_p2sh() {
292292
from_output_script(script_obj, &BITCOIN)
293-
} else if script_obj.is_p2wpkh() || script_obj.is_p2wsh() || script_obj.is_p2tr() {
293+
} else if script_obj.is_p2wpkh()
294+
|| script_obj.is_p2wsh()
295+
|| script_obj.is_p2tr()
296+
|| bech32::is_p2mr(script_obj)
297+
{
294298
from_output_script(script_obj, &BITCOIN_BECH32)
295299
} else {
296300
Err(AddressError::UnsupportedScriptType(format!(
@@ -310,7 +314,11 @@ mod tests {
310314
let script_obj = Script::from_bytes(script);
311315
if script_obj.is_p2pkh() || script_obj.is_p2sh() {
312316
from_output_script(script_obj, &TESTNET)
313-
} else if script_obj.is_p2wpkh() || script_obj.is_p2wsh() || script_obj.is_p2tr() {
317+
} else if script_obj.is_p2wpkh()
318+
|| script_obj.is_p2wsh()
319+
|| script_obj.is_p2tr()
320+
|| bech32::is_p2mr(script_obj)
321+
{
314322
from_output_script(script_obj, &TESTNET_BECH32)
315323
} else {
316324
Err(AddressError::UnsupportedScriptType(format!(
@@ -325,7 +333,11 @@ mod tests {
325333
let script_obj = Script::from_bytes(script);
326334
if script_obj.is_p2pkh() || script_obj.is_p2sh() {
327335
from_output_script(script_obj, &LITECOIN)
328-
} else if script_obj.is_p2wpkh() || script_obj.is_p2wsh() || script_obj.is_p2tr() {
336+
} else if script_obj.is_p2wpkh()
337+
|| script_obj.is_p2wsh()
338+
|| script_obj.is_p2tr()
339+
|| bech32::is_p2mr(script_obj)
340+
{
329341
from_output_script(script_obj, &LITECOIN_BECH32)
330342
} else {
331343
Err(AddressError::UnsupportedScriptType(format!(
@@ -483,7 +495,11 @@ mod tests {
483495
// For networks with both base58 and bech32, choose based on script type
484496
let codec = if script_obj.is_p2pkh() || script_obj.is_p2sh() {
485497
codecs[0]
486-
} else if script_obj.is_p2wpkh() || script_obj.is_p2wsh() || script_obj.is_p2tr() {
498+
} else if script_obj.is_p2wpkh()
499+
|| script_obj.is_p2wsh()
500+
|| script_obj.is_p2tr()
501+
|| bech32::is_p2mr(script_obj)
502+
{
487503
// Use bech32 codec if available (index 1), otherwise fall back to base58
488504
if codecs.len() > 1 {
489505
codecs[1]

packages/wasm-utxo/src/address/networks.rs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
//! This module bridges the Network enum with address codecs, providing
44
//! convenient functions to encode/decode addresses using network identifiers.
55
6+
use super::bech32::is_p2mr;
67
use super::{
78
from_output_script, to_output_script_try_codecs, AddressCodec, AddressError, Result, ScriptBuf,
89
BITCOIN, BITCOIN_BECH32, BITCOIN_CASH, BITCOIN_CASH_CASHADDR, BITCOIN_CASH_TESTNET,
@@ -76,6 +77,7 @@ impl AddressFormat {
7677
pub struct OutputScriptSupport {
7778
pub segwit: bool,
7879
pub taproot: bool,
80+
pub p2mr: bool,
7981
}
8082

8183
impl OutputScriptSupport {
@@ -102,6 +104,15 @@ impl OutputScriptSupport {
102104
Ok(())
103105
}
104106

107+
pub(crate) fn assert_p2mr(&self) -> Result<()> {
108+
if !self.p2mr {
109+
return Err(AddressError::UnsupportedScriptType(
110+
"Network does not support P2MR".to_string(),
111+
));
112+
}
113+
Ok(())
114+
}
115+
105116
pub fn assert_support(&self, script: &Script) -> Result<()> {
106117
match script.witness_version() {
107118
None => {
@@ -113,6 +124,9 @@ impl OutputScriptSupport {
113124
Some(WitnessVersion::V1) => {
114125
self.assert_taproot()?;
115126
}
127+
Some(WitnessVersion::V2) => {
128+
self.assert_p2mr()?;
129+
}
116130
_ => {
117131
return Err(AddressError::UnsupportedScriptType(
118132
"Unsupported witness version".to_string(),
@@ -170,7 +184,16 @@ impl Network {
170184
// - https://github.com/litecoin-project/litecoin/blob/v0.21.4/src/script/interpreter.h#L129-L131
171185
let taproot = segwit && matches!(self.mainnet(), Network::Bitcoin);
172186

173-
OutputScriptSupport { segwit, taproot }
187+
// P2MR (BIP-360) support:
188+
// Enabled on all Bitcoin networks (mainnet + testnets) for address encoding.
189+
// Backend activation is controlled separately.
190+
let p2mr = matches!(self.mainnet(), Network::Bitcoin);
191+
192+
OutputScriptSupport {
193+
segwit,
194+
taproot,
195+
p2mr,
196+
}
174197
}
175198
}
176199

@@ -182,12 +205,13 @@ fn get_encode_codec(
182205
) -> Result<&'static dyn AddressCodec> {
183206
network.output_script_support().assert_support(script)?;
184207

185-
let is_witness = script.is_p2wpkh() || script.is_p2wsh() || script.is_p2tr();
208+
let is_witness = script.is_p2wpkh() || script.is_p2wsh() || script.is_p2tr() || is_p2mr(script);
186209
let is_legacy = script.is_p2pkh() || script.is_p2sh();
187210

188211
if !is_witness && !is_legacy {
189212
return Err(AddressError::UnsupportedScriptType(
190-
"Script is not a standard address type (P2PKH, P2SH, P2WPKH, P2WSH, P2TR)".to_string(),
213+
"Script is not a standard address type (P2PKH, P2SH, P2WPKH, P2WSH, P2TR, P2MR)"
214+
.to_string(),
191215
));
192216
}
193217

@@ -554,12 +578,14 @@ mod tests {
554578
let support_none = OutputScriptSupport {
555579
segwit: false,
556580
taproot: false,
581+
p2mr: false,
557582
};
558583
assert!(support_none.assert_legacy().is_ok());
559584

560585
let support_all = OutputScriptSupport {
561586
segwit: true,
562587
taproot: true,
588+
p2mr: false,
563589
};
564590
assert!(support_all.assert_legacy().is_ok());
565591
}
@@ -570,13 +596,15 @@ mod tests {
570596
let support_segwit = OutputScriptSupport {
571597
segwit: true,
572598
taproot: false,
599+
p2mr: false,
573600
};
574601
assert!(support_segwit.assert_segwit().is_ok());
575602

576603
// Should fail when segwit is not supported
577604
let no_support = OutputScriptSupport {
578605
segwit: false,
579606
taproot: false,
607+
p2mr: false,
580608
};
581609
let result = no_support.assert_segwit();
582610
assert!(result.is_err());
@@ -592,13 +620,15 @@ mod tests {
592620
let support_taproot = OutputScriptSupport {
593621
segwit: true,
594622
taproot: true,
623+
p2mr: false,
595624
};
596625
assert!(support_taproot.assert_taproot().is_ok());
597626

598627
// Should fail when taproot is not supported
599628
let no_support = OutputScriptSupport {
600629
segwit: true,
601630
taproot: false,
631+
p2mr: false,
602632
};
603633
let result = no_support.assert_taproot();
604634
assert!(result.is_err());
@@ -619,6 +649,7 @@ mod tests {
619649
let no_support = OutputScriptSupport {
620650
segwit: false,
621651
taproot: false,
652+
p2mr: false,
622653
};
623654
assert!(no_support.assert_support(&p2pkh_script).is_ok());
624655

@@ -640,13 +671,15 @@ mod tests {
640671
let support_segwit = OutputScriptSupport {
641672
segwit: true,
642673
taproot: false,
674+
p2mr: false,
643675
};
644676
assert!(support_segwit.assert_support(&p2wpkh_script).is_ok());
645677

646678
// Should fail without segwit support
647679
let no_support = OutputScriptSupport {
648680
segwit: false,
649681
taproot: false,
682+
p2mr: false,
650683
};
651684
let result = no_support.assert_support(&p2wpkh_script);
652685
assert!(result.is_err());
@@ -685,13 +718,15 @@ mod tests {
685718
let support_taproot = OutputScriptSupport {
686719
segwit: true,
687720
taproot: true,
721+
p2mr: false,
688722
};
689723
assert!(support_taproot.assert_support(&p2tr_script).is_ok());
690724

691725
// Should fail without taproot support (but with segwit)
692726
let no_taproot = OutputScriptSupport {
693727
segwit: true,
694728
taproot: false,
729+
p2mr: false,
695730
};
696731
let result = no_taproot.assert_support(&p2tr_script);
697732
assert!(result.is_err());
@@ -704,6 +739,7 @@ mod tests {
704739
let no_support = OutputScriptSupport {
705740
segwit: false,
706741
taproot: false,
742+
p2mr: false,
707743
};
708744
let result = no_support.assert_support(&p2tr_script);
709745
assert!(result.is_err());

packages/wasm-utxo/src/address/utxolib_compat.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ impl UtxolibNetwork {
3939
.as_ref()
4040
.is_some_and(|bech32| bech32 == "bc" || bech32 == "tb");
4141

42-
OutputScriptSupport { segwit, taproot }
42+
// P2MR not supported via utxolib compat layer (only via Network enum)
43+
OutputScriptSupport {
44+
segwit,
45+
taproot,
46+
p2mr: false,
47+
}
4348
}
4449
}
4550

packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5051,9 +5051,9 @@ mod tests {
50515051
}
50525052

50535053
// If both have non_witness_utxo, compare the relevant output
5054-
if orig.non_witness_utxo.is_some() && recon.non_witness_utxo.is_some() {
5055-
let orig_tx = orig.non_witness_utxo.as_ref().unwrap();
5056-
let recon_tx = recon.non_witness_utxo.as_ref().unwrap();
5054+
if let (Some(orig_tx), Some(recon_tx)) =
5055+
(&orig.non_witness_utxo, &recon.non_witness_utxo)
5056+
{
50575057
let vout = original_tx.input[idx].previous_output.vout as usize;
50585058
assert_eq!(
50595059
orig_tx.output.get(vout),

packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ mod tests {
377377
let no_segwit_support = OutputScriptSupport {
378378
segwit: false,
379379
taproot: false,
380+
p2mr: false,
380381
};
381382

382383
use OutputScriptType::*;
@@ -410,6 +411,7 @@ mod tests {
410411
let no_taproot_support = OutputScriptSupport {
411412
segwit: true,
412413
taproot: false,
414+
p2mr: false,
413415
};
414416

415417
let result = WalletScripts::from_wallet_keys(

packages/wasm-utxo/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod inscriptions;
88
pub mod inspect;
99
pub mod message;
1010
mod networks;
11+
pub mod p2mr;
1112
pub mod paygo;
1213
pub mod psbt_ops;
1314
#[cfg(test)]

0 commit comments

Comments
 (0)