From b7581b34a4cbd6131d9d8e3bc8b88d47971f7472 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 4 Nov 2025 13:00:04 +0100 Subject: [PATCH 1/8] feat(wasm-utxo): add multi-stage PSBT fixture loading helpers Introduces PsbtStages and PsbtInputStages types to simplify testing across different signing stages (unsigned, halfsigned, fullsigned). Refactors test helpers to use these new types for more structured testing. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/src/bitgo_psbt/mod.rs | 42 +++----- .../test_utils/fixtures.rs | 100 ++++++++++++++++++ .../src/fixed_script_wallet/wallet_keys.rs | 2 +- 3 files changed, 114 insertions(+), 30 deletions(-) diff --git a/packages/wasm-utxo/src/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/bitgo_psbt/mod.rs index 07f15bf..d7a4c8e 100644 --- a/packages/wasm-utxo/src/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/bitgo_psbt/mod.rs @@ -238,7 +238,6 @@ mod tests { use crate::fixed_script_wallet::{RootWalletKeys, WalletScripts}; use crate::test_utils::fixtures; use base64::engine::{general_purpose::STANDARD as BASE64_STANDARD, Engine}; - use miniscript::bitcoin::bip32::Xpub; use miniscript::bitcoin::consensus::Decodable; use miniscript::bitcoin::Transaction; @@ -386,7 +385,7 @@ mod tests { output.script_pubkey.to_hex_string() } - fn assert_matches_wallet_scripts( + fn assert_full_signed_matches_wallet_scripts( network: Network, tx_format: fixtures::TxFormat, fixture: &fixtures::PsbtFixture, @@ -497,50 +496,35 @@ mod tests { 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"), - ); + let psbt_stages = fixtures::PsbtStages::load(network, tx_format)?; + let psbt_input_stages = + fixtures::PsbtInputStages::from_psbt_stages(&psbt_stages, script_type); // 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(), + psbt_input_stages.is_err(), "Expected error for unsupported script type" ); return Ok(()); } - let (input_index, input_fixture) = input_fixture.unwrap(); + let psbt_input_stages = psbt_input_stages.unwrap(); - assert_matches_wallet_scripts( + assert_full_signed_matches_wallet_scripts( network, tx_format, - &fixture, - &wallet_keys, - input_index, - input_fixture, + &psbt_stages.fullsigned, + &psbt_input_stages.wallet_keys, + psbt_input_stages.input_index, + &psbt_input_stages.input_fixture_fullsigned, )?; assert_finalize_input( - fixture.to_bitgo_psbt(network).unwrap(), - input_index, + psbt_stages.fullsigned.to_bitgo_psbt(network).unwrap(), + psbt_input_stages.input_index, network, tx_format, )?; 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 69c73d1..039bb77 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 @@ -480,6 +480,106 @@ pub struct PsbtFixture { pub extracted_transaction: Option, } +// Test helper types for multi-stage PSBT testing + +pub struct PsbtStages { + pub network: Network, + pub tx_format: TxFormat, + pub wallet_keys: crate::fixed_script_wallet::RootWalletKeys, + pub unsigned: PsbtFixture, + pub halfsigned: PsbtFixture, + pub fullsigned: PsbtFixture, +} + +impl PsbtStages { + pub fn load(network: Network, tx_format: TxFormat) -> Result { + let unsigned = load_psbt_fixture_with_format( + network.to_utxolib_name(), + SignatureState::Unsigned, + tx_format, + ) + .expect("Failed to load unsigned fixture"); + let halfsigned = load_psbt_fixture_with_format( + network.to_utxolib_name(), + SignatureState::Halfsigned, + tx_format, + ) + .expect("Failed to load halfsigned fixture"); + let fullsigned = load_psbt_fixture_with_format( + network.to_utxolib_name(), + SignatureState::Fullsigned, + tx_format, + ) + .expect("Failed to load fullsigned fixture"); + let wallet_keys_unsigned = + parse_wallet_keys(&unsigned).expect("Failed to parse wallet keys"); + let wallet_keys_halfsigned = + parse_wallet_keys(&halfsigned).expect("Failed to parse wallet keys"); + let wallet_keys_fullsigned = + parse_wallet_keys(&fullsigned).expect("Failed to parse wallet keys"); + assert_eq!(wallet_keys_unsigned, wallet_keys_halfsigned); + assert_eq!(wallet_keys_unsigned, wallet_keys_fullsigned); + let secp = crate::bitcoin::secp256k1::Secp256k1::new(); + let wallet_keys = crate::fixed_script_wallet::RootWalletKeys::new( + wallet_keys_unsigned + .iter() + .map(|x| crate::bitcoin::bip32::Xpub::from_priv(&secp, x)) + .collect::>() + .try_into() + .expect("Failed to convert to XpubTriple"), + ); + + Ok(Self { + network, + tx_format, + wallet_keys, + unsigned, + halfsigned, + fullsigned, + }) + } +} + +pub struct PsbtInputStages { + pub network: Network, + pub tx_format: TxFormat, + pub wallet_keys: crate::fixed_script_wallet::RootWalletKeys, + pub wallet_script_type: ScriptType, + pub input_index: usize, + pub input_fixture_unsigned: PsbtInputFixture, + pub input_fixture_halfsigned: PsbtInputFixture, + pub input_fixture_fullsigned: PsbtInputFixture, +} + +impl PsbtInputStages { + pub fn from_psbt_stages( + psbt_stages: &PsbtStages, + wallet_script_type: ScriptType, + ) -> Result { + let input_fixture_unsigned = psbt_stages + .unsigned + .find_input_with_script_type(wallet_script_type)?; + let input_fixture_halfsigned = psbt_stages + .halfsigned + .find_input_with_script_type(wallet_script_type)?; + let input_fixture_fullsigned = psbt_stages + .fullsigned + .find_input_with_script_type(wallet_script_type)?; + assert_eq!(input_fixture_unsigned.0, input_fixture_halfsigned.0); + assert_eq!(input_fixture_unsigned.0, input_fixture_fullsigned.0); + Ok(Self { + network: psbt_stages.network, + tx_format: psbt_stages.tx_format, + wallet_keys: psbt_stages.wallet_keys.clone(), + wallet_script_type, + input_index: input_fixture_unsigned.0, + input_fixture_unsigned: input_fixture_unsigned.1.clone(), + input_fixture_halfsigned: input_fixture_halfsigned.1.clone(), + input_fixture_fullsigned: input_fixture_fullsigned.1.clone(), + }) + } +} + /// Helper function to find a unique input matching a predicate fn find_unique_input<'a, T, I, F>( iter: I, diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs index ba7660a..4067999 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs @@ -32,7 +32,7 @@ pub fn to_pub_triple(xpubs: &XpubTriple) -> PubTriple { .expect("could not convert vec to array") } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct RootWalletKeys { xpubs: XpubTriple, derivation_prefixes: [DerivationPath; 3], From 49829c8d1f2dc616f4f1a380e78a6c26eaefc4f6 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 4 Nov 2025 13:19:54 +0100 Subject: [PATCH 2/8] feat(wasm-utxo): add test for half-signing unsigned PSBTs Implement a helper function for testing that a user signature can be applied to an unsigned PSBT. Sets up the test framework but leaves the implementation as a todo for later. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/src/bitgo_psbt/mod.rs | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/wasm-utxo/src/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/bitgo_psbt/mod.rs index d7a4c8e..6cb0483 100644 --- a/packages/wasm-utxo/src/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/bitgo_psbt/mod.rs @@ -385,6 +385,20 @@ mod tests { output.script_pubkey.to_hex_string() } + // ensure we can put the first signature (user signature) on an unsigned PSBT + fn assert_half_sign( + network: Network, + tx_format: fixtures::TxFormat, + unsigned_bitgo_psbt: &BitGoPsbt, + wallet_keys: &RootWalletKeys, + input_index: usize, + input_fixture: &fixtures::PsbtInputFixture, + halfsigned_fixture: &fixtures::PsbtInputFixture, + ) -> Result<(), String> { + // todo!() + Ok(()) + } + fn assert_full_signed_matches_wallet_scripts( network: Network, tx_format: fixtures::TxFormat, @@ -513,6 +527,19 @@ mod tests { let psbt_input_stages = psbt_input_stages.unwrap(); + assert_half_sign( + network, + tx_format, + &psbt_stages + .unsigned + .to_bitgo_psbt(network) + .expect("Failed to convert to BitGo PSBT"), + &psbt_input_stages.wallet_keys, + psbt_input_stages.input_index, + &psbt_input_stages.input_fixture_unsigned, + &psbt_input_stages.input_fixture_halfsigned, + )?; + assert_full_signed_matches_wallet_scripts( network, tx_format, From 2e6c5a4de67369036a0c144e2ac45ab3b3cc9121 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 4 Nov 2025 13:20:08 +0100 Subject: [PATCH 3/8] feat(wasm-utxo): refactor key handling with XprvTriple Improves wallet key management by adding XprvTriple struct to replace loose vector handling of extended private keys. This provides: - Type safety with fixed array instead of Vec - Named accessor methods for keys (user, backup, bitgo) - Conversion utility to RootWalletKeys - Simplifies test fixtures with direct key access Issue: BTC-2652 Co-authored-by: llm-git --- .../test_utils/fixtures.rs | 89 ++++++++++++------- .../src/fixed_script_wallet/wallet_keys.rs | 11 ++- .../wallet_scripts/singlesig.rs | 29 ++---- 3 files changed, 70 insertions(+), 59 deletions(-) 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 039bb77..ebd642d 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 @@ -16,7 +16,7 @@ //! .expect("Failed to decode PSBT"); //! //! // Parse wallet keys (xprv) -//! let xprvs = parse_wallet_keys(&fixture) +//! let xprvs = fixture.get_wallet_xprvs() //! .expect("Failed to parse wallet keys"); //! //! // Access fixture data @@ -38,10 +38,50 @@ //! } //! ``` +use std::str::FromStr; + +use crate::{bitcoin::bip32::Xpriv, fixed_script_wallet::RootWalletKeys}; +use miniscript::bitcoin::bip32::Xpub; use serde::{Deserialize, Serialize}; use crate::Network; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct XprvTriple([Xpriv; 3]); + +impl XprvTriple { + pub fn new(xprvs: [Xpriv; 3]) -> Self { + Self(xprvs) + } + + pub fn from_strings(strings: Vec) -> Result> { + let xprvs = strings + .iter() + .map(|s| Xpriv::from_str(s).map_err(|e| Box::new(e) as Box)) + .collect::, _>>()?; + Ok(Self::new( + xprvs.try_into().expect("Expected exactly 3 xprvs"), + )) + } + + pub fn user_key(&self) -> &Xpriv { + &self.0[0] + } + + pub fn backup_key(&self) -> &Xpriv { + &self.0[1] + } + + pub fn bitgo_key(&self) -> &Xpriv { + &self.0[2] + } + + pub fn to_root_wallet_keys(&self) -> RootWalletKeys { + let secp = crate::bitcoin::secp256k1::Secp256k1::new(); + RootWalletKeys::new(self.0.map(|x| Xpub::from_priv(&secp, &x))) + } +} + // Basic helper types (no dependencies on other types in this file) #[derive(Debug, Clone, Deserialize, Serialize)] @@ -511,23 +551,18 @@ impl PsbtStages { tx_format, ) .expect("Failed to load fullsigned fixture"); - let wallet_keys_unsigned = - parse_wallet_keys(&unsigned).expect("Failed to parse wallet keys"); - let wallet_keys_halfsigned = - parse_wallet_keys(&halfsigned).expect("Failed to parse wallet keys"); - let wallet_keys_fullsigned = - parse_wallet_keys(&fullsigned).expect("Failed to parse wallet keys"); + let wallet_keys_unsigned = unsigned + .get_wallet_xprvs() + .expect("Failed to parse wallet keys"); + let wallet_keys_halfsigned = halfsigned + .get_wallet_xprvs() + .expect("Failed to parse wallet keys"); + let wallet_keys_fullsigned = fullsigned + .get_wallet_xprvs() + .expect("Failed to parse wallet keys"); assert_eq!(wallet_keys_unsigned, wallet_keys_halfsigned); assert_eq!(wallet_keys_unsigned, wallet_keys_fullsigned); - let secp = crate::bitcoin::secp256k1::Secp256k1::new(); - let wallet_keys = crate::fixed_script_wallet::RootWalletKeys::new( - wallet_keys_unsigned - .iter() - .map(|x| crate::bitcoin::bip32::Xpub::from_priv(&secp, x)) - .collect::>() - .try_into() - .expect("Failed to convert to XpubTriple"), - ); + let wallet_keys = wallet_keys_unsigned.to_root_wallet_keys(); Ok(Self { network, @@ -614,6 +649,11 @@ impl PsbtFixture { Ok(psbt) } + /// Parse wallet keys from fixture (xprv strings) + pub fn get_wallet_xprvs(&self) -> Result> { + XprvTriple::from_strings(self.wallet_keys.clone()) + } + pub fn find_input_with_script_type( &self, script_type: ScriptType, @@ -807,19 +847,6 @@ pub fn decode_psbt_from_fixture( Ok(psbt) } -/// Parse wallet keys from fixture (xprv strings) -pub fn parse_wallet_keys( - fixture: &PsbtFixture, -) -> Result, Box> { - use std::str::FromStr; - - fixture - .wallet_keys - .iter() - .map(|key_str| crate::bitcoin::bip32::Xpriv::from_str(key_str).map_err(|e| e.into())) - .collect() -} - // Helper functions for validation /// Compares a generated hex string with an expected hex string @@ -1382,10 +1409,6 @@ mod tests { let psbt = decode_psbt_from_fixture(&fixture).expect("Failed to decode PSBT"); assert_eq!(psbt.inputs.len(), 7); assert_eq!(psbt.outputs.len(), 5); - - // Parse wallet keys - let xprvs = parse_wallet_keys(&fixture).expect("Failed to parse wallet keys"); - assert_eq!(xprvs.len(), 3); } #[test] diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs index 4067999..2f2d551 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs @@ -32,6 +32,12 @@ pub fn to_pub_triple(xpubs: &XpubTriple) -> PubTriple { .expect("could not convert vec to array") } +pub fn derivation_path(prefix: &DerivationPath, chain: u32, index: u32) -> DerivationPath { + prefix + .child(ChildNumber::Normal { index: chain }) + .child(ChildNumber::Normal { index }) +} + #[derive(Debug, Clone)] pub struct RootWalletKeys { xpubs: XpubTriple, @@ -68,10 +74,7 @@ impl RootWalletKeys { let paths: Vec = self .derivation_prefixes .iter() - .map(|p| { - p.child(ChildNumber::Normal { index: chain }) - .child(ChildNumber::Normal { index }) - }) + .map(|p| derivation_path(p, chain, index)) .collect::>(); let ctx = Secp256k1::new(); diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/singlesig.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/singlesig.rs index 95cc802..5510e57 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/singlesig.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/singlesig.rs @@ -31,11 +31,11 @@ impl ScriptP2shP2pk { #[cfg(test)] mod tests { + use miniscript::bitcoin::bip32::Xpub; + use super::*; use crate::bitcoin::secp256k1::Secp256k1; - use crate::fixed_script_wallet::test_utils::fixtures::{ - load_psbt_fixture, parse_wallet_keys, SignatureState, - }; + use crate::fixed_script_wallet::test_utils::fixtures::{load_psbt_fixture, SignatureState}; #[test] fn test_p2sh_p2pk_script_generation_from_fixture() { @@ -63,26 +63,11 @@ mod tests { .expect("No partial signature found"); // Parse the wallet keys - let xprvs = parse_wallet_keys(&fixture).expect("Failed to parse wallet keys"); + let xprvs = fixture + .get_wallet_xprvs() + .expect("Failed to parse wallet keys"); let secp = Secp256k1::new(); - - // Find which key matches the expected pubkey - let mut matching_key = None; - for xprv in &xprvs { - let xpub = crate::bitcoin::bip32::Xpub::from_priv(&secp, xprv); - // Convert secp256k1::PublicKey to bitcoin::PublicKey - let bitcoin_pubkey = crate::bitcoin::PublicKey::new(xpub.public_key); - let compressed_pubkey = CompressedPublicKey::try_from(bitcoin_pubkey) - .expect("Failed to convert to compressed pubkey"); - let pubkey_hex = hex::encode(compressed_pubkey.to_bytes()); - - if pubkey_hex == *expected_pubkey { - matching_key = Some(compressed_pubkey); - break; - } - } - - let pubkey = matching_key.expect("Could not find matching pubkey in wallet keys"); + let pubkey = Xpub::from_priv(&secp, xprvs.user_key()).to_pub(); // Build the p2sh-p2pk script let script = ScriptP2shP2pk::new(pubkey); From 9169c5a162bb8f24e672ca6a037456909504802a Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 4 Nov 2025 13:27:49 +0100 Subject: [PATCH 4/8] feat(wasm-utxo): update test utils to use XprvTriple directly Use fixtures::XprvTriple instead of RootWalletKeys in test utils to simplify signature testing with wallets. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/src/bitgo_psbt/mod.rs | 8 ++++---- .../src/fixed_script_wallet/test_utils/fixtures.rs | 7 +++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/wasm-utxo/src/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/bitgo_psbt/mod.rs index 6cb0483..72236b3 100644 --- a/packages/wasm-utxo/src/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/bitgo_psbt/mod.rs @@ -390,12 +390,12 @@ mod tests { network: Network, tx_format: fixtures::TxFormat, unsigned_bitgo_psbt: &BitGoPsbt, - wallet_keys: &RootWalletKeys, + wallet_keys: &fixtures::XprvTriple, input_index: usize, input_fixture: &fixtures::PsbtInputFixture, halfsigned_fixture: &fixtures::PsbtInputFixture, ) -> Result<(), String> { - // todo!() + let user_key = wallet_keys.user_key(); Ok(()) } @@ -403,14 +403,14 @@ mod tests { network: Network, tx_format: fixtures::TxFormat, fixture: &fixtures::PsbtFixture, - wallet_keys: &RootWalletKeys, + wallet_keys: &fixtures::XprvTriple, input_index: usize, input_fixture: &fixtures::PsbtInputFixture, ) -> Result<(), String> { let (chain, index) = parse_fixture_paths(input_fixture).expect("Failed to parse fixture paths"); let scripts = WalletScripts::from_wallet_keys( - wallet_keys, + &wallet_keys.to_root_wallet_keys(), chain, index, &network.output_script_support(), 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 ebd642d..351c0c8 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 @@ -525,7 +525,7 @@ pub struct PsbtFixture { pub struct PsbtStages { pub network: Network, pub tx_format: TxFormat, - pub wallet_keys: crate::fixed_script_wallet::RootWalletKeys, + pub wallet_keys: XprvTriple, pub unsigned: PsbtFixture, pub halfsigned: PsbtFixture, pub fullsigned: PsbtFixture, @@ -562,12 +562,11 @@ impl PsbtStages { .expect("Failed to parse wallet keys"); assert_eq!(wallet_keys_unsigned, wallet_keys_halfsigned); assert_eq!(wallet_keys_unsigned, wallet_keys_fullsigned); - let wallet_keys = wallet_keys_unsigned.to_root_wallet_keys(); Ok(Self { network, tx_format, - wallet_keys, + wallet_keys: wallet_keys_unsigned.clone(), unsigned, halfsigned, fullsigned, @@ -578,7 +577,7 @@ impl PsbtStages { pub struct PsbtInputStages { pub network: Network, pub tx_format: TxFormat, - pub wallet_keys: crate::fixed_script_wallet::RootWalletKeys, + pub wallet_keys: XprvTriple, pub wallet_script_type: ScriptType, pub input_index: usize, pub input_fixture_unsigned: PsbtInputFixture, From b12c726f25ea5c5b19de9be37bb4c459ae209d48 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 4 Nov 2025 13:50:38 +0100 Subject: [PATCH 5/8] feat(wasm-utxo): add sign method to BitGoPsbt Implement a sign method for the BitGoPsbt struct that wraps the underlying miniscript PSBT signing capability. This allows direct signing of transactions with keys that implement the GetKey trait and is tested with existing fixtures. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/src/bitgo_psbt/mod.rs | 126 +++++++++++++++--- .../test_utils/fixtures.rs | 16 +++ 2 files changed, 125 insertions(+), 17 deletions(-) diff --git a/packages/wasm-utxo/src/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/bitgo_psbt/mod.rs index 72236b3..99ec8d9 100644 --- a/packages/wasm-utxo/src/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/bitgo_psbt/mod.rs @@ -229,14 +229,55 @@ impl BitGoPsbt { )), } } + + /// Sign the PSBT with the provided key. + /// Wraps the underlying PSBT's sign method from miniscript::psbt::PsbtExt. + /// + /// # Type Parameters + /// - `C`: Signing context from secp256k1 + /// - `K`: Key type that implements `psbt::GetKey` trait + /// + /// # Returns + /// - `Ok(SigningKeysMap)` on success, mapping input index to keys used for signing + /// - `Err((SigningKeysMap, SigningErrors))` on failure, containing both partial success info and errors + pub fn sign( + &mut self, + k: &K, + secp: &secp256k1::Secp256k1, + ) -> Result< + miniscript::bitcoin::psbt::SigningKeysMap, + ( + miniscript::bitcoin::psbt::SigningKeysMap, + miniscript::bitcoin::psbt::SigningErrors, + ), + > + where + C: secp256k1::Signing + secp256k1::Verification, + K: miniscript::bitcoin::psbt::GetKey, + { + match self { + BitGoPsbt::BitcoinLike(ref mut psbt, _network) => psbt.sign(k, secp), + BitGoPsbt::Zcash(_zcash_psbt, _network) => { + // Return an error indicating Zcash signing is not implemented + Err(( + Default::default(), + std::collections::BTreeMap::from_iter([( + 0, + miniscript::bitcoin::psbt::SignError::KeyNotFound, + )]), + )) + } + } + } } #[cfg(test)] mod tests { use super::*; use crate::fixed_script_wallet::Chain; - use crate::fixed_script_wallet::{RootWalletKeys, WalletScripts}; + use crate::fixed_script_wallet::WalletScripts; use crate::test_utils::fixtures; + use crate::test_utils::fixtures::assert_hex_eq; use base64::engine::{general_purpose::STANDARD as BASE64_STANDARD, Engine}; use miniscript::bitcoin::consensus::Decodable; use miniscript::bitcoin::Transaction; @@ -385,17 +426,63 @@ mod tests { output.script_pubkey.to_hex_string() } + type PartialSignatures = + std::collections::BTreeMap; + + fn assert_eq_partial_signatures( + actual: &PartialSignatures, + expected: &PartialSignatures, + ) -> Result<(), String> { + assert_eq!( + actual.len(), + expected.len(), + "Partial signatures should match" + ); + for (actual_sig, expected_sig) in actual.iter().zip(expected.iter()) { + assert_eq!(actual_sig.0, expected_sig.0, "Public key should match"); + assert_hex_eq( + &hex::encode(actual_sig.1.serialize()), + &hex::encode(expected_sig.1.serialize()), + "Signature", + )?; + } + Ok(()) + } + // ensure we can put the first signature (user signature) on an unsigned PSBT fn assert_half_sign( - network: Network, - tx_format: fixtures::TxFormat, unsigned_bitgo_psbt: &BitGoPsbt, + halfsigned_bitgo_psbt: &BitGoPsbt, wallet_keys: &fixtures::XprvTriple, input_index: usize, - input_fixture: &fixtures::PsbtInputFixture, - halfsigned_fixture: &fixtures::PsbtInputFixture, ) -> Result<(), String> { let user_key = wallet_keys.user_key(); + + // Clone the unsigned PSBT and sign with user key + let mut signed_psbt = unsigned_bitgo_psbt.clone(); + let secp = secp256k1::Secp256k1::new(); + + // Sign with user key using the new sign method + signed_psbt + .sign(user_key, &secp) + .map_err(|(_num_keys, errors)| format!("Failed to sign PSBT: {:?}", errors))?; + + // Extract partial signatures from the signed input + let signed_input = match &signed_psbt { + BitGoPsbt::BitcoinLike(psbt, _) => &psbt.inputs[input_index], + BitGoPsbt::Zcash(_, _) => { + return Err("Zcash signing not yet implemented".to_string()); + } + }; + let actual_partial_sigs = signed_input.partial_sigs.clone(); + + // Get expected partial signatures from halfsigned fixture + let expected_partial_sigs = halfsigned_bitgo_psbt.clone().into_psbt().inputs[input_index] + .partial_sigs + .clone(); + + assert_eq_partial_signatures(&actual_partial_sigs, &expected_partial_sigs)?; + Ok(()) } @@ -527,18 +614,23 @@ mod tests { let psbt_input_stages = psbt_input_stages.unwrap(); - assert_half_sign( - network, - tx_format, - &psbt_stages - .unsigned - .to_bitgo_psbt(network) - .expect("Failed to convert to BitGo PSBT"), - &psbt_input_stages.wallet_keys, - psbt_input_stages.input_index, - &psbt_input_stages.input_fixture_unsigned, - &psbt_input_stages.input_fixture_halfsigned, - )?; + if script_type != fixtures::ScriptType::TaprootKeypath + && script_type != fixtures::ScriptType::P2trMusig2 + && script_type != fixtures::ScriptType::P2tr + { + assert_half_sign( + &psbt_stages + .unsigned + .to_bitgo_psbt(network) + .expect("Failed to convert to BitGo PSBT"), + &psbt_stages + .halfsigned + .to_bitgo_psbt(network) + .expect("Failed to convert to BitGo PSBT"), + &psbt_input_stages.wallet_keys, + psbt_input_stages.input_index, + )?; + } assert_full_signed_matches_wallet_scripts( network, 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 351c0c8..34fcd3d 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 @@ -397,6 +397,22 @@ pub enum PsbtInputFixture { P2shP2pk(P2shP2pkInput), } +impl PsbtInputFixture { + /// Get partial signatures from PSBT input fixtures that support them. + /// Returns None for input types that don't use ECDSA partial signatures (e.g., Taproot). + pub fn partial_sigs(&self) -> Option<&Vec> { + match self { + PsbtInputFixture::P2sh(fixture) => Some(&fixture.partial_sig), + PsbtInputFixture::P2shP2wsh(fixture) => Some(&fixture.partial_sig), + PsbtInputFixture::P2wsh(fixture) => Some(&fixture.partial_sig), + PsbtInputFixture::P2shP2pk(fixture) => Some(&fixture.partial_sig), + PsbtInputFixture::P2trLegacy(_) + | PsbtInputFixture::P2trMusig2ScriptPath(_) + | PsbtInputFixture::P2trMusig2KeyPath(_) => None, + } + } +} + // Finalized input type structs (depend on helper types above) #[derive(Debug, Clone, Deserialize, Serialize)] From 7d976acd08d24e8b55f5c41ce1df28e91c30f4c9 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 5 Nov 2025 11:43:35 +0100 Subject: [PATCH 6/8] feat(wasm-utxo): improve script names, suite names Update ScriptType enum variants to have more consistent and descriptive naming that better reflects their purpose: - P2tr -> P2trLegacyScriptPath - P2trMusig2 -> P2trMusig2ScriptPath - TaprootKeypath -> P2trMusig2TaprootKeypath Rename test functions to use more concise and consistent naming pattern. Functions now use "suite" suffix instead of verbose "generation_from_fixture" descriptions. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/src/bitgo_psbt/mod.rs | 45 +++++++++---------- .../src/bitgo_psbt/p2tr_musig2_input.rs | 4 +- .../test_utils/fixtures.rs | 39 ++++++++++------ 3 files changed, 48 insertions(+), 40 deletions(-) diff --git a/packages/wasm-utxo/src/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/bitgo_psbt/mod.rs index 99ec8d9..c94a072 100644 --- a/packages/wasm-utxo/src/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/bitgo_psbt/mod.rs @@ -614,9 +614,9 @@ mod tests { let psbt_input_stages = psbt_input_stages.unwrap(); - if script_type != fixtures::ScriptType::TaprootKeypath - && script_type != fixtures::ScriptType::P2trMusig2 - && script_type != fixtures::ScriptType::P2tr + if script_type != fixtures::ScriptType::P2trMusig2TaprootKeypath + && script_type != fixtures::ScriptType::P2trMusig2ScriptPath + && script_type != fixtures::ScriptType::P2trLegacyScriptPath { assert_half_sign( &psbt_stages @@ -651,7 +651,7 @@ mod tests { Ok(()) } - crate::test_psbt_fixtures!(test_p2sh_script_generation_from_fixture, network, format, { + crate::test_psbt_fixtures!(test_p2sh_suite, network, format, { test_wallet_script_type(fixtures::ScriptType::P2sh, network, format).unwrap(); }, ignore: [ // TODO: sighash support @@ -661,7 +661,7 @@ mod tests { ]); crate::test_psbt_fixtures!( - test_p2sh_p2wsh_script_generation_from_fixture, + test_p2sh_p2wsh_suite, network, format, { @@ -672,7 +672,7 @@ mod tests { ); crate::test_psbt_fixtures!( - test_p2wsh_script_generation_from_fixture, + test_p2wsh_suite, network, format, { @@ -682,27 +682,24 @@ mod tests { ignore: [BitcoinGold] ); - crate::test_psbt_fixtures!(test_p2tr_script_generation_from_fixture, network, format, { - test_wallet_script_type(fixtures::ScriptType::P2tr, network, format).unwrap(); + crate::test_psbt_fixtures!(test_p2tr_legacy_script_path_suite, network, format, { + test_wallet_script_type(fixtures::ScriptType::P2trLegacyScriptPath, network, format) + .unwrap(); }); - crate::test_psbt_fixtures!( - test_p2tr_musig2_script_path_generation_from_fixture, - network, - format, - { - test_wallet_script_type(fixtures::ScriptType::P2trMusig2, network, format).unwrap(); - } - ); + crate::test_psbt_fixtures!(test_p2tr_musig2_script_path_suite, network, format, { + test_wallet_script_type(fixtures::ScriptType::P2trMusig2ScriptPath, network, format) + .unwrap(); + }); - crate::test_psbt_fixtures!( - test_p2tr_musig2_key_path_spend_script_generation_from_fixture, - network, - format, - { - test_wallet_script_type(fixtures::ScriptType::TaprootKeypath, network, format).unwrap(); - } - ); + crate::test_psbt_fixtures!(test_p2tr_musig2_key_path_suite, network, format, { + test_wallet_script_type( + fixtures::ScriptType::P2trMusig2TaprootKeypath, + network, + format, + ) + .unwrap(); + }); crate::test_psbt_fixtures!(test_extract_transaction, network, format, { let fixture = fixtures::load_psbt_fixture_with_format( diff --git a/packages/wasm-utxo/src/bitgo_psbt/p2tr_musig2_input.rs b/packages/wasm-utxo/src/bitgo_psbt/p2tr_musig2_input.rs index e6931a0..83825a4 100644 --- a/packages/wasm-utxo/src/bitgo_psbt/p2tr_musig2_input.rs +++ b/packages/wasm-utxo/src/bitgo_psbt/p2tr_musig2_input.rs @@ -689,12 +689,12 @@ mod tests { .expect("Failed to load fixture"); let (input_index, input_fixture) = fixture - .find_input_with_script_type(ScriptType::TaprootKeypath) + .find_input_with_script_type(ScriptType::P2trMusig2TaprootKeypath) .expect("Failed to find taprootKeyPathSpend input"); let finalized_input_fixture = if signature_state == SignatureState::Fullsigned { let (finalized_input_index, finalized_input_fixture) = fixture - .find_finalized_input_with_script_type(ScriptType::TaprootKeypath) + .find_finalized_input_with_script_type(ScriptType::P2trMusig2TaprootKeypath) .expect("Failed to find taprootKeyPathSpend finalized input"); assert_eq!(input_index, finalized_input_index); Some(finalized_input_fixture) 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 34fcd3d..e87b2c0 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 @@ -1143,9 +1143,12 @@ pub enum ScriptType { P2sh, P2shP2wsh, P2wsh, - P2tr, - P2trMusig2, - TaprootKeypath, + // Chain 30 and 31 - we only support script path spending for these + P2trLegacyScriptPath, + // Chain 40 and 41 - script path spend + P2trMusig2ScriptPath, + // Chain 40 and 41 - keypath spend + P2trMusig2TaprootKeypath, } impl ScriptType { @@ -1155,9 +1158,9 @@ impl ScriptType { ScriptType::P2sh => "p2sh", ScriptType::P2shP2wsh => "p2shP2wsh", ScriptType::P2wsh => "p2wsh", - ScriptType::P2tr => "p2tr", - ScriptType::P2trMusig2 => "p2trMusig2", - ScriptType::TaprootKeypath => "taprootKeypath", + ScriptType::P2trLegacyScriptPath => "p2tr", + ScriptType::P2trMusig2ScriptPath => "p2trMusig2", + ScriptType::P2trMusig2TaprootKeypath => "taprootKeypath", } } @@ -1168,13 +1171,16 @@ impl ScriptType { (ScriptType::P2sh, PsbtInputFixture::P2sh(_)) | (ScriptType::P2shP2wsh, PsbtInputFixture::P2shP2wsh(_)) | (ScriptType::P2wsh, PsbtInputFixture::P2wsh(_)) - | (ScriptType::P2tr, PsbtInputFixture::P2trLegacy(_)) | ( - ScriptType::P2trMusig2, + ScriptType::P2trLegacyScriptPath, + PsbtInputFixture::P2trLegacy(_) + ) + | ( + ScriptType::P2trMusig2ScriptPath, PsbtInputFixture::P2trMusig2ScriptPath(_) ) | ( - ScriptType::TaprootKeypath, + ScriptType::P2trMusig2TaprootKeypath, PsbtInputFixture::P2trMusig2KeyPath(_) ) ) @@ -1187,13 +1193,16 @@ impl ScriptType { (ScriptType::P2sh, PsbtFinalInputFixture::P2sh(_)) | (ScriptType::P2shP2wsh, PsbtFinalInputFixture::P2shP2wsh(_)) | (ScriptType::P2wsh, PsbtFinalInputFixture::P2wsh(_)) - | (ScriptType::P2tr, PsbtFinalInputFixture::P2trLegacy(_)) | ( - ScriptType::P2trMusig2, + ScriptType::P2trLegacyScriptPath, + PsbtFinalInputFixture::P2trLegacy(_) + ) + | ( + ScriptType::P2trMusig2ScriptPath, PsbtFinalInputFixture::P2trMusig2ScriptPath(_) ) | ( - ScriptType::TaprootKeypath, + ScriptType::P2trMusig2TaprootKeypath, PsbtFinalInputFixture::P2trMusig2KeyPath(_) ) ) @@ -1206,7 +1215,9 @@ impl ScriptType { pub fn is_taproot(&self) -> bool { matches!( self, - ScriptType::P2tr | ScriptType::P2trMusig2 | ScriptType::TaprootKeypath + ScriptType::P2trLegacyScriptPath + | ScriptType::P2trMusig2ScriptPath + | ScriptType::P2trMusig2TaprootKeypath ) } @@ -1520,7 +1531,7 @@ mod tests { // Test finding taproot key path finalized input let (index, input) = fixture - .find_finalized_input_with_script_type(ScriptType::TaprootKeypath) + .find_finalized_input_with_script_type(ScriptType::P2trMusig2TaprootKeypath) .expect("Failed to find taproot key path finalized input"); assert_eq!(index, 5); assert!(matches!(input, PsbtFinalInputFixture::P2trMusig2KeyPath(_))); From 39936c1f69ea11e2e59b060913b24cdfd0745dad Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 5 Nov 2025 12:04:23 +0100 Subject: [PATCH 7/8] feat(wasm-utxo): remove unused variable in P2SH-P2PK test Remove unused expected_pubkey variable in the singlesig wallet script tests since it's not being used in the test assertions. Issue: BTC-2652 Co-authored-by: llm-git --- .../src/fixed_script_wallet/wallet_scripts/singlesig.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/singlesig.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/singlesig.rs index 5510e57..2751932 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/singlesig.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/singlesig.rs @@ -56,7 +56,7 @@ mod tests { // Get the expected values from the fixture let expected_redeem_script = &p2shp2pk_input.redeem_script; - let expected_pubkey = p2shp2pk_input + p2shp2pk_input .partial_sig .first() .map(|sig| &sig.pubkey) From 587f006e5f0ebb6879a41b9a22c8fd4af99e5004 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 5 Nov 2025 17:08:47 +0100 Subject: [PATCH 8/8] feat(wasm-utxo): add support for signing P2TR script path inputs Support adding user signatures to P2TR script path inputs using the tap_script_sigs field for both legacy script path and Musig2 script path formats. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/src/bitgo_psbt/mod.rs | 37 +++++++++++++++++------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/wasm-utxo/src/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/bitgo_psbt/mod.rs index c94a072..850519c 100644 --- a/packages/wasm-utxo/src/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/bitgo_psbt/mod.rs @@ -451,6 +451,7 @@ mod tests { // ensure we can put the first signature (user signature) on an unsigned PSBT fn assert_half_sign( + script_type: fixtures::ScriptType, unsigned_bitgo_psbt: &BitGoPsbt, halfsigned_bitgo_psbt: &BitGoPsbt, wallet_keys: &fixtures::XprvTriple, @@ -474,14 +475,30 @@ mod tests { return Err("Zcash signing not yet implemented".to_string()); } }; - let actual_partial_sigs = signed_input.partial_sigs.clone(); - - // Get expected partial signatures from halfsigned fixture - let expected_partial_sigs = halfsigned_bitgo_psbt.clone().into_psbt().inputs[input_index] - .partial_sigs - .clone(); - assert_eq_partial_signatures(&actual_partial_sigs, &expected_partial_sigs)?; + match script_type { + fixtures::ScriptType::P2trLegacyScriptPath + | fixtures::ScriptType::P2trMusig2ScriptPath => { + assert_eq!(signed_input.tap_script_sigs.len(), 1); + // Get expected tap script sig from halfsigned fixture + let expected_tap_script_sig = halfsigned_bitgo_psbt.clone().into_psbt().inputs + [input_index] + .tap_script_sigs + .clone(); + assert_eq!(signed_input.tap_script_sigs, expected_tap_script_sig); + } + _ => { + let actual_partial_sigs = signed_input.partial_sigs.clone(); + // Get expected partial signatures from halfsigned fixture + let expected_partial_sigs = halfsigned_bitgo_psbt.clone().into_psbt().inputs + [input_index] + .partial_sigs + .clone(); + + assert_eq!(actual_partial_sigs.len(), 1); + assert_eq_partial_signatures(&actual_partial_sigs, &expected_partial_sigs)?; + } + } Ok(()) } @@ -614,11 +631,9 @@ mod tests { let psbt_input_stages = psbt_input_stages.unwrap(); - if script_type != fixtures::ScriptType::P2trMusig2TaprootKeypath - && script_type != fixtures::ScriptType::P2trMusig2ScriptPath - && script_type != fixtures::ScriptType::P2trLegacyScriptPath - { + if script_type != fixtures::ScriptType::P2trMusig2TaprootKeypath { assert_half_sign( + script_type, &psbt_stages .unsigned .to_bitgo_psbt(network)