From f328158d876b4209715f12849fa40f9d702ec291 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 4 Nov 2025 10:08:37 +0100 Subject: [PATCH 1/3] feat(wasm-utxo): add paste dependency for macro expansion Add the paste crate to test dependencies to support cleaner test macro expansion for upcoming MuSig2 implementation. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/Cargo.lock | 7 +++++++ packages/wasm-utxo/Cargo.toml | 2 ++ packages/wasm-utxo/deny.toml | 11 ++++++++++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index 1d09fa5..5ad856c 100644 --- a/packages/wasm-utxo/Cargo.lock +++ b/packages/wasm-utxo/Cargo.lock @@ -856,6 +856,12 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -1560,6 +1566,7 @@ dependencies = [ "js-sys", "miniscript", "musig2", + "pastey", "rstest", "serde", "serde_json", diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index 54a0b82..3bc8167 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -20,6 +20,7 @@ miniscript = { git = "https://github.com/BitGo/rust-miniscript", tag = "miniscri bech32 = "0.11" musig2 = { version = "0.3.1", default-features = false, features = ["k256"] } getrandom = { version = "0.2", features = ["js"] } +pastey = "0.1" [dev-dependencies] base64 = "0.22.1" @@ -28,6 +29,7 @@ serde_json = "1.0" hex = "0.4" wasm-bindgen-test = "0.3" rstest = "0.26.1" +pastey = "0.1" [profile.release] # this is required to make webpack happy diff --git a/packages/wasm-utxo/deny.toml b/packages/wasm-utxo/deny.toml index ec58bc1..eb53935 100644 --- a/packages/wasm-utxo/deny.toml +++ b/packages/wasm-utxo/deny.toml @@ -10,7 +10,16 @@ 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", "BSD-3-Clause", "Unlicense"] +allow = [ + "MIT", + "Apache-2.0", + "CC0-1.0", + "MITNFA", + "Unicode-DFS-2016", + "Unicode-3.0", + "BSD-3-Clause", + "Unlicense", +] # Clarify license for unlicensed crate [[licenses.clarify]] name = "wasm-utxo" From 6d1e5add9a9c3758e1c99a612fccae86fadc753d Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 4 Nov 2025 10:07:16 +0100 Subject: [PATCH 2/3] feat(wasm-utxo): add PSBT finalization support for MuSig2 Add finalize_input, finalize_mut, and finalize methods to BitGoPsbt to support proper finalization of P2TR MuSig2 inputs. Also export BitGoKeyValue and related constants from the propkv module. Includes comprehensive tests with fixtures to verify proper finalization and transaction extraction. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/src/bitgo_psbt/mod.rs | 236 +++++++++++++++--- .../test_utils/fixtures.rs | 189 ++++++++++++-- .../fixed_script_wallet/wallet_scripts/mod.rs | 4 +- 3 files changed, 373 insertions(+), 56 deletions(-) diff --git a/packages/wasm-utxo/src/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/bitgo_psbt/mod.rs index 0ae99e6..07f15bf 100644 --- a/packages/wasm-utxo/src/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/bitgo_psbt/mod.rs @@ -12,10 +12,11 @@ pub use p2tr_musig2_input::{ parse_musig2_nonces, parse_musig2_partial_sigs, parse_musig2_participants, Musig2Error, Musig2Input, Musig2PartialSig, Musig2Participants, Musig2PubNonce, }; +pub use propkv::{BitGoKeyValue, ProprietaryKeySubtype, BITGO}; pub use sighash::validate_sighash_type; use crate::{bitgo_psbt::zcash_psbt::ZcashPsbt, networks::Network}; -use miniscript::bitcoin::psbt::Psbt; +use miniscript::bitcoin::{psbt::Psbt, secp256k1}; #[derive(Debug)] pub enum DeserializeError { @@ -146,6 +147,88 @@ impl BitGoPsbt { BitGoPsbt::Zcash(zcash_psbt, _network) => zcash_psbt.into_bitcoin_psbt(), } } + + pub fn finalize_input( + &mut self, + secp: &secp256k1::Secp256k1, + input_index: usize, + ) -> Result<(), String> { + use miniscript::psbt::PsbtExt; + + match self { + BitGoPsbt::BitcoinLike(ref mut psbt, _network) => { + // Use custom bitgo p2trMusig2 input finalization for MuSig2 inputs + if Musig2Input::is_musig2_input(&psbt.inputs[input_index]) { + Musig2Input::finalize_input(psbt, secp, input_index) + .map_err(|e| e.to_string())?; + return Ok(()); + } + // other inputs can be finalized using the standard miniscript::psbt::finalize_input + psbt.finalize_inp_mut(secp, input_index) + .map_err(|e| e.to_string())?; + Ok(()) + } + BitGoPsbt::Zcash(_zcash_psbt, _network) => { + todo!("Zcash PSBT finalization not yet implemented"); + } + } + } + + /// Finalize all inputs in the PSBT, attempting each input even if some fail. + /// Similar to miniscript::psbt::PsbtExt::finalize_mut. + /// + /// # Returns + /// - `Ok(())` if all inputs were successfully finalized + /// - `Err(Vec)` containing error messages for each failed input + /// + /// # Note + /// This method will attempt to finalize ALL inputs, collecting errors for any that fail. + /// It does not stop at the first error. + pub fn finalize_mut( + &mut self, + secp: &secp256k1::Secp256k1, + ) -> Result<(), Vec> { + let num_inputs = match self { + BitGoPsbt::BitcoinLike(psbt, _network) => psbt.inputs.len(), + BitGoPsbt::Zcash(zcash_psbt, _network) => zcash_psbt.psbt.inputs.len(), + }; + + let mut errors = vec![]; + for index in 0..num_inputs { + match self.finalize_input(secp, index) { + Ok(()) => {} + Err(e) => { + errors.push(format!("Input {}: {}", index, e)); + } + } + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } + + /// Finalize all inputs and consume the PSBT, returning the finalized PSBT. + /// Similar to miniscript::psbt::PsbtExt::finalize. + /// + /// # Returns + /// - `Ok(Psbt)` if all inputs were successfully finalized + /// - `Err(String)` containing a formatted error message if any input failed + pub fn finalize( + mut self, + secp: &secp256k1::Secp256k1, + ) -> Result { + match self.finalize_mut(secp) { + Ok(()) => Ok(self.into_psbt()), + Err(errors) => Err(format!( + "Failed to finalize {} input(s): {}", + errors.len(), + errors.join("; ") + )), + } + } } #[cfg(test)] @@ -303,46 +386,18 @@ mod tests { output.script_pubkey.to_hex_string() } - fn test_wallet_script_type( - script_type: fixtures::ScriptType, + fn assert_matches_wallet_scripts( network: Network, tx_format: fixtures::TxFormat, + fixture: &fixtures::PsbtFixture, + wallet_keys: &RootWalletKeys, + input_index: usize, + input_fixture: &fixtures::PsbtInputFixture, ) -> Result<(), String> { - let fixture = fixtures::load_psbt_fixture_with_format( - network.to_utxolib_name(), - fixtures::SignatureState::Fullsigned, - tx_format, - ) - .expect("Failed to load fixture"); - let xprvs = fixtures::parse_wallet_keys(&fixture).expect("Failed to parse wallet keys"); - let secp = crate::bitcoin::secp256k1::Secp256k1::new(); - let wallet_keys = RootWalletKeys::new( - xprvs - .iter() - .map(|x| Xpub::from_priv(&secp, x)) - .collect::>() - .try_into() - .expect("Failed to convert to XpubTriple"), - ); - - // Check if the script type is supported by the network - let output_script_support = network.output_script_support(); - let input_fixture = fixture.find_input_with_script_type(script_type); - if !script_type.is_supported_by(&output_script_support) { - // Script type not supported by network - skip test (no fixture expected) - assert!( - input_fixture.is_err(), - "Expected error for unsupported script type" - ); - return Ok(()); - } - - let (input_index, input_fixture) = input_fixture.unwrap(); - let (chain, index) = parse_fixture_paths(input_fixture).expect("Failed to parse fixture paths"); let scripts = WalletScripts::from_wallet_keys( - &wallet_keys, + wallet_keys, chain, index, &network.output_script_support(), @@ -421,13 +476,86 @@ mod tests { )); } } + Ok(()) + } + + fn assert_finalize_input( + mut bitgo_psbt: BitGoPsbt, + input_index: usize, + _network: Network, + _tx_format: fixtures::TxFormat, + ) -> Result<(), String> { + let secp = crate::bitcoin::secp256k1::Secp256k1::new(); + bitgo_psbt + .finalize_input(&secp, input_index) + .map_err(|e| e.to_string())?; + Ok(()) + } + + fn test_wallet_script_type( + script_type: fixtures::ScriptType, + network: Network, + tx_format: fixtures::TxFormat, + ) -> Result<(), String> { + let fixture = fixtures::load_psbt_fixture_with_format( + network.to_utxolib_name(), + fixtures::SignatureState::Fullsigned, + tx_format, + ) + .expect("Failed to load fixture"); + let wallet_keys = + fixtures::parse_wallet_keys(&fixture).expect("Failed to parse wallet keys"); + let secp = crate::bitcoin::secp256k1::Secp256k1::new(); + let wallet_keys = RootWalletKeys::new( + wallet_keys + .iter() + .map(|x| Xpub::from_priv(&secp, x)) + .collect::>() + .try_into() + .expect("Failed to convert to XpubTriple"), + ); + + // Check if the script type is supported by the network + let output_script_support = network.output_script_support(); + let input_fixture = fixture.find_input_with_script_type(script_type); + if !script_type.is_supported_by(&output_script_support) { + // Script type not supported by network - skip test (no fixture expected) + assert!( + input_fixture.is_err(), + "Expected error for unsupported script type" + ); + return Ok(()); + } + + let (input_index, input_fixture) = input_fixture.unwrap(); + + assert_matches_wallet_scripts( + network, + tx_format, + &fixture, + &wallet_keys, + input_index, + input_fixture, + )?; + + assert_finalize_input( + fixture.to_bitgo_psbt(network).unwrap(), + input_index, + network, + tx_format, + )?; Ok(()) } crate::test_psbt_fixtures!(test_p2sh_script_generation_from_fixture, network, format, { test_wallet_script_type(fixtures::ScriptType::P2sh, network, format).unwrap(); - }); + }, ignore: [ + // TODO: sighash support + BitcoinCash, Ecash, BitcoinGold, + // TODO: zec support + Zcash, + ]); crate::test_psbt_fixtures!( test_p2sh_p2wsh_script_generation_from_fixture, @@ -435,7 +563,9 @@ mod tests { format, { test_wallet_script_type(fixtures::ScriptType::P2shP2wsh, network, format).unwrap(); - } + }, + // TODO: sighash support + ignore: [BitcoinGold] ); crate::test_psbt_fixtures!( @@ -444,7 +574,9 @@ mod tests { format, { test_wallet_script_type(fixtures::ScriptType::P2wsh, network, format).unwrap(); - } + }, + // TODO: sighash support + ignore: [BitcoinGold] ); crate::test_psbt_fixtures!(test_p2tr_script_generation_from_fixture, network, format, { @@ -469,6 +601,34 @@ mod tests { } ); + crate::test_psbt_fixtures!(test_extract_transaction, network, format, { + let fixture = fixtures::load_psbt_fixture_with_format( + network.to_utxolib_name(), + fixtures::SignatureState::Fullsigned, + format, + ) + .expect("Failed to load fixture"); + let bitgo_psbt = fixture + .to_bitgo_psbt(network) + .expect("Failed to convert to BitGo PSBT"); + let fixture_extracted_transaction = fixture + .extracted_transaction + .expect("Failed to extract transaction"); + + // // Use BitGoPsbt::finalize() which handles MuSig2 inputs + let secp = crate::bitcoin::secp256k1::Secp256k1::new(); + let finalized_psbt = bitgo_psbt.finalize(&secp).expect("Failed to finalize PSBT"); + let extracted_transaction = finalized_psbt + .extract_tx() + .expect("Failed to extract transaction"); + use miniscript::bitcoin::consensus::serialize; + let extracted_transaction_hex = hex::encode(serialize(&extracted_transaction)); + assert_eq!( + extracted_transaction_hex, fixture_extracted_transaction, + "Extracted transaction should match" + ); + }, ignore: [BitcoinGold, BitcoinCash, Ecash, Zcash]); + #[test] fn test_serialize_bitcoin_psbt() { // Test that Bitcoin-like PSBTs can be serialized diff --git a/packages/wasm-utxo/src/fixed_script_wallet/test_utils/fixtures.rs b/packages/wasm-utxo/src/fixed_script_wallet/test_utils/fixtures.rs index 38465b1..69c73d1 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/test_utils/fixtures.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/test_utils/fixtures.rs @@ -1103,26 +1103,133 @@ impl ScriptType { /// let fixture = load_psbt_fixture_with_network(network, SignatureState::Fullsigned).unwrap(); /// // ... test logic using both network and format /// }); +/// +/// // With ignored networks: +/// test_psbt_fixtures!(test_my_feature, network, format, { +/// // test body +/// }, ignore: [BitcoinGold, Zcash]); /// ``` +/// +/// This macro generates separate test functions for each network to enable proper +/// `#[ignore]` support. For a test named `test_foo`, it generates: +/// - `test_foo_bitcoin` +/// - `test_foo_bitcoin_cash` +/// - `test_foo_zcash` (with #[ignore] if in ignore list) +/// - etc. #[macro_export] macro_rules! test_psbt_fixtures { + // Pattern without ignored networks - delegates to the pattern with ignore (backward compatible) ($test_name:ident, $network:ident, $format:ident, $body:block) => { - #[rstest::rstest] - #[case::bitcoin($crate::Network::Bitcoin)] - #[case::bitcoin_cash($crate::Network::BitcoinCash)] - #[case::ecash($crate::Network::Ecash)] - #[case::bitcoin_gold($crate::Network::BitcoinGold)] - #[case::dash($crate::Network::Dash)] - #[case::dogecoin($crate::Network::Dogecoin)] - #[case::litecoin($crate::Network::Litecoin)] - #[case::zcash($crate::Network::Zcash)] - fn $test_name( - #[case] $network: $crate::Network, - #[values( - $crate::fixed_script_wallet::test_utils::fixtures::TxFormat::Psbt, - $crate::fixed_script_wallet::test_utils::fixtures::TxFormat::PsbtLite - )] $format: $crate::fixed_script_wallet::test_utils::fixtures::TxFormat - ) $body + $crate::test_psbt_fixtures!($test_name, $network, $format, $body, ignore: []); + }; + + // Pattern with ignored networks + ($test_name:ident, $network:ident, $format:ident, $body:block, ignore: [$($ignore_net:ident),* $(,)?]) => { + $crate::test_psbt_fixtures!(@generate_test $test_name, bitcoin, Bitcoin, $crate::Network::Bitcoin, $network, $format, $body, [$($ignore_net),*]); + $crate::test_psbt_fixtures!(@generate_test $test_name, bitcoin_cash, BitcoinCash, $crate::Network::BitcoinCash, $network, $format, $body, [$($ignore_net),*]); + $crate::test_psbt_fixtures!(@generate_test $test_name, ecash, Ecash, $crate::Network::Ecash, $network, $format, $body, [$($ignore_net),*]); + $crate::test_psbt_fixtures!(@generate_test $test_name, bitcoin_gold, BitcoinGold, $crate::Network::BitcoinGold, $network, $format, $body, [$($ignore_net),*]); + $crate::test_psbt_fixtures!(@generate_test $test_name, dash, Dash, $crate::Network::Dash, $network, $format, $body, [$($ignore_net),*]); + $crate::test_psbt_fixtures!(@generate_test $test_name, dogecoin, Dogecoin, $crate::Network::Dogecoin, $network, $format, $body, [$($ignore_net),*]); + $crate::test_psbt_fixtures!(@generate_test $test_name, litecoin, Litecoin, $crate::Network::Litecoin, $network, $format, $body, [$($ignore_net),*]); + $crate::test_psbt_fixtures!(@generate_test $test_name, zcash, Zcash, $crate::Network::Zcash, $network, $format, $body, [$($ignore_net),*]); + }; + + // Internal: Generate a test function for a specific network + (@generate_test $test_name:ident, $net_suffix:ident, $net_id:ident, $net_value:path, $network:ident, $format:ident, $body:block, []) => { + ::pastey::paste! { + #[::rstest::rstest] + fn [<$test_name _ $net_suffix>]( + #[values( + $crate::fixed_script_wallet::test_utils::fixtures::TxFormat::Psbt, + $crate::fixed_script_wallet::test_utils::fixtures::TxFormat::PsbtLite + )] + $format: $crate::fixed_script_wallet::test_utils::fixtures::TxFormat + ) { + let $network = $net_value; + $body + } + } + }; + + (@generate_test $test_name:ident, $net_suffix:ident, $net_id:ident, $net_value:path, $network:ident, $format:ident, $body:block, [$($ignore_net:ident),+]) => { + $crate::test_psbt_fixtures!(@check_ignore_and_generate $test_name, $net_suffix, $net_id, $net_value, $network, $format, $body, $($ignore_net),+); + }; + + // Check if this network should be ignored and generate accordingly + (@check_ignore_and_generate $test_name:ident, $net_suffix:ident, $net_id:ident, $net_value:path, $network:ident, $format:ident, $body:block, $($ignore_net:ident),+) => { + $crate::test_psbt_fixtures!(@is_ignored $test_name, $net_suffix, $net_id, $net_value, $network, $format, $body, false, $($ignore_net),+); + }; + + // Check if current network matches any in the ignore list + (@is_ignored $test_name:ident, $net_suffix:ident, Bitcoin, $net_value:path, $network:ident, $format:ident, $body:block, $ignored:tt, Bitcoin $(, $rest:ident)*) => { + $crate::test_psbt_fixtures!(@emit_test $test_name, $net_suffix, $net_value, $network, $format, $body, true); + }; + (@is_ignored $test_name:ident, $net_suffix:ident, BitcoinCash, $net_value:path, $network:ident, $format:ident, $body:block, $ignored:tt, BitcoinCash $(, $rest:ident)*) => { + $crate::test_psbt_fixtures!(@emit_test $test_name, $net_suffix, $net_value, $network, $format, $body, true); + }; + (@is_ignored $test_name:ident, $net_suffix:ident, Ecash, $net_value:path, $network:ident, $format:ident, $body:block, $ignored:tt, Ecash $(, $rest:ident)*) => { + $crate::test_psbt_fixtures!(@emit_test $test_name, $net_suffix, $net_value, $network, $format, $body, true); + }; + (@is_ignored $test_name:ident, $net_suffix:ident, BitcoinGold, $net_value:path, $network:ident, $format:ident, $body:block, $ignored:tt, BitcoinGold $(, $rest:ident)*) => { + $crate::test_psbt_fixtures!(@emit_test $test_name, $net_suffix, $net_value, $network, $format, $body, true); + }; + (@is_ignored $test_name:ident, $net_suffix:ident, Dash, $net_value:path, $network:ident, $format:ident, $body:block, $ignored:tt, Dash $(, $rest:ident)*) => { + $crate::test_psbt_fixtures!(@emit_test $test_name, $net_suffix, $net_value, $network, $format, $body, true); + }; + (@is_ignored $test_name:ident, $net_suffix:ident, Dogecoin, $net_value:path, $network:ident, $format:ident, $body:block, $ignored:tt, Dogecoin $(, $rest:ident)*) => { + $crate::test_psbt_fixtures!(@emit_test $test_name, $net_suffix, $net_value, $network, $format, $body, true); + }; + (@is_ignored $test_name:ident, $net_suffix:ident, Litecoin, $net_value:path, $network:ident, $format:ident, $body:block, $ignored:tt, Litecoin $(, $rest:ident)*) => { + $crate::test_psbt_fixtures!(@emit_test $test_name, $net_suffix, $net_value, $network, $format, $body, true); + }; + (@is_ignored $test_name:ident, $net_suffix:ident, Zcash, $net_value:path, $network:ident, $format:ident, $body:block, $ignored:tt, Zcash $(, $rest:ident)*) => { + $crate::test_psbt_fixtures!(@emit_test $test_name, $net_suffix, $net_value, $network, $format, $body, true); + }; + + // No match - try next + (@is_ignored $test_name:ident, $net_suffix:ident, $net_id:ident, $net_value:path, $network:ident, $format:ident, $body:block, $ignored:tt, $other:ident $(, $rest:ident)*) => { + $crate::test_psbt_fixtures!(@is_ignored $test_name, $net_suffix, $net_id, $net_value, $network, $format, $body, $ignored $(, $rest)*); + }; + + // Exhausted list without match - not ignored + (@is_ignored $test_name:ident, $net_suffix:ident, $net_id:ident, $net_value:path, $network:ident, $format:ident, $body:block, $ignored:tt) => { + $crate::test_psbt_fixtures!(@emit_test $test_name, $net_suffix, $net_value, $network, $format, $body, false); + }; + + // Emit test function - not ignored + (@emit_test $test_name:ident, $net_suffix:ident, $net_value:path, $network:ident, $format:ident, $body:block, false) => { + ::pastey::paste! { + #[::rstest::rstest] + fn [<$test_name _ $net_suffix>]( + #[values( + $crate::fixed_script_wallet::test_utils::fixtures::TxFormat::Psbt, + $crate::fixed_script_wallet::test_utils::fixtures::TxFormat::PsbtLite + )] + $format: $crate::fixed_script_wallet::test_utils::fixtures::TxFormat + ) { + let $network = $net_value; + $body + } + } + }; + + // Emit test function - ignored + (@emit_test $test_name:ident, $net_suffix:ident, $net_value:path, $network:ident, $format:ident, $body:block, true) => { + ::pastey::paste! { + #[ignore] + #[::rstest::rstest] + fn [<$test_name _ $net_suffix>]( + #[values( + $crate::fixed_script_wallet::test_utils::fixtures::TxFormat::Psbt, + $crate::fixed_script_wallet::test_utils::fixtures::TxFormat::PsbtLite + )] + $format: $crate::fixed_script_wallet::test_utils::fixtures::TxFormat + ) { + let $network = $net_value; + $body + } + } }; } @@ -1240,4 +1347,54 @@ mod tests { assert_eq!(p2tr_musig2_fixtures[0].control_blocks.len(), 2); assert_eq!(p2tr_musig2_fixtures[0].tap_tree.leaves.len(), 2); } + + #[test] + fn test_find_input_with_script_type() { + let fixture = load_psbt_fixture("bitcoin", SignatureState::Fullsigned) + .expect("Failed to load fixture"); + + // Test finding P2SH input + let (index, input) = fixture + .find_input_with_script_type(ScriptType::P2sh) + .expect("Failed to find P2SH input"); + assert_eq!(index, 0); + assert!(matches!(input, PsbtInputFixture::P2sh(_))); + + // Test finding P2WSH input + let (index, input) = fixture + .find_input_with_script_type(ScriptType::P2wsh) + .expect("Failed to find P2WSH input"); + assert_eq!(index, 2); + assert!(matches!(input, PsbtInputFixture::P2wsh(_))); + } + + #[test] + fn test_find_finalized_input_with_script_type() { + let fixture = load_psbt_fixture("bitcoin", SignatureState::Fullsigned) + .expect("Failed to load fixture"); + + // Test finding P2SH finalized input + let (index, input) = fixture + .find_finalized_input_with_script_type(ScriptType::P2sh) + .expect("Failed to find P2SH finalized input"); + assert_eq!(index, 0); + assert!(matches!(input, PsbtFinalInputFixture::P2sh(_))); + + // Test finding taproot key path finalized input + let (index, input) = fixture + .find_finalized_input_with_script_type(ScriptType::TaprootKeypath) + .expect("Failed to find taproot key path finalized input"); + assert_eq!(index, 5); + assert!(matches!(input, PsbtFinalInputFixture::P2trMusig2KeyPath(_))); + + // Test with unsigned fixture (should return error) + let unsigned_fixture = load_psbt_fixture("bitcoin", SignatureState::Unsigned) + .expect("Failed to load unsigned fixture"); + let result = unsigned_fixture.find_finalized_input_with_script_type(ScriptType::P2sh); + assert!(result.is_err()); + assert_eq!( + result.unwrap_err(), + "No finalized inputs available in fixture" + ); + } } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs index dbe8e4f..35b4af6 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs @@ -1,10 +1,10 @@ /// Code relating to script types of BitGo's 2-of-3 multisig wallets. -mod bitgo_musig; +pub mod bitgo_musig; mod checkmultisig; mod checksigverify; mod singlesig; -pub use bitgo_musig::{key_agg_bitgo_p2tr_legacy, BitGoMusigError}; +pub use bitgo_musig::BitGoMusigError; pub use checkmultisig::{ build_multisig_script_2_of_3, parse_multisig_script_2_of_3, ScriptP2sh, ScriptP2shP2wsh, ScriptP2wsh, From ddc016558ea03cec3cd827666fdfdb1bfff6fd4b Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 4 Nov 2025 10:06:04 +0100 Subject: [PATCH 3/3] feat(wasm-utxo): add proper parsing of BitGo PSBT extensions Add structured parsing for Musig2 PSBT extensions: - Participants data - Public nonces - Partial signatures These are now displayed in a human-readable format in the CLI tool instead of raw byte arrays. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/cli/src/parse/node.rs | 155 ++++++++++++++++-- .../test/fixtures/psbt_bitcoin_fullsigned.txt | 47 +++--- 2 files changed, 165 insertions(+), 37 deletions(-) diff --git a/packages/wasm-utxo/cli/src/parse/node.rs b/packages/wasm-utxo/cli/src/parse/node.rs index 5519cb7..9182e5b 100644 --- a/packages/wasm-utxo/cli/src/parse/node.rs +++ b/packages/wasm-utxo/cli/src/parse/node.rs @@ -3,6 +3,10 @@ use bitcoin::consensus::Decodable; use bitcoin::hashes::Hash; use bitcoin::psbt::Psbt; use bitcoin::{Network, ScriptBuf, Transaction}; +use wasm_utxo::bitgo_psbt::{ + BitGoKeyValue, Musig2PartialSig, Musig2Participants, Musig2PubNonce, ProprietaryKeySubtype, + BITGO, +}; pub use crate::node::{Node, Primitive}; @@ -39,24 +43,151 @@ fn bip32_derivations_to_nodes( .collect() } +fn musig2_participants_to_node(participants: &Musig2Participants) -> Node { + let mut node = Node::new("musig2_participants", Primitive::None); + node.add_child(Node::new( + "tap_output_key", + Primitive::Buffer(participants.tap_output_key.serialize().to_vec()), + )); + node.add_child(Node::new( + "tap_internal_key", + Primitive::Buffer(participants.tap_internal_key.serialize().to_vec()), + )); + + let mut participants_node = Node::new("participant_pub_keys", Primitive::U64(2)); + for (i, pub_key) in participants.participant_pub_keys.iter().enumerate() { + let pub_key_vec: Vec = pub_key.to_bytes().to_vec(); + participants_node.add_child(Node::new( + format!("participant_{}", i), + Primitive::Buffer(pub_key_vec), + )); + } + node.add_child(participants_node); + node +} + +fn musig2_pub_nonce_to_node(nonce: &Musig2PubNonce) -> Node { + let mut node = Node::new("musig2_pub_nonce", Primitive::None); + node.add_child(Node::new( + "participant_pub_key", + Primitive::Buffer(nonce.participant_pub_key.to_bytes().to_vec()), + )); + node.add_child(Node::new( + "tap_output_key", + Primitive::Buffer(nonce.tap_output_key.serialize().to_vec()), + )); + node.add_child(Node::new( + "pub_nonce", + Primitive::Buffer(nonce.pub_nonce.serialize().to_vec()), + )); + node +} + +fn musig2_partial_sig_to_node(sig: &Musig2PartialSig) -> Node { + let mut node = Node::new("musig2_partial_sig", Primitive::None); + node.add_child(Node::new( + "participant_pub_key", + Primitive::Buffer(sig.participant_pub_key.to_bytes().to_vec()), + )); + node.add_child(Node::new( + "tap_output_key", + Primitive::Buffer(sig.tap_output_key.serialize().to_vec()), + )); + node.add_child(Node::new( + "partial_sig", + Primitive::Buffer(sig.partial_sig.clone()), + )); + node +} + +fn bitgo_proprietary_to_node(prop_key: &bitcoin::psbt::raw::ProprietaryKey, v: &[u8]) -> Node { + // Try to parse as BitGo key-value + let v_vec = v.to_vec(); + let bitgo_kv_result = BitGoKeyValue::from_key_value(prop_key, &v_vec); + + match bitgo_kv_result { + Ok(bitgo_kv) => { + // Parse based on subtype + match bitgo_kv.subtype { + ProprietaryKeySubtype::Musig2ParticipantPubKeys => { + match Musig2Participants::from_key_value(&bitgo_kv) { + Ok(participants) => musig2_participants_to_node(&participants), + Err(_) => { + // Fall back to raw display + raw_proprietary_to_node("musig2_participants_error", prop_key, v) + } + } + } + ProprietaryKeySubtype::Musig2PubNonce => { + match Musig2PubNonce::from_key_value(&bitgo_kv) { + Ok(nonce) => musig2_pub_nonce_to_node(&nonce), + Err(_) => { + // Fall back to raw display + raw_proprietary_to_node("musig2_pub_nonce_error", prop_key, v) + } + } + } + ProprietaryKeySubtype::Musig2PartialSig => { + match Musig2PartialSig::from_key_value(&bitgo_kv) { + Ok(sig) => musig2_partial_sig_to_node(&sig), + Err(_) => { + // Fall back to raw display + raw_proprietary_to_node("musig2_partial_sig_error", prop_key, v) + } + } + } + _ => { + // Other BitGo subtypes - show with name + let subtype_name = match bitgo_kv.subtype { + ProprietaryKeySubtype::ZecConsensusBranchId => "zec_consensus_branch_id", + ProprietaryKeySubtype::PayGoAddressAttestationProof => { + "paygo_address_attestation_proof" + } + ProprietaryKeySubtype::Bip322Message => "bip322_message", + _ => "unknown", + }; + raw_proprietary_to_node(subtype_name, prop_key, v) + } + } + } + Err(_) => { + // Not a valid BitGo key-value, show raw + raw_proprietary_to_node("unknown", prop_key, v) + } + } +} + +fn raw_proprietary_to_node( + label: &str, + prop_key: &bitcoin::psbt::raw::ProprietaryKey, + v: &[u8], +) -> Node { + let mut prop_node = Node::new(label, Primitive::None); + prop_node.add_child(Node::new( + "prefix", + Primitive::String(String::from_utf8_lossy(&prop_key.prefix).to_string()), + )); + prop_node.add_child(Node::new("subtype", Primitive::U8(prop_key.subtype))); + prop_node.add_child(Node::new( + "key_data", + Primitive::Buffer(prop_key.key.to_vec()), + )); + prop_node.add_child(Node::new("value", Primitive::Buffer(v.to_vec()))); + prop_node +} + fn proprietary_to_nodes( proprietary: &std::collections::BTreeMap>, ) -> Vec { proprietary .iter() .map(|(prop_key, v)| { - let mut prop_node = Node::new("key", Primitive::None); - prop_node.add_child(Node::new( - "prefix", - Primitive::String(String::from_utf8_lossy(&prop_key.prefix).to_string()), - )); - prop_node.add_child(Node::new("subtype", Primitive::U8(prop_key.subtype))); - prop_node.add_child(Node::new( - "key_data", - Primitive::Buffer(prop_key.key.to_vec()), - )); - prop_node.add_child(Node::new("value", Primitive::Buffer(v.to_vec()))); - prop_node + // Check if this is a BITGO proprietary key + if prop_key.prefix.as_slice() == BITGO { + bitgo_proprietary_to_node(prop_key, v) + } else { + raw_proprietary_to_node("key", prop_key, v) + } }) .collect() } diff --git a/packages/wasm-utxo/cli/test/fixtures/psbt_bitcoin_fullsigned.txt b/packages/wasm-utxo/cli/test/fixtures/psbt_bitcoin_fullsigned.txt index 244c499..deae6d7 100644 --- a/packages/wasm-utxo/cli/test/fixtures/psbt_bitcoin_fullsigned.txt +++ b/packages/wasm-utxo/cli/test/fixtures/psbt_bitcoin_fullsigned.txt @@ -182,31 +182,28 @@ psbt: None │ │ ├─ sighash_type: 0u32 │ │ ├─ sighash_type: SIGHASH_DEFAULT │ │ └─ proprietary: 5u64 -│ │ ├─ key: None -│ │ │ ├─ prefix: BITGO -│ │ │ ├─ subtype: 1u8 -│ │ │ ├─ key_data: 15c5815026f6a54b10194fc6980f1866a02d9ec128533c7997cdb4289bf3ef16eb5ad29a85aed24de2880e774caaf624f9cb1be09c67ed4aefbb9b7bc12ddf1a (64 bytes) -│ │ │ └─ value: 021d978a17486ff9e47c82990269e531fc63981419d4ce73ee8bd2c99661c53953020fdea69e40a3adef3cdc7fa6f3af02f4c9d9e3254503c96a6a2b4aa66e778171 (66 bytes) -│ │ ├─ key: None -│ │ │ ├─ prefix: BITGO -│ │ │ ├─ subtype: 2u8 -│ │ │ ├─ key_data: 020fdea69e40a3adef3cdc7fa6f3af02f4c9d9e3254503c96a6a2b4aa66e77817115c5815026f6a54b10194fc6980f1866a02d9ec128533c7997cdb4289bf3ef16 (65 bytes) -│ │ │ └─ value: 02826949740dff45d408b1f19d94c720f53411e02c525b28ab3c593b6b530fe0370374b8a0ffcaaaee6b772dac5f7c23ef33670b32ec77c6d41efb3c36df2165a094 (66 bytes) -│ │ ├─ key: None -│ │ │ ├─ prefix: BITGO -│ │ │ ├─ subtype: 2u8 -│ │ │ ├─ key_data: 021d978a17486ff9e47c82990269e531fc63981419d4ce73ee8bd2c99661c5395315c5815026f6a54b10194fc6980f1866a02d9ec128533c7997cdb4289bf3ef16 (65 bytes) -│ │ │ └─ value: 03a4aaf46f3a0bc39a73855fa875b2f2f04bdb06235afbaedf857b593dddc63cb402cdb7f1a93ec526282198d834423371757e8f43932d03b9f43c3f7378300ae508 (66 bytes) -│ │ ├─ key: None -│ │ │ ├─ prefix: BITGO -│ │ │ ├─ subtype: 3u8 -│ │ │ ├─ key_data: 020fdea69e40a3adef3cdc7fa6f3af02f4c9d9e3254503c96a6a2b4aa66e77817115c5815026f6a54b10194fc6980f1866a02d9ec128533c7997cdb4289bf3ef16 (65 bytes) -│ │ │ └─ value: fbdc39c3b8ffca4e6caf3298fa1a4be546b99163c2f21bd374d2254ec5b3c0f4 (32 bytes) -│ │ └─ key: None -│ │ ├─ prefix: BITGO -│ │ ├─ subtype: 3u8 -│ │ ├─ key_data: 021d978a17486ff9e47c82990269e531fc63981419d4ce73ee8bd2c99661c5395315c5815026f6a54b10194fc6980f1866a02d9ec128533c7997cdb4289bf3ef16 (65 bytes) -│ │ └─ value: 1cd8a0c0598b0d88f889fdf9a3140f8f088e2187803a2faaee3f13c0163eb76c (32 bytes) +│ │ ├─ musig2_participants: None +│ │ │ ├─ tap_output_key: 15c5815026f6a54b10194fc6980f1866a02d9ec128533c7997cdb4289bf3ef16 (32 bytes) +│ │ │ ├─ tap_internal_key: eb5ad29a85aed24de2880e774caaf624f9cb1be09c67ed4aefbb9b7bc12ddf1a (32 bytes) +│ │ │ └─ participant_pub_keys: 2u64 +│ │ │ ├─ participant_0: 021d978a17486ff9e47c82990269e531fc63981419d4ce73ee8bd2c99661c53953 (33 bytes) +│ │ │ └─ participant_1: 020fdea69e40a3adef3cdc7fa6f3af02f4c9d9e3254503c96a6a2b4aa66e778171 (33 bytes) +│ │ ├─ musig2_pub_nonce: None +│ │ │ ├─ participant_pub_key: 020fdea69e40a3adef3cdc7fa6f3af02f4c9d9e3254503c96a6a2b4aa66e778171 (33 bytes) +│ │ │ ├─ tap_output_key: 15c5815026f6a54b10194fc6980f1866a02d9ec128533c7997cdb4289bf3ef16 (32 bytes) +│ │ │ └─ pub_nonce: 02826949740dff45d408b1f19d94c720f53411e02c525b28ab3c593b6b530fe0370374b8a0ffcaaaee6b772dac5f7c23ef33670b32ec77c6d41efb3c36df2165a094 (66 bytes) +│ │ ├─ musig2_pub_nonce: None +│ │ │ ├─ participant_pub_key: 021d978a17486ff9e47c82990269e531fc63981419d4ce73ee8bd2c99661c53953 (33 bytes) +│ │ │ ├─ tap_output_key: 15c5815026f6a54b10194fc6980f1866a02d9ec128533c7997cdb4289bf3ef16 (32 bytes) +│ │ │ └─ pub_nonce: 03a4aaf46f3a0bc39a73855fa875b2f2f04bdb06235afbaedf857b593dddc63cb402cdb7f1a93ec526282198d834423371757e8f43932d03b9f43c3f7378300ae508 (66 bytes) +│ │ ├─ musig2_partial_sig: None +│ │ │ ├─ participant_pub_key: 020fdea69e40a3adef3cdc7fa6f3af02f4c9d9e3254503c96a6a2b4aa66e778171 (33 bytes) +│ │ │ ├─ tap_output_key: 15c5815026f6a54b10194fc6980f1866a02d9ec128533c7997cdb4289bf3ef16 (32 bytes) +│ │ │ └─ partial_sig: fbdc39c3b8ffca4e6caf3298fa1a4be546b99163c2f21bd374d2254ec5b3c0f4 (32 bytes) +│ │ └─ musig2_partial_sig: None +│ │ ├─ participant_pub_key: 021d978a17486ff9e47c82990269e531fc63981419d4ce73ee8bd2c99661c53953 (33 bytes) +│ │ ├─ tap_output_key: 15c5815026f6a54b10194fc6980f1866a02d9ec128533c7997cdb4289bf3ef16 (32 bytes) +│ │ └─ partial_sig: 1cd8a0c0598b0d88f889fdf9a3140f8f088e2187803a2faaee3f13c0163eb76c (32 bytes) │ └─ input_6: None │ ├─ non_witness_utxo: 97441d99a8d66f124ab3c9de26b87bd00aeb1547051c842a88165c1b089ee902 (32 bytes) │ ├─ redeem_script: 210336ef228ffe9b8efffba052c32d334660dd1f8366cf8fe44ae5aa672b6b629095ac (35 bytes)