From 3f2b05d1927f6d5d69ba9bc8607a26e941858132 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 31 Oct 2025 10:34:37 +0100 Subject: [PATCH] feat(wasm-utxo): add MuSig2 external crate implementation Add k256-based MuSig2 crate to implement BIP327 key aggregation. This allows us to use a standard implementation while maintaining our own legacy algorithms for backward compatibility. Includes test cases to verify that external crate produces identical results to our internal implementation. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/Cargo.lock | 217 +++++++++++++++- packages/wasm-utxo/Cargo.toml | 2 + packages/wasm-utxo/deny.toml | 2 +- .../wallet_scripts/bitgo_musig.rs | 244 +++++++++++++++--- 4 files changed, 416 insertions(+), 49 deletions(-) diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index 4edb027..1d09fa5 100644 --- a/packages/wasm-utxo/Cargo.lock +++ b/packages/wasm-utxo/Cargo.lock @@ -114,6 +114,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base58ck" version = "0.1.0" @@ -136,6 +142,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + [[package]] name = "bech32" version = "0.11.0" @@ -304,6 +316,12 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-random" version = "0.1.18" @@ -348,6 +366,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -358,6 +388,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -366,6 +406,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -398,6 +439,37 @@ dependencies = [ "const-random", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "elliptic-curve", + "signature", + "spki", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -413,6 +485,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + [[package]] name = "fixedbitset" version = "0.4.2" @@ -470,6 +552,7 @@ checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -479,8 +562,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -489,6 +574,17 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -541,6 +637,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "indexmap" version = "2.12.0" @@ -584,6 +689,18 @@ dependencies = [ "serde", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", +] + [[package]] name = "lazy_static" version = "0.2.11" @@ -649,6 +766,21 @@ dependencies = [ "bitcoin", ] +[[package]] +name = "musig2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c5ffeab912897e7577287c8f2b4efbc4be24912f77531b45ba4b18c93f8be21" +dependencies = [ + "base16ct", + "hmac", + "k256", + "once_cell", + "secp", + "sha2", + "subtle", +] + [[package]] name = "nom" version = "7.1.3" @@ -689,12 +821,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.20.1" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1" -dependencies = [ - "portable-atomic", -] +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" @@ -799,10 +928,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] -name = "portable-atomic" -version = "1.11.1" +name = "pkcs8" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] [[package]] name = "proc-macro-crate" @@ -846,6 +979,15 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -973,6 +1115,32 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secp" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d3b203895e8f18854c828d1cf7e5710683c3abc28d79330fe5ab723ce5b76e1" +dependencies = [ + "base16ct", + "k256", + "once_cell", + "subtle", +] + [[package]] name = "secp256k1" version = "0.29.1" @@ -1076,18 +1244,43 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + [[package]] name = "slab" version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.87" @@ -1362,9 +1555,11 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "bech32", + "getrandom", "hex", "js-sys", "miniscript", + "musig2", "rstest", "serde", "serde_json", @@ -1675,3 +1870,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index 36a6ab7..54a0b82 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -18,6 +18,8 @@ wasm-bindgen = "0.2" js-sys = "0.3" miniscript = { git = "https://github.com/BitGo/rust-miniscript", tag = "miniscript-12.3.4-opdrop" } bech32 = "0.11" +musig2 = { version = "0.3.1", default-features = false, features = ["k256"] } +getrandom = { version = "0.2", features = ["js"] } [dev-dependencies] base64 = "0.22.1" diff --git a/packages/wasm-utxo/deny.toml b/packages/wasm-utxo/deny.toml index dd653e9..ec58bc1 100644 --- a/packages/wasm-utxo/deny.toml +++ b/packages/wasm-utxo/deny.toml @@ -10,7 +10,7 @@ allow-git = ["https://github.com/BitGo/rust-miniscript"] # Allow common licenses used in the Rust ecosystem [licenses] -allow = ["MIT", "Apache-2.0", "CC0-1.0", "MITNFA", "Unicode-DFS-2016"] +allow = ["MIT", "Apache-2.0", "CC0-1.0", "MITNFA", "Unicode-DFS-2016", "BSD-3-Clause", "Unlicense"] # Clarify license for unlicensed crate [[licenses.clarify]] name = "wasm-utxo" diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/bitgo_musig.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/bitgo_musig.rs index f731656..e6caf39 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/bitgo_musig.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/bitgo_musig.rs @@ -8,6 +8,7 @@ //! use miniscript::bitcoin::CompressedPublicKey; +use musig2::KeyAggContext; use crate::bitcoin::hashes::{sha256, Hash, HashEngine}; use crate::bitcoin::secp256k1::{Parity, PublicKey, Scalar, Secp256k1, XOnlyPublicKey}; @@ -178,13 +179,56 @@ pub fn key_agg_bitgo_p2tr_legacy( key_agg(&xonly_keys) } -/// P2TR MuSig2 key aggregation. +/// P2TR MuSig2 key aggregation using external musig2 crate. /// -/// This is the standard BIP327 key aggregation without sorting or x-only mode. -/// Order of keys matters - different order produces different aggregate keys. +/// This function uses the external `musig2` crate to perform BIP327-compliant +/// key aggregation. pub fn key_agg_p2tr_musig2(pubkeys: &[CompressedPublicKey]) -> Result<[u8; 32], BitGoMusigError> { - let pubkey_bytes: Vec> = pubkeys.iter().map(|pk| pk.to_bytes().to_vec()).collect(); - key_agg(&pubkey_bytes) + if pubkeys.len() < 2 { + return Err(BitGoMusigError::InvalidPubkeyCount( + "At least two pubkeys are required for MuSig key aggregation".to_string(), + )); + } + + // Check for duplicate keys + let first = &pubkeys[0]; + let has_distinct = pubkeys.iter().skip(1).any(|pk| pk != first); + if !has_distinct { + return Err(BitGoMusigError::InvalidPubkeyCount( + "All pubkeys are identical - MuSig requires at least two distinct keys".to_string(), + )); + } + + // Convert CompressedPublicKey to k256::PublicKey + let k256_pubkeys: Result, _> = pubkeys + .iter() + .enumerate() + .map(|(i, cpk)| { + use musig2::secp::Point; + Point::try_from(&cpk.to_bytes()[..]).map_err(|e| { + BitGoMusigError::InvalidPubkey(format!("Invalid pubkey at index {}: {}", i, e)) + }) + }) + .collect(); + let k256_pubkeys = k256_pubkeys?; + + // Use musig2 crate for key aggregation + let key_agg_ctx = KeyAggContext::new(k256_pubkeys).map_err(|e| { + BitGoMusigError::AggregationFailed(format!("KeyAggContext creation failed: {}", e)) + })?; + + // Get the aggregated x-only public key + // The aggregated_pubkey returns a Point, we need to extract x-coordinate + let agg_point: musig2::secp::Point = key_agg_ctx.aggregated_pubkey(); + + // Convert Point to compressed bytes (33 bytes: 0x02/0x03 + x-coordinate) + let compressed_bytes = agg_point.serialize(); + + // Extract x-only bytes (skip the first parity byte, take next 32 bytes) + let mut x_only = [0u8; 32]; + x_only.copy_from_slice(&compressed_bytes[1..33]); + + Ok(x_only) } #[cfg(test)] @@ -201,63 +245,111 @@ mod tests { .serialize() } + /// p2tr musig2 key aggregation. + /// + /// this is the standard bip327 key aggregation without sorting or x-only mode. + /// order of keys matters - different order produces different aggregate keys. + pub fn key_agg_p2tr_musig2_internal( + pubkeys: &[CompressedPublicKey], + ) -> Result<[u8; 32], BitGoMusigError> { + let pubkey_bytes: Vec> = pubkeys.iter().map(|pk| pk.to_bytes().to_vec()).collect(); + key_agg(&pubkey_bytes) + } + + /// Test keys used across multiple tests + struct TestKeys { + user: CompressedPublicKey, + bitgo: CompressedPublicKey, + backup: CompressedPublicKey, + } + + fn get_test_keys() -> TestKeys { + TestKeys { + user: pubkey_from_hex( + "02d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7", + ), + bitgo: pubkey_from_hex( + "03203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64", + ), + backup: pubkey_from_hex( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ), + } + } + + /// Expected fixtures for key aggregation tests + struct AggregationFixtures { + p2tr_legacy: [u8; 32], + p2tr_musig2_forward: [u8; 32], + p2tr_musig2_reverse: [u8; 32], + } + + fn get_aggregation_fixtures() -> AggregationFixtures { + AggregationFixtures { + p2tr_legacy: pubkey_from_hex_xonly( + "cc899cac29f6243ef481be86f0d39e173c075cd57193d46332b1ec0b42c439aa", + ), + p2tr_musig2_forward: pubkey_from_hex_xonly( + "c0e255b4510e041ab81151091d875687a618de314344dff4b73b1bcd366cdbd8", + ), + p2tr_musig2_reverse: pubkey_from_hex_xonly( + "e48d309b535811eb0b148c4b0600a10e82e289899429e40aee05577504eca356", + ), + } + } + + /// Assert that aggregation result matches expected fixture + fn assert_aggregation(result: [u8; 32], expected: [u8; 32], msg: &str) { + assert_eq!(result, expected, "{}", msg); + } + #[test] fn test_bitgo_p2tr_aggregation() { // Test matching the Python test_agg_bitgo function // This is the algorithm used by the bitgo 'p2tr' output script type (chain 30, 31) - - let pubkey_user = - pubkey_from_hex("02d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7"); - let pubkey_bitgo = - pubkey_from_hex("03203ab799ce28e2cca044f594c69275050af4bb0854ad730a8f74622342300e64"); - let expected_internal_pubkey_p2tr = pubkey_from_hex_xonly( - "cc899cac29f6243ef481be86f0d39e173c075cd57193d46332b1ec0b42c439aa", - ); - let expected_internal_pubkey_p2tr_musig2 = pubkey_from_hex_xonly( - "c0e255b4510e041ab81151091d875687a618de314344dff4b73b1bcd366cdbd8", - ); - let expected_internal_pubkey_p2tr_musig2_reverse = pubkey_from_hex_xonly( - "e48d309b535811eb0b148c4b0600a10e82e289899429e40aee05577504eca356", - ); + let keys = get_test_keys(); + let fixtures = get_aggregation_fixtures(); // Test 1: bitgo_p2tr_legacy aggregation using xonly conversion + sort - let result = key_agg_bitgo_p2tr_legacy(&[pubkey_user, pubkey_bitgo]).unwrap(); - assert_eq!( - result, expected_internal_pubkey_p2tr, - "p2tr legacy aggregation mismatch" + let result = key_agg_bitgo_p2tr_legacy(&[keys.user, keys.bitgo]).unwrap(); + assert_aggregation( + result, + fixtures.p2tr_legacy, + "p2tr legacy aggregation mismatch", ); // Test 2: bitgo_p2tr_legacy aggregation in reverse order should give same result (because sort=true) - let result = key_agg_bitgo_p2tr_legacy(&[pubkey_bitgo, pubkey_user]).unwrap(); - assert_eq!( - result, expected_internal_pubkey_p2tr, - "p2tr legacy aggregation (reverse) mismatch" + let result = key_agg_bitgo_p2tr_legacy(&[keys.bitgo, keys.user]).unwrap(); + assert_aggregation( + result, + fixtures.p2tr_legacy, + "p2tr legacy aggregation (reverse) mismatch", ); // Test 3: p2tr_musig2 aggregation using standard BIP327 - let result = key_agg_p2tr_musig2(&[pubkey_user, pubkey_bitgo]).unwrap(); - assert_eq!( - result, expected_internal_pubkey_p2tr_musig2, - "p2trMusig2 aggregation mismatch" + let result = key_agg_p2tr_musig2(&[keys.user, keys.bitgo]).unwrap(); + assert_aggregation( + result, + fixtures.p2tr_musig2_forward, + "p2trMusig2 aggregation mismatch", ); // Test 4: p2tr_musig2 aggregation in reverse order gives different result (because sort=false) - let result = key_agg_p2tr_musig2(&[pubkey_bitgo, pubkey_user]).unwrap(); - assert_eq!( - result.to_vec(), - expected_internal_pubkey_p2tr_musig2_reverse, - "p2trMusig2 aggregation (reverse) mismatch" + let result = key_agg_p2tr_musig2(&[keys.bitgo, keys.user]).unwrap(); + assert_aggregation( + result, + fixtures.p2tr_musig2_reverse, + "p2trMusig2 aggregation (reverse) mismatch", ); } #[test] fn test_identical_keys_error() { // Test that aggregating identical keys returns an error - let pubkey_user = - pubkey_from_hex("02d20a62701c54f6eb3abb9f964b0e29ff90ffa3b4e3fcb73e7c67d4950fa6e3c7"); + let keys = get_test_keys(); // All keys are identical - should error - let result = key_agg_bitgo_p2tr_legacy(&[pubkey_user, pubkey_user]); + let result = key_agg_bitgo_p2tr_legacy(&[keys.user, keys.user]); assert!( result.is_err(), "Expected error when all keys are identical" @@ -268,7 +360,7 @@ mod tests { ); // Same for p2tr_musig2 - let result = key_agg_p2tr_musig2(&[pubkey_user, pubkey_user]); + let result = key_agg_p2tr_musig2(&[keys.user, keys.user]); assert!( result.is_err(), "Expected error when all keys are identical" @@ -278,4 +370,76 @@ mod tests { "Expected InvalidPubkeyCount error" ); } + + #[test] + fn test_external_crate_matches_internal_implementation() { + // Test that the external musig2 crate produces the same results as our internal implementation + let keys = get_test_keys(); + let fixtures = get_aggregation_fixtures(); + + // Test 1: Same order should produce same results + let result_internal = key_agg_p2tr_musig2_internal(&[keys.user, keys.bitgo]).unwrap(); + let result_external = key_agg_p2tr_musig2(&[keys.user, keys.bitgo]).unwrap(); + assert_aggregation( + result_internal, + fixtures.p2tr_musig2_forward, + "Internal implementation mismatch", + ); + assert_aggregation( + result_external, + fixtures.p2tr_musig2_forward, + "External crate mismatch", + ); + + // Test 2: Reverse order should produce same results (but different from test 1) + let result_internal_reverse = + key_agg_p2tr_musig2_internal(&[keys.bitgo, keys.user]).unwrap(); + let result_external_reverse = key_agg_p2tr_musig2(&[keys.bitgo, keys.user]).unwrap(); + assert_aggregation( + result_internal_reverse, + fixtures.p2tr_musig2_reverse, + "Internal implementation (reverse) mismatch", + ); + assert_aggregation( + result_external_reverse, + fixtures.p2tr_musig2_reverse, + "External crate (reverse) mismatch", + ); + + // Test 3: Verify order matters for both implementations + assert_ne!( + result_internal, result_internal_reverse, + "Different key order should produce different results" + ); + assert_ne!( + result_external, result_external_reverse, + "Different key order should produce different results for external crate" + ); + } + + #[test] + fn test_external_crate_identical_keys_error() { + // Test that the external crate also rejects identical keys + let keys = get_test_keys(); + + let result = key_agg_p2tr_musig2(&[keys.user, keys.user]); + assert!( + result.is_err(), + "External crate should error when all keys are identical" + ); + } + + #[test] + fn test_external_crate_with_three_keys() { + // Test with three keys to ensure it works with more than 2 keys + let keys = get_test_keys(); + + let result_internal = key_agg_p2tr_musig2(&[keys.user, keys.bitgo, keys.backup]).unwrap(); + let result_external = key_agg_p2tr_musig2(&[keys.user, keys.bitgo, keys.backup]).unwrap(); + + assert_eq!( + result_internal, result_external, + "External crate should match internal implementation with 3 keys" + ); + } }