From b172925e52d5087f689fd0fef35b40a8684a898e Mon Sep 17 00:00:00 2001 From: James Date: Tue, 11 Aug 2026 13:04:15 -0400 Subject: [PATCH] feat(bip32): add BIP-328 MuSig2 synthetic xpub support Adds ChainCode::musig2_synthetic_root() (the fixed BIP-328 chain code), XPub::synthetic_musig2_root() to construct a synthetic root from a MuSig2 aggregate pubkey, and XPub::is_musig2_synthetic_root() to identify one. Verified against the BIP-328 spec's own test vector. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01RxoDav1hztgxJGcSGuCqKv --- crates/bip32/src/primitives.rs | 30 +++++++++++++++ crates/bip32/src/xkeys.rs | 67 ++++++++++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/crates/bip32/src/primitives.rs b/crates/bip32/src/primitives.rs index c3596a9b..cf95a236 100644 --- a/crates/bip32/src/primitives.rs +++ b/crates/bip32/src/primitives.rs @@ -73,6 +73,19 @@ impl From<[u8; 32]> for ChainCode { } } +impl ChainCode { + /// The chain code fixed by BIP-328 for MuSig2 synthetic xpubs: `sha256("MuSig2MuSig2MuSig2")`. + /// + /// See . + pub const fn musig2_synthetic_root() -> Self { + Self([ + 0x86, 0x80, 0x87, 0xca, 0x02, 0xa6, 0xf9, 0x74, 0xc4, 0x59, 0x89, 0x24, 0xc3, 0x6b, + 0x57, 0x76, 0x2d, 0x32, 0xcb, 0x45, 0x71, 0x71, 0x67, 0xe3, 0x00, 0x62, 0x2c, 0x71, + 0x67, 0xe3, 0x89, 0x65, + ]) + } +} + /// Info associated with an extended key #[derive(Copy, Clone, Debug)] pub struct XKeyInfo { @@ -97,3 +110,20 @@ impl PartialEq for XKeyInfo { && self.chain_code == other.chain_code } } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn musig2_synthetic_root_chain_code_matches_bip328() { + // BIP-328: fixed chain code for synthetic xpubs is sha256("MuSig2MuSig2MuSig2") + // https://github.com/bitcoin/bips/blob/master/bip-0328.mediawiki + let expected: [u8; 32] = [ + 0x86, 0x80, 0x87, 0xca, 0x02, 0xa6, 0xf9, 0x74, 0xc4, 0x59, 0x89, 0x24, 0xc3, 0x6b, + 0x57, 0x76, 0x2d, 0x32, 0xcb, 0x45, 0x71, 0x71, 0x67, 0xe3, 0x00, 0x62, 0x2c, 0x71, + 0x67, 0xe3, 0x89, 0x65, + ]; + assert_eq!(ChainCode::musig2_synthetic_root().0, expected); + } +} diff --git a/crates/bip32/src/xkeys.rs b/crates/bip32/src/xkeys.rs index 5cb0bbed..b751d19c 100644 --- a/crates/bip32/src/xkeys.rs +++ b/crates/bip32/src/xkeys.rs @@ -312,6 +312,37 @@ impl XPub { pub fn pubkey_hash160(&self) -> Hash160Digest { Hash160::digest_marked(self.key.to_sec1_bytes().as_ref()) } + + /// Construct a BIP-328 "synthetic xpub" root from a MuSig2 plain aggregate public key. + /// + /// Per BIP-328, this fixes the depth, parent fingerprint, and child number to 0, and + /// attaches the fixed chain code `ChainCode::musig2_synthetic_root()`. As there is no + /// aggregate private key, only unhardened derivation from the result is possible. + /// + /// See . + pub const fn synthetic_musig2_root(key: ecdsa::VerifyingKey) -> Self { + Self { + key, + xkey_info: XKeyInfo { + depth: 0, + parent: KeyFingerprint([0u8; 4]), + index: 0, + chain_code: ChainCode::musig2_synthetic_root(), + hint: Hint::Legacy, + }, + } + } + + /// Returns `true` if this xpub's depth, parent fingerprint, child number, and chain code + /// match the BIP-328 synthetic xpub root shape for a MuSig2 aggregate key. + /// + /// See . + pub fn is_musig2_synthetic_root(&self) -> bool { + self.xkey_info.depth == 0 + && self.xkey_info.parent == KeyFingerprint([0u8; 4]) + && self.xkey_info.index == 0 + && self.xkey_info.chain_code == ChainCode::musig2_synthetic_root() + } } impl PartialEq for XPub { @@ -593,4 +624,40 @@ mod test { let xpriv: XPriv = MainnetEncoder::xpriv_from_base58(&xpriv_str).unwrap(); println!("{xpriv:?}"); } + + // BIP-328 test vector: https://github.com/bitcoin/bips/blob/master/bip-0328.mediawiki + const BIP328_AGG_PUBKEY: &str = + "0354240c76b8f2999143301a99c7f721ee57eee0bce401df3afeaa9ae218c70f23"; + const BIP328_SYNTHETIC_XPUB: &str = "xpub661MyMwAqRbcFt6tk3uaczE1y6EvM1TqXvawXcYmFEWijEM4PDBnuCXwwXEKGEouzXE6QLLRxjatMcLLzJ5LV5Nib1BN7vJg6yp45yHHRbm"; + + #[test] + fn synthetic_musig2_root_matches_bip328_test_vector() { + let key_bytes = hex::decode(BIP328_AGG_PUBKEY).unwrap(); + let key = ecdsa::VerifyingKey::from_sec1_bytes(&key_bytes).unwrap(); + + let synthetic = XPub::synthetic_musig2_root(key); + + assert_eq!( + MainnetEncoder::xpub_to_base58(&synthetic).unwrap(), + BIP328_SYNTHETIC_XPUB + ); + } + + #[test] + fn it_identifies_musig2_synthetic_roots() { + let key_bytes = hex::decode(BIP328_AGG_PUBKEY).unwrap(); + let key = ecdsa::VerifyingKey::from_sec1_bytes(&key_bytes).unwrap(); + let synthetic = XPub::synthetic_musig2_root(key); + assert!(synthetic.is_musig2_synthetic_root()); + + let ordinary = MainnetEncoder::xpub_from_base58( + "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8", + ) + .unwrap(); + assert!(!ordinary.is_musig2_synthetic_root()); + + // Deriving a child moves depth away from 0, so it's no longer a synthetic root. + let child = synthetic.derive_child(0).unwrap(); + assert!(!child.is_musig2_synthetic_root()); + } }