Skip to content

Commit 5d0c2e6

Browse files
OttoAllmendingerllm-git
andcommitted
feat(wasm-utxo): add MuSig2 key aggregation using external musig2 crate
Implement alternative key aggregation function using the standard musig2 crate that produces BIP-327 compliant results. Added tests to verify consistency between implementations and proper error handling. Issue: BTC-2652 Co-authored-by: llm-git <[email protected]>
1 parent 74f8b09 commit 5d0c2e6

File tree

1 file changed

+190
-35
lines changed
  • packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts

1 file changed

+190
-35
lines changed

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

Lines changed: 190 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
//!
99
1010
use miniscript::bitcoin::CompressedPublicKey;
11+
use musig2::KeyAggContext;
1112

1213
use crate::bitcoin::hashes::{sha256, Hash, HashEngine};
1314
use crate::bitcoin::secp256k1::{Parity, PublicKey, Scalar, Secp256k1, XOnlyPublicKey};
@@ -187,6 +188,50 @@ pub fn key_agg_p2tr_musig2(pubkeys: &[CompressedPublicKey]) -> Result<[u8; 32],
187188
key_agg(&pubkey_bytes)
188189
}
189190

191+
/// P2TR MuSig2 key aggregation using external musig2 crate.
192+
///
193+
/// This function uses the external `musig2` crate to perform BIP327-compliant
194+
/// key aggregation. It should produce identical results to `key_agg_p2tr_musig2`.
195+
pub fn key_agg_p2tr_musig2_external_crate(
196+
pubkeys: &[CompressedPublicKey],
197+
) -> Result<[u8; 32], BitGoMusigError> {
198+
if pubkeys.len() < 2 {
199+
return Err(BitGoMusigError::InvalidPubkeyCount(
200+
"At least two pubkeys are required for MuSig key aggregation".to_string(),
201+
));
202+
}
203+
204+
// Check for duplicate keys
205+
let first = &pubkeys[0];
206+
let has_distinct = pubkeys.iter().skip(1).any(|pk| pk != first);
207+
if !has_distinct {
208+
return Err(BitGoMusigError::InvalidPubkeyCount(
209+
"All pubkeys are identical - MuSig requires at least two distinct keys".to_string(),
210+
));
211+
}
212+
213+
// Convert CompressedPublicKey to musig2::secp256k1::PublicKey
214+
let secp_pubkeys: Result<Vec<musig2::secp256k1::PublicKey>, _> = pubkeys
215+
.iter()
216+
.enumerate()
217+
.map(|(i, cpk)| {
218+
musig2::secp256k1::PublicKey::from_slice(&cpk.to_bytes()).map_err(|e| {
219+
BitGoMusigError::InvalidPubkey(format!("Invalid pubkey at index {}: {}", i, e))
220+
})
221+
})
222+
.collect();
223+
let secp_pubkeys = secp_pubkeys?;
224+
225+
// Use musig2 crate for key aggregation
226+
let key_agg_ctx = KeyAggContext::new(secp_pubkeys).map_err(|e| {
227+
BitGoMusigError::AggregationFailed(format!("KeyAggContext creation failed: {}", e))
228+
})?;
229+
230+
// Get the aggregated x-only public key
231+
let agg_pk: musig2::secp256k1::XOnlyPublicKey = key_agg_ctx.aggregated_pubkey();
232+
Ok(agg_pk.serialize())
233+
}
234+
190235
#[cfg(test)]
191236
mod tests {
192237
use super::*;
@@ -201,63 +246,100 @@ mod tests {
201246
.serialize()
202247
}
203248

249+
/// Test keys used across multiple tests
250+
struct TestKeys {
251+
user: CompressedPublicKey,
252+
bitgo: CompressedPublicKey,
253+
backup: CompressedPublicKey,
254+
}
255+
256+
fn get_test_keys() -> TestKeys {
257+
TestKeys {
258+
user: pubkey_from_hex(
259+
"02d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7",
260+
),
261+
bitgo: pubkey_from_hex(
262+
"03203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64",
263+
),
264+
backup: pubkey_from_hex(
265+
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
266+
),
267+
}
268+
}
269+
270+
/// Expected fixtures for key aggregation tests
271+
struct AggregationFixtures {
272+
p2tr_legacy: [u8; 32],
273+
p2tr_musig2_forward: [u8; 32],
274+
p2tr_musig2_reverse: [u8; 32],
275+
}
276+
277+
fn get_aggregation_fixtures() -> AggregationFixtures {
278+
AggregationFixtures {
279+
p2tr_legacy: pubkey_from_hex_xonly(
280+
"cc899cac29f6243ef481be86f0d39e173c075cd57193d46332b1ec0b42c439aa",
281+
),
282+
p2tr_musig2_forward: pubkey_from_hex_xonly(
283+
"c0e255b4510e041ab81151091d875687a618de314344dff4b73b1bcd366cdbd8",
284+
),
285+
p2tr_musig2_reverse: pubkey_from_hex_xonly(
286+
"e48d309b535811eb0b148c4b0600a10e82e289899429e40aee05577504eca356",
287+
),
288+
}
289+
}
290+
291+
/// Assert that aggregation result matches expected fixture
292+
fn assert_aggregation(result: [u8; 32], expected: [u8; 32], msg: &str) {
293+
assert_eq!(result, expected, "{}", msg);
294+
}
295+
204296
#[test]
205297
fn test_bitgo_p2tr_aggregation() {
206298
// Test matching the Python test_agg_bitgo function
207299
// 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-
);
300+
let keys = get_test_keys();
301+
let fixtures = get_aggregation_fixtures();
222302

223303
// 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"
304+
let result = key_agg_bitgo_p2tr_legacy(&[keys.user, keys.bitgo]).unwrap();
305+
assert_aggregation(
306+
result,
307+
fixtures.p2tr_legacy,
308+
"p2tr legacy aggregation mismatch",
228309
);
229310

230311
// 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"
312+
let result = key_agg_bitgo_p2tr_legacy(&[keys.bitgo, keys.user]).unwrap();
313+
assert_aggregation(
314+
result,
315+
fixtures.p2tr_legacy,
316+
"p2tr legacy aggregation (reverse) mismatch",
235317
);
236318

237319
// 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"
320+
let result = key_agg_p2tr_musig2(&[keys.user, keys.bitgo]).unwrap();
321+
assert_aggregation(
322+
result,
323+
fixtures.p2tr_musig2_forward,
324+
"p2trMusig2 aggregation mismatch",
242325
);
243326

244327
// 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"
328+
let result = key_agg_p2tr_musig2(&[keys.bitgo, keys.user]).unwrap();
329+
assert_aggregation(
330+
result,
331+
fixtures.p2tr_musig2_reverse,
332+
"p2trMusig2 aggregation (reverse) mismatch",
250333
);
251334
}
252335

