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/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) 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" 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,