Skip to content

Commit 663d0d9

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): implement BitGo-specific MuSig2 key aggregation
Add implementation of BitGo's non-standard variant of MuSig2 key aggregation that uses x-only (32-byte) pubkeys in the hash computation. This differs from the standard BIP327 implementation and is required for compatibility with existing BitGo wallets. BTC-2652 Co-authored-by: llm-git <[email protected]>
1 parent f5c8114 commit 663d0d9

File tree

4 files changed

+284
-0
lines changed

4 files changed

+284
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub mod wallet_scripts;
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
//! BitGo-specific MuSig2 implementation
2+
//!
3+
//! This module implements BitGo's non-standard variant of MuSig2 key aggregation
4+
//! that uses x-only (32-byte) pubkeys in the hash computation, which differs from
5+
//! standard BIP327.
6+
//!
7+
//! See bips/bip-0327/README.md for more details.
8+
//!
9+
10+
use miniscript::bitcoin::CompressedPublicKey;
11+
12+
use crate::bitcoin::hashes::{sha256, Hash, HashEngine};
13+
use crate::bitcoin::secp256k1::{Parity, PublicKey, Scalar, Secp256k1, XOnlyPublicKey};
14+
15+
/// Error types for BitGo MuSig2 operations
16+
#[derive(Debug)]
17+
pub enum BitGoMusigError {
18+
InvalidPubkeyCount(String),
19+
InvalidPubkey(String),
20+
AggregationFailed(String),
21+
}
22+
23+
impl std::fmt::Display for BitGoMusigError {
24+
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
25+
match self {
26+
BitGoMusigError::InvalidPubkeyCount(msg) => write!(f, "Invalid pubkey count: {}", msg),
27+
BitGoMusigError::InvalidPubkey(msg) => write!(f, "Invalid pubkey: {}", msg),
28+
BitGoMusigError::AggregationFailed(msg) => write!(f, "Aggregation failed: {}", msg),
29+
}
30+
}
31+
}
32+
33+
impl std::error::Error for BitGoMusigError {}
34+
35+
/// BIP340-style tagged hash
36+
fn tagged_hash(tag: &str, msg: &[u8]) -> [u8; 32] {
37+
let tag_hash = sha256::Hash::hash(tag.as_bytes());
38+
let mut engine = sha256::Hash::engine();
39+
engine.input(tag_hash.as_ref());
40+
engine.input(tag_hash.as_ref());
41+
engine.input(msg);
42+
sha256::Hash::from_engine(engine).to_byte_array()
43+
}
44+
45+
/// MuSig2 key aggregation base function.
46+
///
47+
/// This function implements key aggregation as per BIP327 reference.
48+
/// It accepts either 33-byte compressed or 32-byte x-only public keys.
49+
///
50+
/// # Arguments
51+
/// * `pubkey_bytes` - Slice of public key bytes (either 33-byte compressed or 32-byte x-only)
52+
///
53+
/// # Returns
54+
/// The aggregated x-only public key (32 bytes)
55+
///
56+
/// # Errors
57+
/// Returns error if:
58+
/// - Less than 2 pubkeys provided
59+
/// - Any pubkey is invalid
60+
/// - Aggregation results in point at infinity
61+
fn key_agg(pubkey_bytes: &[Vec<u8>]) -> Result<[u8; 32], BitGoMusigError> {
62+
if pubkey_bytes.len() < 2 {
63+
return Err(BitGoMusigError::InvalidPubkeyCount(
64+
"At least two pubkeys are required for MuSig key aggregation".to_string(),
65+
));
66+
}
67+
68+
let secp = Secp256k1::new();
69+
70+
// Determine if we're working with xonly keys (32 bytes) or compressed keys (33 bytes)
71+
let xonly = pubkey_bytes[0].len() == 32;
72+
73+
// Compute L using the pubkey_bytes
74+
let mut l_input = Vec::new();
75+
for pk in pubkey_bytes {
76+
l_input.extend_from_slice(pk);
77+
}
78+
let l = tagged_hash("KeyAgg list", &l_input);
79+
80+
// Find second unique key
81+
let pk2 = pubkey_bytes
82+
.iter()
83+
.skip(1)
84+
.find(|pk| pk != &&pubkey_bytes[0]);
85+
86+
// Aggregate the keys
87+
let mut q_option: Option<PublicKey> = None;
88+
89+
for (i, pk_bytes) in pubkey_bytes.iter().enumerate() {
90+
// In xonly mode, pubkeys are 32 bytes, so reconstruct with even Y
91+
let p_i = if xonly {
92+
let xonly_pk = XOnlyPublicKey::from_slice(pk_bytes).map_err(|e| {
93+
BitGoMusigError::InvalidPubkey(format!(
94+
"Invalid x-only pubkey at index {}: {}",
95+
i, e
96+
))
97+
})?;
98+
PublicKey::from_x_only_public_key(xonly_pk, Parity::Even)
99+
} else {
100+
// Parse as compressed (33-byte) pubkey
101+
PublicKey::from_slice(pk_bytes).map_err(|e| {
102+
BitGoMusigError::InvalidPubkey(format!(
103+
"Invalid compressed pubkey at index {}: {}",
104+
i, e
105+
))
106+
})?
107+
};
108+
109+
// Compute coefficient
110+
let a_i = if let Some(pk2_bytes) = pk2 {
111+
if pk_bytes == pk2_bytes {
112+
// Second unique key gets coefficient 1
113+
Scalar::ONE
114+
} else {
115+
// Compute coefficient for this key
116+
let mut coeff_input = Vec::new();
117+
coeff_input.extend_from_slice(&l);
118+
coeff_input.extend_from_slice(pk_bytes);
119+
let coeff_hash = tagged_hash("KeyAgg coefficient", &coeff_input);
120+
Scalar::from_be_bytes(coeff_hash).map_err(|e| {
121+
BitGoMusigError::AggregationFailed(format!("Invalid coefficient: {}", e))
122+
})?
123+
}
124+
} else {
125+
// All keys are identical - this is cryptographically invalid
126+
return Err(BitGoMusigError::InvalidPubkeyCount(
127+
"All pubkeys are identical - MuSig requires at least two distinct keys".to_string(),
128+
));
129+
};
130+
131+
// Multiply point by coefficient
132+
let contribution = p_i.mul_tweak(&secp, &a_i).map_err(|e| {
133+
BitGoMusigError::AggregationFailed(format!("Point multiplication failed: {}", e))
134+
})?;
135+
136+
// Add to aggregate
137+
q_option = match q_option {
138+
None => Some(contribution),
139+
Some(q) => {
140+
let combined = q.combine(&contribution).map_err(|e| {
141+
BitGoMusigError::AggregationFailed(format!("Point addition failed: {}", e))
142+
})?;
143+
Some(combined)
144+
}
145+
};
146+
}
147+
148+
let q = q_option.ok_or_else(|| {
149+
BitGoMusigError::AggregationFailed("Aggregation resulted in point at infinity".to_string())
150+
})?;
151+
152+
// Return x-coordinate (x-only pubkey)
153+
let (xonly_result, _parity) = q.x_only_public_key();
154+
Ok(xonly_result.serialize())
155+
}
156+
157+
/// BitGo legacy P2TR key aggregation.
158+
///
159+
/// This is the legacy algorithm used by the BitGo 'p2tr' output script type (chain 30, 31).
160+
/// Here, we convert the pubkeys to xonly first and then sort.
161+
/// This corresponds to an older variant of the musig2 scheme.
162+
pub fn key_agg_bitgo_p2tr_legacy(
163+
pubkeys: &[CompressedPublicKey],
164+
) -> Result<[u8; 32], BitGoMusigError> {
165+
// For xonly mode, normalize all pubkeys to use only x-coordinate in hashes
166+
// by converting them to 32-byte x-only format
167+
let mut xonly_keys: Vec<Vec<u8>> = pubkeys
168+
.iter()
169+
.map(|pk| {
170+
let bytes = pk.to_bytes();
171+
bytes[bytes.len() - 32..].to_vec()
172+
})
173+
.collect();
174+
175+
// Sort the keys after xonly conversion, before aggregation
176+
xonly_keys.sort();
177+
178+
key_agg(&xonly_keys)
179+
}
180+
181+
/// P2TR MuSig2 key aggregation.
182+
///
183+
/// This is the standard BIP327 key aggregation without sorting or x-only mode.
184+
/// Order of keys matters - different order produces different aggregate keys.
185+
pub fn key_agg_p2tr_musig2(pubkeys: &[CompressedPublicKey]) -> Result<[u8; 32], BitGoMusigError> {
186+
let pubkey_bytes: Vec<Vec<u8>> = pubkeys.iter().map(|pk| pk.to_bytes().to_vec()).collect();
187+
key_agg(&pubkey_bytes)
188+
}
189+
190+
#[cfg(test)]
191+
mod tests {
192+
use super::*;
193+
194+
fn pubkey_from_hex(hex: &str) -> CompressedPublicKey {
195+
CompressedPublicKey::from_slice(&hex::decode(hex).unwrap()).unwrap()
196+
}
197+
198+
fn pubkey_from_hex_xonly(hex: &str) -> [u8; 32] {
199+
XOnlyPublicKey::from_slice(&hex::decode(hex).unwrap())
200+
.unwrap()
201+
.serialize()
202+
}
203+
204+
#[test]
205+
fn test_bitgo_p2tr_aggregation() {
206+
// Test matching the Python test_agg_bitgo function
207+
// This is the algorithm used by the bitgo 'p2tr' output script type (chain 30, 31)
208+
209+
let pubkey_user =
210+
pubkey_from_hex("02d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7");
211+
let pubkey_bitgo =
212+
pubkey_from_hex("03203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64");
213+
let expected_internal_pubkey_p2tr = pubkey_from_hex_xonly(
214+
"cc899cac29f6243ef481be86f0d39e173c075cd57193d46332b1ec0b42c439aa",
215+
);
216+
let expected_internal_pubkey_p2tr_musig2 = pubkey_from_hex_xonly(
217+
"c0e255b4510e041ab81151091d875687a618de314344dff4b73b1bcd366cdbd8",
218+
);
219+
let expected_internal_pubkey_p2tr_musig2_reverse = pubkey_from_hex_xonly(
220+
"e48d309b535811eb0b148c4b0600a10e82e289899429e40aee05577504eca356",
221+
);
222+
223+
// Test 1: bitgo_p2tr_legacy aggregation using xonly conversion + sort
224+
let result = key_agg_bitgo_p2tr_legacy(&[pubkey_user, pubkey_bitgo]).unwrap();
225+
assert_eq!(
226+
result, expected_internal_pubkey_p2tr,
227+
"p2tr legacy aggregation mismatch"
228+
);
229+
230+
// Test 2: bitgo_p2tr_legacy aggregation in reverse order should give same result (because sort=true)
231+
let result = key_agg_bitgo_p2tr_legacy(&[pubkey_bitgo, pubkey_user]).unwrap();
232+
assert_eq!(
233+
result, expected_internal_pubkey_p2tr,
234+
"p2tr legacy aggregation (reverse) mismatch"
235+
);
236+
237+
// Test 3: p2tr_musig2 aggregation using standard BIP327
238+
let result = key_agg_p2tr_musig2(&[pubkey_user, pubkey_bitgo]).unwrap();
239+
assert_eq!(
240+
result, expected_internal_pubkey_p2tr_musig2,
241+
"p2trMusig2 aggregation mismatch"
242+
);
243+
244+
// Test 4: p2tr_musig2 aggregation in reverse order gives different result (because sort=false)
245+
let result = key_agg_p2tr_musig2(&[pubkey_bitgo, pubkey_user]).unwrap();
246+
assert_eq!(
247+
result.to_vec(),
248+
expected_internal_pubkey_p2tr_musig2_reverse,
249+
"p2trMusig2 aggregation (reverse) mismatch"
250+
);
251+
}
252+
253+
#[test]
254+
fn test_identical_keys_error() {
255+
// Test that aggregating identical keys returns an error
256+
let pubkey_user =
257+
pubkey_from_hex("02d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7");
258+
259+
// All keys are identical - should error
260+
let result = key_agg_bitgo_p2tr_legacy(&[pubkey_user, pubkey_user]);
261+
assert!(
262+
result.is_err(),
263+
"Expected error when all keys are identical"
264+
);
265+
assert!(
266+
matches!(result, Err(BitGoMusigError::InvalidPubkeyCount(_))),
267+
"Expected InvalidPubkeyCount error"
268+
);
269+
270+
// Same for p2tr_musig2
271+
let result = key_agg_p2tr_musig2(&[pubkey_user, pubkey_user]);
272+
assert!(
273+
result.is_err(),
274+
"Expected error when all keys are identical"
275+
);
276+
assert!(
277+
matches!(result, Err(BitGoMusigError::InvalidPubkeyCount(_))),
278+
"Expected InvalidPubkeyCount error"
279+
);
280+
}
281+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub mod bitgo_musig;

packages/wasm-utxo/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod descriptor;
22
mod error;
3+
mod fixed_script_wallet;
34
mod miniscript;
45
mod psbt;
56
mod try_into_js_value;

0 commit comments

Comments
 (0)