Skip to content

Commit e2330d5

Browse files
committed
feat: add P2MR (BIP-360) address encoding and decoding
Add witness v2 (P2MR) support to the address encoding layer: - Switch bech32 encoding from version-specific to generic segwit::encode() supporting all witness versions (v0-v2+) - Add P2MR script detection (34 bytes, witness v2, OP_PUSHBYTES_32) - Add OutputScriptSupport.p2mr flag, enabled only on BitGo Signet - Create separate bitcoinBitGoSignet.json fixture file with P2MR address test vectors (tb1z prefix) - Filter P2MR from utxolib compat test path (not supported there) BTC-3241
1 parent 871430e commit e2330d5

7 files changed

Lines changed: 229 additions & 24 deletions

File tree

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: 21 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!(
@@ -450,6 +462,7 @@ mod tests {
450462
"bitcoin.json" => vec![&BITCOIN as &dyn AddressCodec, &BITCOIN_BECH32],
451463
"testnet.json" => vec![&TESTNET, &TESTNET_BECH32],
452464
"bitcoinPublicSignet.json" => vec![&TESTNET, &TESTNET_BECH32],
465+
"bitcoinBitGoSignet.json" => vec![&TESTNET, &TESTNET_BECH32],
453466
"bitcoincash.json" => vec![&BITCOIN_CASH],
454467
"bitcoincash-cashaddr.json" => vec![&BITCOIN_CASH_CASHADDR],
455468
"bitcoincashTestnet.json" => vec![&BITCOIN_CASH_TESTNET],
@@ -483,7 +496,11 @@ mod tests {
483496
// For networks with both base58 and bech32, choose based on script type
484497
let codec = if script_obj.is_p2pkh() || script_obj.is_p2sh() {
485498
codecs[0]
486-
} else if script_obj.is_p2wpkh() || script_obj.is_p2wsh() || script_obj.is_p2tr() {
499+
} else if script_obj.is_p2wpkh()
500+
|| script_obj.is_p2wsh()
501+
|| script_obj.is_p2tr()
502+
|| bech32::is_p2mr(script_obj)
503+
{
487504
// Use bech32 codec if available (index 1), otherwise fall back to base58
488505
if codecs.len() > 1 {
489506
codecs[1]

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

Lines changed: 38 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,15 @@ 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+
// Currently only enabled on the BitGo Signet for testing.
189+
let p2mr = matches!(self, Network::BitcoinBitGoSignet);
190+
191+
OutputScriptSupport {
192+
segwit,
193+
taproot,
194+
p2mr,
195+
}
174196
}
175197
}
176198

@@ -182,12 +204,13 @@ fn get_encode_codec(
182204
) -> Result<&'static dyn AddressCodec> {
183205
network.output_script_support().assert_support(script)?;
184206

185-
let is_witness = script.is_p2wpkh() || script.is_p2wsh() || script.is_p2tr();
207+
let is_witness = script.is_p2wpkh() || script.is_p2wsh() || script.is_p2tr() || is_p2mr(script);
186208
let is_legacy = script.is_p2pkh() || script.is_p2sh();
187209

188210
if !is_witness && !is_legacy {
189211
return Err(AddressError::UnsupportedScriptType(
190-
"Script is not a standard address type (P2PKH, P2SH, P2WPKH, P2WSH, P2TR)".to_string(),
212+
"Script is not a standard address type (P2PKH, P2SH, P2WPKH, P2WSH, P2TR, P2MR)"
213+
.to_string(),
191214
));
192215
}
193216

@@ -554,12 +577,14 @@ mod tests {
554577
let support_none = OutputScriptSupport {
555578
segwit: false,
556579
taproot: false,
580+
p2mr: false,
557581
};
558582
assert!(support_none.assert_legacy().is_ok());
559583

560584
let support_all = OutputScriptSupport {
561585
segwit: true,
562586
taproot: true,
587+
p2mr: false,
563588
};
564589
assert!(support_all.assert_legacy().is_ok());
565590
}
@@ -570,13 +595,15 @@ mod tests {
570595
let support_segwit = OutputScriptSupport {
571596
segwit: true,
572597
taproot: false,
598+
p2mr: false,
573599
};
574600
assert!(support_segwit.assert_segwit().is_ok());
575601

576602
// Should fail when segwit is not supported
577603
let no_support = OutputScriptSupport {
578604
segwit: false,
579605
taproot: false,
606+
p2mr: false,
580607
};
581608
let result = no_support.assert_segwit();
582609
assert!(result.is_err());
@@ -592,13 +619,15 @@ mod tests {
592619
let support_taproot = OutputScriptSupport {
593620
segwit: true,
594621
taproot: true,
622+
p2mr: false,
595623
};
596624
assert!(support_taproot.assert_taproot().is_ok());
597625

598626
// Should fail when taproot is not supported
599627
let no_support = OutputScriptSupport {
600628
segwit: true,
601629
taproot: false,
630+
p2mr: false,
602631
};
603632
let result = no_support.assert_taproot();
604633
assert!(result.is_err());
@@ -619,6 +648,7 @@ mod tests {
619648
let no_support = OutputScriptSupport {
620649
segwit: false,
621650
taproot: false,
651+
p2mr: false,
622652
};
623653
assert!(no_support.assert_support(&p2pkh_script).is_ok());
624654

@@ -640,13 +670,15 @@ mod tests {
640670
let support_segwit = OutputScriptSupport {
641671
segwit: true,
642672
taproot: false,
673+
p2mr: false,
643674
};
644675
assert!(support_segwit.assert_support(&p2wpkh_script).is_ok());
645676

646677
// Should fail without segwit support
647678
let no_support = OutputScriptSupport {
648679
segwit: false,
649680
taproot: false,
681+
p2mr: false,
650682
};
651683
let result = no_support.assert_support(&p2wpkh_script);
652684
assert!(result.is_err());
@@ -685,13 +717,15 @@ mod tests {
685717
let support_taproot = OutputScriptSupport {
686718
segwit: true,
687719
taproot: true,
720+
p2mr: false,
688721
};
689722
assert!(support_taproot.assert_support(&p2tr_script).is_ok());
690723

691724
// Should fail without taproot support (but with segwit)
692725
let no_taproot = OutputScriptSupport {
693726
segwit: true,
694727
taproot: false,
728+
p2mr: false,
695729
};
696730
let result = no_taproot.assert_support(&p2tr_script);
697731
assert!(result.is_err());
@@ -704,6 +738,7 @@ mod tests {
704738
let no_support = OutputScriptSupport {
705739
segwit: false,
706740
taproot: false,
741+
p2mr: false,
707742
};
708743
let result = no_support.assert_support(&p2tr_script);
709744
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/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/test/address/utxolibCompat.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,6 @@ const __dirname = dirname(__filename);
1414
type Fixture = [type: string, script: string, address: string];
1515

1616
async function getFixtures(name: string, addressFormat?: AddressFormat): Promise<Fixture[]> {
17-
if (name === "bitcoinBitGoSignet") {
18-
name = "bitcoinPublicSignet";
19-
}
2017
const filename = addressFormat ? `${name}-${addressFormat}` : name;
2118
const fixturePath = path.join(__dirname, "..", "fixtures", "address", `${filename}.json`);
2219
const fixtures = await fs.readFile(fixturePath, "utf8");
@@ -33,7 +30,9 @@ function runTest(network: utxolib.Network, addressFormat?: AddressFormat) {
3330
});
3431

3532
it("should convert to utxolib compatible network", function () {
36-
for (const fixture of fixtures) {
33+
// P2MR is not supported via the utxolib compat layer (only via Network enum)
34+
const compatFixtures = fixtures.filter(([type]) => type !== "p2mr");
35+
for (const fixture of compatFixtures) {
3736
const [, script, addressRef] = fixture;
3837
const scriptBuf = Buffer.from(script, "hex");
3938
const address = utxolibCompat.fromOutputScript(scriptBuf, network, addressFormat);

0 commit comments

Comments
 (0)