diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index 4edb027..ea8f15f 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" @@ -156,7 +162,7 @@ dependencies = [ "bitcoin_hashes", "hex-conservative", "hex_lit", - "secp256k1", + "secp256k1 0.29.1", ] [[package]] @@ -319,7 +325,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom", + "getrandom 0.2.16", "once_cell", "tiny-keccak", ] @@ -366,6 +372,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -479,8 +486,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", ] [[package]] @@ -541,6 +562,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" @@ -649,6 +679,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", + "once_cell", + "secp", + "secp256k1 0.31.1", + "sha2", + "subtle", +] + [[package]] name = "nom" version = "7.1.3" @@ -689,12 +734,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 +841,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] -name = "portable-atomic" -version = "1.11.1" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] [[package]] name = "proc-macro-crate" @@ -846,13 +891,48 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_users" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom", + "getrandom 0.2.16", "libredox", "thiserror", ] @@ -973,6 +1053,18 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "secp" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d3b203895e8f18854c828d1cf7e5710683c3abc28d79330fe5ab723ce5b76e1" +dependencies = [ + "base16ct", + "once_cell", + "secp256k1 0.31.1", + "subtle", +] + [[package]] name = "secp256k1" version = "0.29.1" @@ -980,7 +1072,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ "bitcoin_hashes", - "secp256k1-sys", + "secp256k1-sys 0.10.1", +] + +[[package]] +name = "secp256k1" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" +dependencies = [ + "bitcoin_hashes", + "rand", + "secp256k1-sys 0.11.0", ] [[package]] @@ -992,6 +1095,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secp256k1-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb913707158fadaf0d8702c2db0e857de66eb003ccfdda5924b5f5ac98efb38" +dependencies = [ + "cc", +] + [[package]] name = "semver" version = "1.0.27" @@ -1088,6 +1200,12 @@ 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" @@ -1260,6 +1378,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.104" @@ -1362,10 +1489,13 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "bech32", + "getrandom 0.2.16", "hex", "js-sys", "miniscript", + "musig2", "rstest", + "secp256k1 0.31.1", "serde", "serde_json", "wasm-bindgen", @@ -1645,6 +1775,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + [[package]] name = "yaml-rust2" version = "0.8.1" diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index 36a6ab7..49b2f87 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -18,6 +18,9 @@ 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" +secp256k1 = { version = "0.31.1" } +musig2 = { version = "0.3.1" } +getrandom = { version = "0.2", features = ["js"] } [dev-dependencies] base64 = "0.22.1" diff --git a/packages/wasm-utxo/Makefile b/packages/wasm-utxo/Makefile index e9cdfc5..710d16b 100644 --- a/packages/wasm-utxo/Makefile +++ b/packages/wasm-utxo/Makefile @@ -2,6 +2,9 @@ WASM_PACK = wasm-pack WASM_OPT = wasm-opt WASM_PACK_FLAGS = --no-pack --weak-refs +# Fix for secp256k1-sys compilation with strict compilers +export CFLAGS = -Wno-error=implicit-function-declaration + ifdef WASM_PACK_DEV WASM_PACK_FLAGS += --dev endif 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..892845e 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}; @@ -187,6 +188,50 @@ pub fn key_agg_p2tr_musig2(pubkeys: &[CompressedPublicKey]) -> Result<[u8; 32], key_agg(&pubkey_bytes) } +/// P2TR MuSig2 key aggregation using external musig2 crate. +/// +/// This function uses the external `musig2` crate to perform BIP327-compliant +/// key aggregation. It should produce identical results to `key_agg_p2tr_musig2`. +pub fn key_agg_p2tr_musig2_external_crate( + pubkeys: &[CompressedPublicKey], +) -> Result<[u8; 32], BitGoMusigError> { + 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 musig2::secp256k1::PublicKey + let secp_pubkeys: Result, _> = pubkeys + .iter() + .enumerate() + .map(|(i, cpk)| { + musig2::secp256k1::PublicKey::from_slice(&cpk.to_bytes()).map_err(|e| { + BitGoMusigError::InvalidPubkey(format!("Invalid pubkey at index {}: {}", i, e)) + }) + }) + .collect(); + let secp_pubkeys = secp_pubkeys?; + + // Use musig2 crate for key aggregation + let key_agg_ctx = KeyAggContext::new(secp_pubkeys).map_err(|e| { + BitGoMusigError::AggregationFailed(format!("KeyAggContext creation failed: {}", e)) + })?; + + // Get the aggregated x-only public key + let agg_pk: musig2::secp256k1::XOnlyPublicKey = key_agg_ctx.aggregated_pubkey(); + Ok(agg_pk.serialize()) +} + #[cfg(test)] mod tests { use super::*; @@ -201,63 +246,100 @@ mod tests { .serialize() } + /// 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 +350,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 +360,77 @@ 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(&[keys.user, keys.bitgo]).unwrap(); + let result_external = key_agg_p2tr_musig2_external_crate(&[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(&[keys.bitgo, keys.user]).unwrap(); + let result_external_reverse = + key_agg_p2tr_musig2_external_crate(&[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_external_crate(&[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_external_crate(&[keys.user, keys.bitgo, keys.backup]).unwrap(); + + assert_eq!( + result_internal, result_external, + "External crate should match internal implementation with 3 keys" + ); + } }