253336
#[test]
254337
fn test_identical_keys_error() {
255338
// Test that aggregating identical keys returns an error
256-
let pubkey_user =
257-
pubkey_from_hex("02d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7");
339+
let keys = get_test_keys();
258340

259341
// All keys are identical - should error
260-
let result = key_agg_bitgo_p2tr_legacy(&[pubkey_user, pubkey_user]);
342+
let result = key_agg_bitgo_p2tr_legacy(&[keys.user, keys.user]);
261343
assert!(
262344
result.is_err(),
263345
"Expected error when all keys are identical"
@@ -268,7 +350,7 @@ mod tests {
268350
);
269351

270352
// Same for p2tr_musig2
271-
let result = key_agg_p2tr_musig2(&[pubkey_user, pubkey_user]);
353+
let result = key_agg_p2tr_musig2(&[keys.user, keys.user]);
272354
assert!(
273355
result.is_err(),
274356
"Expected error when all keys are identical"
@@ -278,4 +360,77 @@ mod tests {
278360
"Expected InvalidPubkeyCount error"
279361
);
280362
}
363+
364+
#[test]
365+
fn test_external_crate_matches_internal_implementation() {
366+
// Test that the external musig2 crate produces the same results as our internal implementation
367+
let keys = get_test_keys();
368+
let fixtures = get_aggregation_fixtures();
369+
370+
// Test 1: Same order should produce same results
371+
let result_internal = key_agg_p2tr_musig2(&[keys.user, keys.bitgo]).unwrap();
372+
let result_external = key_agg_p2tr_musig2_external_crate(&[keys.user, keys.bitgo]).unwrap();
373+
assert_aggregation(
374+
result_internal,
375+
fixtures.p2tr_musig2_forward,
376+
"Internal implementation mismatch",
377+
);
378+
assert_aggregation(
379+
result_external,
380+
fixtures.p2tr_musig2_forward,
381+
"External crate mismatch",
382+
);
383+
384+
// Test 2: Reverse order should produce same results (but different from test 1)
385+
let result_internal_reverse = key_agg_p2tr_musig2(&[keys.bitgo, keys.user]).unwrap();
386+
let result_external_reverse =
387+
key_agg_p2tr_musig2_external_crate(&[keys.bitgo, keys.user]).unwrap();
388+
assert_aggregation(
389+
result_internal_reverse,
390+
fixtures.p2tr_musig2_reverse,
391+
"Internal implementation (reverse) mismatch",
392+
);
393+
assert_aggregation(
394+
result_external_reverse,
395+
fixtures.p2tr_musig2_reverse,
396+
"External crate (reverse) mismatch",
397+
);
398+
399+
// Test 3: Verify order matters for both implementations
400+
assert_ne!(
401+
result_internal, result_internal_reverse,
402+
"Different key order should produce different results"
403+
);
404+
assert_ne!(
405+
result_external, result_external_reverse,
406+
"Different key order should produce different results for external crate"
407+
);
408+
}
409+
410+
#[test]
411+
fn test_external_crate_identical_keys_error() {
412+
// Test that the external crate also rejects identical keys
413+
let keys = get_test_keys();
414+
415+
let result = key_agg_p2tr_musig2_external_crate(&[keys.user, keys.user]);
416+
assert!(
417+
result.is_err(),
418+
"External crate should error when all keys are identical"
419+
);
420+
}
421+
422+
#[test]
423+
fn test_external_crate_with_three_keys() {
424+
// Test with three keys to ensure it works with more than 2 keys
425+
let keys = get_test_keys();
426+
427+
let result_internal = key_agg_p2tr_musig2(&[keys.user, keys.bitgo, keys.backup]).unwrap();
428+
let result_external =
429+
key_agg_p2tr_musig2_external_crate(&[keys.user, keys.bitgo, keys.backup]).unwrap();
430+
431+
assert_eq!(
432+
result_internal, result_external,
433+
"External crate should match internal implementation with 3 keys"
434+
);
435+
}
281436
}

0 commit comments

Comments
 (0)