From 749031e3bfc0fdf7df9e84cdcaa0220d6361a476 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 11 Nov 2025 14:42:36 +0100 Subject: [PATCH 1/2] feat(wasm-utxo): add method to get unsigned txid from psbt Added `unsignedTxid` method to BitGoPsbt class, which returns the transaction ID of the unsigned transaction contained in the PSBT. Added tests to verify the method works correctly. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/js/fixedScriptWallet.ts | 8 ++++++++ .../wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs | 7 ++++++- packages/wasm-utxo/src/wasm/fixed_script_wallet.rs | 5 +++++ .../test/fixedScript/parseTransactionWithWalletKeys.ts | 9 +++++++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/wasm-utxo/js/fixedScriptWallet.ts b/packages/wasm-utxo/js/fixedScriptWallet.ts index cdcbb32..c565226 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet.ts @@ -94,6 +94,14 @@ export class BitGoPsbt { return new BitGoPsbt(wasm); } + /** + * Get the unsigned transaction ID + * @returns The unsigned transaction ID + */ + unsignedTxid(): string { + return this.wasm.unsigned_txid(); + } + /** * Parse transaction with wallet keys to identify wallet inputs/outputs * @param walletKeys - The wallet keys to use for identification diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index 5a9928c..bc83eeb 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -13,7 +13,7 @@ mod sighash; mod zcash_psbt; use crate::Network; -use miniscript::bitcoin::{psbt::Psbt, secp256k1, CompressedPublicKey}; +use miniscript::bitcoin::{psbt::Psbt, secp256k1, CompressedPublicKey, Txid}; pub use propkv::{BitGoKeyValue, ProprietaryKeySubtype, BITGO}; pub use sighash::validate_sighash_type; use zcash_psbt::ZcashPsbt; @@ -319,6 +319,11 @@ impl BitGoPsbt { } } + /// Get the unsigned transaction ID + pub fn unsigned_txid(&self) -> Txid { + self.psbt().unsigned_tx.compute_txid() + } + /// Helper function to create a MuSig2 context for an input /// /// This validates that: diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs index a9528ee..9c8dd0a 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs @@ -147,6 +147,11 @@ impl BitGoPsbt { Ok(BitGoPsbt { psbt }) } + /// Get the unsigned transaction ID + pub fn unsigned_txid(&self) -> String { + self.psbt.unsigned_txid().to_string() + } + /// Parse transaction with wallet keys to identify wallet inputs/outputs pub fn parse_transaction_with_wallet_keys( &self, diff --git a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts index 443fd27..3469944 100644 --- a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts +++ b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts @@ -88,6 +88,15 @@ describe("parseTransactionWithWalletKeys", function () { rootWalletKeys = loadWalletKeysFromFixture(networkName); }); + it("should have matching unsigned transaction ID", function () { + const unsignedTxid = bitgoPsbt.unsignedTxid(); + const expectedUnsignedTxid = utxolib.bitgo + .createPsbtFromBuffer(psbtBytes, network) + .getUnsignedTx() + .getId(); + assert.strictEqual(unsignedTxid, expectedUnsignedTxid); + }); + it("should parse transaction and identify internal/external outputs", function () { const parsed = bitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { outputScripts: [replayProtectionScript], From 12180b028a02d8a492958d8cc637d5fb7d7bce46 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 12 Nov 2025 13:33:42 +0100 Subject: [PATCH 2/2] feat(wasm-utxo): add signature verification methods to BitGoPsbt Add two methods for verifying transaction signatures: - verifySignature - Verify ECDSA, Schnorr, or MuSig2 signatures based on xpub derivation path found in the PSBT - verifyReplayProtectionSignature - Verify signatures in replay protection inputs (P2SH-P2PK) These methods enable transaction validation without requiring full re-signing, which is particularly useful when checking the signing state of PSBTs across different types of inputs. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/js/fixedScriptWallet.ts | 40 ++ .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 428 ++++++++++++++++-- .../bitgo_psbt/psbt_wallet_input.rs | 289 +++++++++++- .../test_utils/fixtures.rs | 6 +- .../wasm-utxo/src/wasm/fixed_script_wallet.rs | 71 +++ .../wasm-utxo/test/fixedScript/fixtureUtil.ts | 133 ++++++ .../parseTransactionWithWalletKeys.ts | 52 +-- .../test/fixedScript/verifySignature.ts | 237 ++++++++++ 8 files changed, 1157 insertions(+), 99 deletions(-) create mode 100644 packages/wasm-utxo/test/fixedScript/fixtureUtil.ts create mode 100644 packages/wasm-utxo/test/fixedScript/verifySignature.ts diff --git a/packages/wasm-utxo/js/fixedScriptWallet.ts b/packages/wasm-utxo/js/fixedScriptWallet.ts index c565226..5fabe50 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet.ts @@ -129,4 +129,44 @@ export class BitGoPsbt { parseOutputsWithWalletKeys(walletKeys: WalletKeys): ParsedOutput[] { return this.wasm.parse_outputs_with_wallet_keys(walletKeys); } + + /** + * Verify if a valid signature exists for a given extended public key at the specified input index. + * + * This method derives the public key from the xpub using the derivation path found in the + * PSBT input, then verifies the signature. It supports: + * - ECDSA signatures (for legacy/SegWit inputs) + * - Schnorr signatures (for Taproot script path inputs) + * - MuSig2 partial signatures (for Taproot keypath MuSig2 inputs) + * + * @param inputIndex - The index of the input to check (0-based) + * @param xpub - The extended public key as a base58-encoded string + * @returns true if a valid signature exists, false if no signature exists + * @throws Error if input index is out of bounds, xpub is invalid, or verification fails + */ + verifySignature(inputIndex: number, xpub: string): boolean { + return this.wasm.verify_signature(inputIndex, xpub); + } + + /** + * Verify if a replay protection input has a valid signature. + * + * This method checks if a given input is a replay protection input (like P2shP2pk) and verifies + * the signature. Replay protection inputs don't use standard derivation paths, so this method + * verifies signatures without deriving from xpub. + * + * For P2PK replay protection inputs, this: + * - Extracts the signature from final_script_sig + * - Extracts the public key from redeem_script + * - Computes the legacy P2SH sighash + * - Verifies the ECDSA signature cryptographically + * + * @param inputIndex - The index of the input to check (0-based) + * @param replayProtection - Scripts that identify replay protection inputs (same format as parseTransactionWithWalletKeys) + * @returns true if the input is a replay protection input and has a valid signature, false if no valid signature + * @throws Error if the input is not a replay protection input, index is out of bounds, or scripts are invalid + */ + verifyReplayProtectionSignature(inputIndex: number, replayProtection: ReplayProtection): boolean { + return this.wasm.verify_replay_protection_signature(inputIndex, replayProtection); + } } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index bc83eeb..ec6f222 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -588,6 +588,227 @@ impl BitGoPsbt { ) } + /// Helper function to extract public key from a P2PK redeem script + /// + /// # Arguments + /// - `redeem_script`: The redeem script to parse (expected format: OP_CHECKSIG) + /// + /// # Returns + /// - `Ok(PublicKey)` if parsing succeeds + /// - `Err(String)` if the script format is invalid + fn extract_pubkey_from_p2pk_redeem_script( + redeem_script: &miniscript::bitcoin::ScriptBuf, + ) -> Result { + use miniscript::bitcoin::{opcodes::all::OP_CHECKSIG, script::Instruction, PublicKey}; + + // Extract public key from redeem script + // For P2SH(P2PK), redeem_script is: OP_CHECKSIG + let mut redeem_instructions = redeem_script.instructions(); + let public_key_bytes = match redeem_instructions.next() { + Some(Ok(Instruction::PushBytes(bytes))) => bytes.as_bytes(), + _ => return Err("Invalid redeem script format: missing public key".to_string()), + }; + + // Verify the script ends with OP_CHECKSIG + match redeem_instructions.next() { + Some(Ok(Instruction::Op(op))) if op == OP_CHECKSIG => {} + _ => return Err("Redeem script does not end with OP_CHECKSIG".to_string()), + } + + PublicKey::from_slice(public_key_bytes).map_err(|e| format!("Invalid public key: {}", e)) + } + + /// Helper function to parse an ECDSA signature from final_script_sig + /// + /// # Returns + /// - `Ok(bitcoin::ecdsa::Signature)` if parsing succeeds + /// - `Err(String)` if parsing fails + fn parse_signature_from_script_sig( + final_script_sig: &miniscript::bitcoin::ScriptBuf, + ) -> Result { + use miniscript::bitcoin::{ecdsa::Signature, script::Instruction}; + + // Extract signature from final_script_sig + // For P2SH(P2PK), the scriptSig is: + let mut instructions = final_script_sig.instructions(); + let signature_bytes = match instructions.next() { + Some(Ok(Instruction::PushBytes(bytes))) => bytes.as_bytes(), + _ => return Err("Invalid final_script_sig format".to_string()), + }; + + if signature_bytes.is_empty() { + return Err("Empty signature in final_script_sig".to_string()); + } + + Signature::from_slice(signature_bytes) + .map_err(|e| format!("Invalid signature in final_script_sig: {}", e)) + } + + /// Verify if a replay protection input has a valid signature + /// + /// This method checks if a given input is a replay protection input and verifies the signature. + /// Replay protection inputs (like P2shP2pk) don't use standard derivation paths, + /// so this method verifies signatures without deriving from xpub. + /// + /// For P2PK replay protection inputs: + /// - Extracts public key from `redeem_script` + /// - Checks for signature in `partial_sigs` (non-finalized) or `final_script_sig` (finalized) + /// - Computes the legacy P2SH sighash using the redeem script + /// - Verifies the ECDSA signature + /// + /// # Arguments + /// - `secp`: Secp256k1 context for signature verification + /// - `input_index`: The index of the input to check + /// - `replay_protection`: Replay protection configuration + /// + /// # Returns + /// - `Ok(true)` if the input is a replay protection input and has a valid signature + /// - `Ok(false)` if the input is a replay protection input but has no valid signature + /// - `Err(String)` if the input is not a replay protection input, index is out of bounds, or verification fails + pub fn verify_replay_protection_signature( + &self, + secp: &secp256k1::Secp256k1, + input_index: usize, + replay_protection: &psbt_wallet_input::ReplayProtection, + ) -> Result { + use miniscript::bitcoin::{hashes::Hash, sighash::SighashCache}; + + let psbt = self.psbt(); + + // Check input index bounds + if input_index >= psbt.inputs.len() { + return Err(format!("Input index {} out of bounds", input_index)); + } + + let input = &psbt.inputs[input_index]; + let prevout = psbt.unsigned_tx.input[input_index].previous_output; + + // Get output script from input + let (output_script, _value) = + psbt_wallet_input::get_output_script_and_value(input, prevout) + .map_err(|e| format!("Failed to get output script: {}", e))?; + + // Verify this is a replay protection input + if !replay_protection.is_replay_protection_input(output_script) { + return Err(format!( + "Input {} is not a replay protection input", + input_index + )); + } + + // Get redeem script and extract public key + let redeem_script = input + .redeem_script + .as_ref() + .ok_or_else(|| "Missing redeem_script for replay protection input".to_string())?; + let public_key = Self::extract_pubkey_from_p2pk_redeem_script(redeem_script)?; + + // Get signature from partial_sigs (non-finalized) or final_script_sig (finalized) + // The bitcoin crate's ecdsa::Signature type contains both .signature and .sighash_type + let ecdsa_sig = if let Some(&partial_sig) = input.partial_sigs.get(&public_key) { + partial_sig + } else if let Some(final_script_sig) = &input.final_script_sig { + Self::parse_signature_from_script_sig(final_script_sig)? + } else { + // No signature present (neither partial nor final) + return Ok(false); + }; + + // Compute legacy P2SH sighash + let cache = SighashCache::new(&psbt.unsigned_tx); + let sighash = cache + .legacy_signature_hash(input_index, redeem_script, ecdsa_sig.sighash_type.to_u32()) + .map_err(|e| format!("Failed to compute sighash: {}", e))?; + + // Verify the signature using the bitcoin crate's built-in verification + let message = secp256k1::Message::from_digest(sighash.to_byte_array()); + match secp.verify_ecdsa(&message, &ecdsa_sig.signature, &public_key.inner) { + Ok(()) => Ok(true), + Err(_) => Ok(false), + } + } + + /// Verify if a valid signature exists for a given extended public key at the specified input index + /// + /// This method derives the public key from the xpub using the derivation path found in the + /// PSBT input, then verifies the signature. It supports: + /// - ECDSA signatures (for legacy/SegWit inputs) + /// - Schnorr signatures (for Taproot script path inputs) + /// - MuSig2 partial signatures (for Taproot keypath MuSig2 inputs) + /// + /// # Arguments + /// - `secp`: Secp256k1 context for signature verification and key derivation + /// - `input_index`: The index of the input to check + /// - `xpub`: The extended public key to derive from and verify the signature for + /// + /// # Returns + /// - `Ok(true)` if a valid signature exists for the derived public key + /// - `Ok(false)` if no signature exists for the derived public key + /// - `Err(String)` if the input index is out of bounds, derivation fails, or verification fails + pub fn verify_signature( + &self, + secp: &secp256k1::Secp256k1, + input_index: usize, + xpub: &miniscript::bitcoin::bip32::Xpub, + ) -> Result { + let psbt = self.psbt(); + + // Check input index bounds + if input_index >= psbt.inputs.len() { + return Err(format!("Input index {} out of bounds", input_index)); + } + + let input = &psbt.inputs[input_index]; + + // Handle MuSig2 inputs early - they use proprietary fields for partial signatures + if p2tr_musig2_input::Musig2Input::is_musig2_input(input) { + // Parse MuSig2 data from input + let musig2_input = p2tr_musig2_input::Musig2Input::from_input(input) + .map_err(|e| format!("Failed to parse MuSig2 input: {}", e))?; + + // Derive the public key for this input using tap_key_origins + // If this xpub doesn't match any tap_key_origins, return false (e.g., backup key) + let derived_xpub = + match p2tr_musig2_input::derive_xpub_for_input_tap(xpub, &input.tap_key_origins) { + Ok(xpub) => xpub, + Err(_) => return Ok(false), // This xpub doesn't match + }; + let derived_pubkey = derived_xpub.to_pub(); + + // Check if this public key has a partial signature in the MuSig2 proprietary fields + let has_partial_sig = musig2_input + .partial_sigs + .iter() + .any(|sig| sig.participant_pub_key == derived_pubkey); + + return Ok(has_partial_sig); + } + + // For non-MuSig2 inputs, use standard derivation + // Derive the public key from xpub using derivation path in PSBT + let derived_pubkey = match psbt_wallet_input::derive_pubkey_from_input(secp, xpub, input)? { + Some(pubkey) => pubkey, + None => return Ok(false), // No matching derivation path for this xpub + }; + + // Convert to CompressedPublicKey for verification + let public_key = CompressedPublicKey::from_slice(&derived_pubkey.serialize()) + .map_err(|e| format!("Failed to convert derived key: {}", e))?; + + // Check for Taproot script path signatures first + if !input.tap_script_sigs.is_empty() { + return psbt_wallet_input::verify_taproot_script_signature( + secp, + psbt, + input_index, + public_key, + ); + } + + // Fall back to ECDSA signature verification for legacy/SegWit inputs + psbt_wallet_input::verify_ecdsa_signature(secp, psbt, input_index, public_key) + } + /// Parse outputs with wallet keys to identify which outputs belong to a particular wallet. /// /// This is useful in cases where we want to identify outputs that belong to a different @@ -661,6 +882,7 @@ impl BitGoPsbt { mod tests { use super::*; use crate::fixed_script_wallet::Chain; + use crate::fixed_script_wallet::RootWalletKeys; use crate::fixed_script_wallet::WalletScripts; use crate::test_utils::fixtures; use crate::test_utils::fixtures::assert_hex_eq; @@ -877,6 +1099,10 @@ mod tests { }; match script_type { + fixtures::ScriptType::P2shP2pk => { + // In production, these will be signed by BitGo + assert_eq!(signed_input.partial_sigs.len(), 0); + } fixtures::ScriptType::P2trLegacyScriptPath | fixtures::ScriptType::P2trMusig2ScriptPath => { assert_eq!(signed_input.tap_script_sigs.len(), 1); @@ -1009,6 +1235,76 @@ mod tests { Ok(()) } + fn assert_replay_protection_signature( + bitgo_psbt: &BitGoPsbt, + _wallet_keys: &fixtures::XprvTriple, + input_index: usize, + ) -> Result<(), String> { + let secp = secp256k1::Secp256k1::new(); + let psbt = bitgo_psbt.psbt(); + + if input_index >= psbt.inputs.len() { + return Err(format!("Input index {} out of bounds", input_index)); + } + + let input = &psbt.inputs[input_index]; + let prevout = psbt.unsigned_tx.input[input_index].previous_output; + + // Get the output script from the input + let (output_script, _value) = + psbt_wallet_input::get_output_script_and_value(input, prevout) + .map_err(|e| format!("Failed to get output script: {}", e))?; + + // Create replay protection with this output script + let replay_protection = + psbt_wallet_input::ReplayProtection::new(vec![output_script.clone()]); + + // Verify the signature exists and is valid + let has_valid_signature = bitgo_psbt.verify_replay_protection_signature( + &secp, + input_index, + &replay_protection, + )?; + + if !has_valid_signature { + return Err(format!( + "Replay protection input {} does not have a valid signature", + input_index + )); + } + + Ok(()) + } + + fn assert_signature_count( + bitgo_psbt: &BitGoPsbt, + wallet_keys: &RootWalletKeys, + input_index: usize, + expected_count: usize, + stage_name: &str, + ) -> Result<(), String> { + // Use verify_signature to count valid signatures for all input types + // This now handles MuSig2, ECDSA, and Schnorr signatures uniformly + let secp = secp256k1::Secp256k1::new(); + let mut signature_count = 0; + for xpub in &wallet_keys.xpubs { + match bitgo_psbt.verify_signature(&secp, input_index, xpub) { + Ok(true) => signature_count += 1, + Ok(false) => {} // No signature for this key + Err(e) => return Err(e), // Propagate other errors + } + } + + if signature_count != expected_count { + return Err(format!( + "{} input {} should have {} signature(s), found {}", + stage_name, input_index, expected_count, signature_count + )); + } + + Ok(()) + } + fn test_wallet_script_type( script_type: fixtures::ScriptType, network: Network, @@ -1031,31 +1327,82 @@ mod tests { let psbt_input_stages = psbt_input_stages.unwrap(); + let halfsigned_bitgo_psbt = psbt_stages + .halfsigned + .to_bitgo_psbt(network) + .expect("Failed to convert to BitGo PSBT"); + + let fullsigned_bitgo_psbt = psbt_stages + .fullsigned + .to_bitgo_psbt(network) + .expect("Failed to convert to BitGo PSBT"); + assert_half_sign( script_type, &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"), + &halfsigned_bitgo_psbt, &psbt_input_stages.wallet_keys, psbt_input_stages.input_index, )?; - assert_full_signed_matches_wallet_scripts( - network, - tx_format, - &psbt_stages.fullsigned, - &psbt_input_stages.wallet_keys, + let wallet_keys = psbt_input_stages.wallet_keys.to_root_wallet_keys(); + + // Verify halfsigned PSBT has exactly 1 signature + assert_signature_count( + &halfsigned_bitgo_psbt, + &wallet_keys, + psbt_input_stages.input_index, + if matches!(script_type, fixtures::ScriptType::P2shP2pk) { + // p2shP2pk inputs are signed at the halfsigned stage with replay protection + 0 + } else { + 1 + }, + "Halfsigned", + )?; + + if matches!(script_type, fixtures::ScriptType::P2shP2pk) { + // Replay protection inputs are signed at the halfsigned stage + assert_replay_protection_signature( + &halfsigned_bitgo_psbt, + &psbt_input_stages.wallet_keys, + psbt_input_stages.input_index, + )?; + // They remain signed at the fullsigned stage + assert_replay_protection_signature( + &fullsigned_bitgo_psbt, + &psbt_input_stages.wallet_keys, + psbt_input_stages.input_index, + )?; + } else { + assert_full_signed_matches_wallet_scripts( + network, + tx_format, + &psbt_stages.fullsigned, + &psbt_input_stages.wallet_keys, + psbt_input_stages.input_index, + &psbt_input_stages.input_fixture_fullsigned, + )?; + } + + // Verify fullsigned PSBT has exactly 2 signatures + assert_signature_count( + &fullsigned_bitgo_psbt, + &wallet_keys, psbt_input_stages.input_index, - &psbt_input_stages.input_fixture_fullsigned, + if matches!(script_type, fixtures::ScriptType::P2shP2pk) { + 0 + } else { + 2 + }, + "Fullsigned", )?; assert_finalize_input( - psbt_stages.fullsigned.to_bitgo_psbt(network).unwrap(), + fullsigned_bitgo_psbt, psbt_input_stages.input_index, network, tx_format, @@ -1064,6 +1411,15 @@ mod tests { Ok(()) } + crate::test_psbt_fixtures!(test_p2sh_p2pk_suite, network, format, { + test_wallet_script_type(fixtures::ScriptType::P2shP2pk, network, format).unwrap(); + }, ignore: [ + // TODO: sighash support + BitcoinCash, Ecash, BitcoinGold, + // TODO: zec support + Zcash, + ]); + crate::test_psbt_fixtures!(test_p2sh_suite, network, format, { test_wallet_script_type(fixtures::ScriptType::P2sh, network, format).unwrap(); }, ignore: [ @@ -1095,24 +1451,42 @@ mod tests { ignore: [BitcoinGold] ); - 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_legacy_script_path_suite, + network, + format, + { + test_wallet_script_type(fixtures::ScriptType::P2trLegacyScriptPath, network, format) + .unwrap(); + }, + ignore: [BitcoinCash, Ecash, BitcoinGold, Dash, Dogecoin, Litecoin, Zcash] + ); - 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_script_path_suite, + network, + format, + { + test_wallet_script_type(fixtures::ScriptType::P2trMusig2ScriptPath, network, format) + .unwrap(); + }, + ignore: [BitcoinCash, Ecash, BitcoinGold, Dash, Dogecoin, Litecoin, Zcash] + ); - 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_p2tr_musig2_key_path_suite, + network, + format, + { + test_wallet_script_type( + fixtures::ScriptType::P2trMusig2TaprootKeypath, + network, + format, + ) + .unwrap(); + }, + ignore: [BitcoinCash, Ecash, BitcoinGold, Dash, Dogecoin, Litecoin, Zcash] + ); crate::test_psbt_fixtures!(test_extract_transaction, network, format, { let fixture = fixtures::load_psbt_fixture_with_format( diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs index d97b4a0..ebfc340 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs @@ -24,7 +24,29 @@ impl ReplayProtection { } } -type Bip32DerivationMap = std::collections::BTreeMap; +pub type Bip32DerivationMap = std::collections::BTreeMap; + +/// Check if a fingerprint matches any xpub in the wallet +fn has_fingerprint( + wallet_keys: &RootWalletKeys, + fingerprint: miniscript::bitcoin::bip32::Fingerprint, +) -> bool { + wallet_keys + .xpubs + .iter() + .any(|xpub| xpub.fingerprint() == fingerprint) +} + +/// Find an xpub in the wallet by fingerprint +fn find_xpub_by_fingerprint( + wallet_keys: &RootWalletKeys, + fingerprint: miniscript::bitcoin::bip32::Fingerprint, +) -> Option<&miniscript::bitcoin::bip32::Xpub> { + wallet_keys + .xpubs + .iter() + .find(|xpub| xpub.fingerprint() == fingerprint) +} /// Make sure that deriving from the wallet xpubs matches keys in the derivation map /// Check if BIP32 derivation info belongs to the wallet keys (non-failing) @@ -33,12 +55,216 @@ pub fn is_bip32_derivation_for_wallet( wallet_keys: &RootWalletKeys, derivation_map: &Bip32DerivationMap, ) -> bool { - derivation_map.iter().all(|(_, (fingerprint, _))| { - wallet_keys - .xpubs - .iter() - .any(|xpub| xpub.fingerprint() == *fingerprint) - }) + derivation_map + .iter() + .all(|(_, (fingerprint, _))| has_fingerprint(wallet_keys, *fingerprint)) +} + +/// Helper function to derive a public key from an xpub and derivation path +fn derive_pubkey( + secp: &secp256k1::Secp256k1, + xpub: &miniscript::bitcoin::bip32::Xpub, + derivation_path: &miniscript::bitcoin::bip32::DerivationPath, +) -> Result { + xpub.derive_pub(secp, derivation_path) + .map(|derived_xpub| derived_xpub.public_key) + .map_err(|e| format!("Failed to derive public key: {}", e)) +} + +/// Find a derivation path in bip32_derivation map by fingerprint +fn find_bip32_derivation_path( + bip32_derivation: &Bip32DerivationMap, + fingerprint: miniscript::bitcoin::bip32::Fingerprint, +) -> Option<&DerivationPath> { + bip32_derivation + .values() + .find(|(fp, _)| *fp == fingerprint) + .map(|(_, path)| path) +} + +/// Find a derivation path in tap_key_origins map by fingerprint +fn find_tap_key_origins_path( + tap_key_origins: &TapKeyOrigins, + fingerprint: miniscript::bitcoin::bip32::Fingerprint, +) -> Option<&DerivationPath> { + tap_key_origins + .values() + .find(|(_, (fp, _))| *fp == fingerprint) + .map(|(_, (_, path))| path) +} + +/// Derives a public key from an xpub using the derivation path found in a PSBT input +/// +/// This function works with both legacy/SegWit inputs (using bip32_derivation) and +/// Taproot inputs (using tap_key_origins). It searches for a derivation path matching +/// the xpub's fingerprint and derives the public key. +/// +/// # Arguments +/// - `secp`: Secp256k1 context for key derivation +/// - `xpub`: The extended public key to derive from +/// - `input`: The PSBT input containing derivation information +/// +/// # Returns +/// - `Ok(Some(PublicKey))` if a matching derivation path is found and derivation succeeds +/// - `Ok(None)` if no matching derivation path is found or no derivation info exists in the input +/// - `Err(String)` if derivation fails +pub fn derive_pubkey_from_input( + secp: &secp256k1::Secp256k1, + xpub: &miniscript::bitcoin::bip32::Xpub, + input: &Input, +) -> Result, String> { + let xpub_fingerprint = xpub.fingerprint(); + + // Try bip32_derivation first (for legacy/SegWit inputs) + if !input.bip32_derivation.is_empty() { + let derivation_path = find_bip32_derivation_path(&input.bip32_derivation, xpub_fingerprint); + + return match derivation_path { + Some(path) => derive_pubkey(secp, xpub, path).map(Some), + None => Ok(None), // No matching fingerprint found - not an error + }; + } + + // Try tap_key_origins (for Taproot inputs) + if !input.tap_key_origins.is_empty() { + let derivation_path = find_tap_key_origins_path(&input.tap_key_origins, xpub_fingerprint); + + return match derivation_path { + Some(path) => derive_pubkey(secp, xpub, path).map(Some), + None => Ok(None), // No matching fingerprint found - not an error + }; + } + + // No derivation info in input - return None (not an error) + Ok(None) +} + +/// Verifies a Taproot script path signature for a given public key in a PSBT input +/// +/// # Arguments +/// - `secp`: Secp256k1 context for signature verification +/// - `psbt`: The PSBT containing the transaction and inputs +/// - `input_index`: The index of the input to verify +/// - `public_key`: The compressed public key to verify the signature for +/// +/// # Returns +/// - `Ok(true)` if a valid Schnorr signature exists for the public key +/// - `Ok(false)` if no signature exists or verification fails +/// - `Err(String)` if required data is missing or computation fails +pub fn verify_taproot_script_signature( + secp: &secp256k1::Secp256k1, + psbt: &miniscript::bitcoin::psbt::Psbt, + input_index: usize, + public_key: miniscript::bitcoin::CompressedPublicKey, +) -> Result { + use miniscript::bitcoin::{ + hashes::Hash, sighash::Prevouts, sighash::SighashCache, TapLeafHash, XOnlyPublicKey, + }; + + let input = &psbt.inputs[input_index]; + + if input.tap_script_sigs.is_empty() { + return Ok(false); + } + + // Convert CompressedPublicKey to XOnlyPublicKey for Taproot + let x_only_key = XOnlyPublicKey::from_slice(&public_key.to_bytes()[1..]) + .map_err(|e| format!("Failed to convert to x-only public key: {}", e))?; + + // Check all tap_script_sigs for this public key + for ((sig_pubkey, leaf_hash), signature) in &input.tap_script_sigs { + if sig_pubkey == &x_only_key { + // Found a signature for this public key, now verify it + let mut cache = SighashCache::new(&psbt.unsigned_tx); + + // Compute taproot script spend sighash + let prevouts = super::p2tr_musig2_input::collect_prevouts(psbt) + .map_err(|e| format!("Failed to collect prevouts: {}", e))?; + + // Find the script for this leaf hash + // tap_scripts is keyed by ControlBlock, so we need to find the matching entry + let mut found_script = false; + for (script, leaf_version) in input.tap_scripts.values() { + // Compute the leaf hash from the script and leaf version + let computed_leaf_hash = TapLeafHash::from_script(script, *leaf_version); + + if &computed_leaf_hash == leaf_hash { + found_script = true; + break; + } + } + + if !found_script { + return Err("Tap script not found for leaf hash".to_string()); + } + + let sighash_type = signature.sighash_type; + let sighash = cache + .taproot_script_spend_signature_hash( + input_index, + &Prevouts::All(&prevouts), + *leaf_hash, + sighash_type, + ) + .map_err(|e| format!("Failed to compute taproot sighash: {}", e))?; + + // Verify Schnorr signature + let message = secp256k1::Message::from_digest(sighash.to_byte_array()); + match secp.verify_schnorr(&signature.signature, &message, sig_pubkey) { + Ok(()) => return Ok(true), + Err(_) => continue, // Try next signature + } + } + } + + // No valid signature found for this public key in tap_script_sigs + Ok(false) +} + +/// Verifies an ECDSA signature for a given public key in a PSBT input (legacy/SegWit) +/// +/// # Arguments +/// - `secp`: Secp256k1 context for signature verification +/// - `psbt`: The PSBT containing the transaction and inputs +/// - `input_index`: The index of the input to verify +/// - `public_key`: The compressed public key to verify the signature for +/// +/// # Returns +/// - `Ok(true)` if a valid ECDSA signature exists for the public key +/// - `Ok(false)` if no signature exists or verification fails +/// - `Err(String)` if sighash computation fails +pub fn verify_ecdsa_signature( + secp: &secp256k1::Secp256k1, + psbt: &miniscript::bitcoin::psbt::Psbt, + input_index: usize, + public_key: miniscript::bitcoin::CompressedPublicKey, +) -> Result { + use miniscript::bitcoin::{sighash::SighashCache, PublicKey}; + + let input = &psbt.inputs[input_index]; + + // Convert to PublicKey for ECDSA + let public_key_inner = PublicKey::from_slice(&public_key.to_bytes()) + .map_err(|e| format!("Failed to convert public key: {}", e))?; + + // Check if there's a partial signature for this public key + if let Some(signature) = input.partial_sigs.get(&public_key_inner) { + // Create sighash cache and compute sighash for this input + let mut cache = SighashCache::new(&psbt.unsigned_tx); + let (sighash_msg, _sighash_type) = match psbt.sighash_ecdsa(input_index, &mut cache) { + Ok(result) => result, + Err(e) => return Err(format!("Failed to compute sighash: {}", e)), + }; + + // Verify the signature + match secp.verify_ecdsa(&sighash_msg, &signature.signature, &public_key_inner.inner) { + Ok(()) => Ok(true), + Err(_) => Ok(false), + } + } else { + // No signature found for this public key + Ok(false) + } } fn assert_bip32_derivation_map( @@ -46,12 +272,9 @@ fn assert_bip32_derivation_map( derivation_map: &Bip32DerivationMap, ) -> Result<(), String> { for (key, (fingerprint, path)) in derivation_map { - let derived_key = wallet_keys - .xpubs - .iter() - .find(|xpub| xpub.fingerprint() == *fingerprint) + let xpub = find_xpub_by_fingerprint(wallet_keys, *fingerprint) .ok_or_else(|| format!("No xpub found with fingerprint {}", fingerprint))?; - let derived_key = derived_key + let derived_key = xpub .derive_pub(&secp256k1::Secp256k1::new(), path) .map_err(|e| format!("Failed to derive pubkey: {}", e))?; if derived_key.public_key != *key { @@ -64,7 +287,7 @@ fn assert_bip32_derivation_map( Ok(()) } -type TapKeyOrigins = std::collections::BTreeMap, KeySource)>; +pub type TapKeyOrigins = std::collections::BTreeMap, KeySource)>; /// Check if tap key origins belong to the wallet keys (non-failing) /// Returns true if all fingerprints match, false if any don't match (external wallet) @@ -72,12 +295,33 @@ pub fn is_tap_key_origins_for_wallet( wallet_keys: &RootWalletKeys, tap_key_origins: &TapKeyOrigins, ) -> bool { - tap_key_origins.iter().all(|(_, (_, (fingerprint, _)))| { - wallet_keys - .xpubs - .iter() - .any(|xpub| xpub.fingerprint() == *fingerprint) - }) + tap_key_origins + .iter() + .all(|(_, (_, (fingerprint, _)))| has_fingerprint(wallet_keys, *fingerprint)) +} + +/// Derives a public key from an xpub using the derivation path found in the input's tap_key_origins +/// +/// This searches for a derivation path matching the xpub's fingerprint and derives the public key. +/// +/// # Returns +/// - `Ok(PublicKey)` if a matching derivation path is found and derivation succeeds +/// - `Err(String)` if no matching derivation path is found or derivation fails +pub fn derive_pubkey_from_tap_key_origins( + secp: &secp256k1::Secp256k1, + xpub: &miniscript::bitcoin::bip32::Xpub, + tap_key_origins: &TapKeyOrigins, +) -> Result { + let xpub_fingerprint = xpub.fingerprint(); + let derivation_path = + find_tap_key_origins_path(tap_key_origins, xpub_fingerprint).ok_or_else(|| { + format!( + "No tap key origin found for xpub fingerprint {}", + xpub_fingerprint + ) + })?; + + derive_pubkey(secp, xpub, derivation_path) } fn assert_tap_key_origins( @@ -85,12 +329,9 @@ fn assert_tap_key_origins( tap_key_origins: &TapKeyOrigins, ) -> Result<(), String> { for (key, (_, (fingerprint, path))) in tap_key_origins { - let derived_key = wallet_keys - .xpubs - .iter() - .find(|xpub| xpub.fingerprint() == *fingerprint) + let xpub = find_xpub_by_fingerprint(wallet_keys, *fingerprint) .ok_or_else(|| format!("No xpub found with fingerprint {}", fingerprint))?; - let derived_key = derived_key + let derived_key = xpub .derive_pub(&secp256k1::Secp256k1::new(), path) .map_err(|e| format!("Failed to derive pubkey: {}", e))? .to_x_only_pub(); 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 2376fc2..f8ce8c9 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 @@ -1144,6 +1144,7 @@ impl P2trMusig2KeyPathInput { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ScriptType { P2sh, + P2shP2pk, // aka "replay protection" P2shP2wsh, P2wsh, P2trLegacyScriptPath, @@ -1156,6 +1157,7 @@ impl ScriptType { pub fn as_str(&self) -> &'static str { match self { ScriptType::P2sh => "p2sh", + ScriptType::P2shP2pk => "p2shP2pk", ScriptType::P2shP2wsh => "p2shP2wsh", ScriptType::P2wsh => "p2wsh", ScriptType::P2trLegacyScriptPath => "p2tr", @@ -1169,6 +1171,7 @@ impl ScriptType { matches!( (self, fixture), (ScriptType::P2sh, PsbtInputFixture::P2sh(_)) + | (ScriptType::P2shP2pk, PsbtInputFixture::P2shP2pk(_)) | (ScriptType::P2shP2wsh, PsbtInputFixture::P2shP2wsh(_)) | (ScriptType::P2wsh, PsbtInputFixture::P2wsh(_)) | ( @@ -1191,6 +1194,7 @@ impl ScriptType { matches!( (self, fixture), (ScriptType::P2sh, PsbtFinalInputFixture::P2sh(_)) + | (ScriptType::P2shP2pk, PsbtFinalInputFixture::P2shP2pk(_)) | (ScriptType::P2shP2wsh, PsbtFinalInputFixture::P2shP2wsh(_)) | (ScriptType::P2wsh, PsbtFinalInputFixture::P2wsh(_)) | ( @@ -1224,7 +1228,7 @@ impl ScriptType { /// Checks if this script type is supported by the given network's output script support pub fn is_supported_by(&self, support: &crate::address::networks::OutputScriptSupport) -> bool { // P2sh is always supported (legacy) - if matches!(self, ScriptType::P2sh) { + if matches!(self, ScriptType::P2sh | ScriptType::P2shP2pk) { return true; } diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs index 9c8dd0a..5fe10b4 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs @@ -1,3 +1,4 @@ +use std::str::FromStr; use wasm_bindgen::prelude::*; use wasm_bindgen::JsValue; @@ -194,4 +195,74 @@ impl BitGoPsbt { // Convert Vec to JsValue parsed_outputs.try_to_js_value() } + + /// Verify if a valid signature exists for a given xpub at the specified input index + /// + /// This method derives the public key from the xpub using the derivation path found in the + /// PSBT input, then verifies the signature. It supports both ECDSA signatures (for legacy/SegWit + /// inputs) and Schnorr signatures (for Taproot script path inputs). + /// + /// # Arguments + /// - `input_index`: The index of the input to check + /// - `xpub_str`: The extended public key as a base58-encoded string + /// + /// # Returns + /// - `Ok(true)` if a valid signature exists for the derived public key + /// - `Ok(false)` if no signature exists for the derived public key + /// - `Err(WasmUtxoError)` if the input index is out of bounds, xpub is invalid, derivation fails, or verification fails + pub fn verify_signature( + &self, + input_index: usize, + xpub_str: &str, + ) -> Result { + // Parse xpub from string + let xpub = miniscript::bitcoin::bip32::Xpub::from_str(xpub_str) + .map_err(|e| WasmUtxoError::new(&format!("Invalid xpub: {}", e)))?; + + // Create secp context + let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only(); + + // Call the Rust implementation + self.psbt + .verify_signature(&secp, input_index, &xpub) + .map_err(|e| WasmUtxoError::new(&format!("Failed to verify signature: {}", e))) + } + + /// Verify if a replay protection input has a valid signature + /// + /// This method checks if a given input is a replay protection input and cryptographically verifies + /// the signature. Replay protection inputs (like P2shP2pk) don't use standard derivation paths, + /// so this method verifies signatures without deriving from xpub. + /// + /// # Arguments + /// - `input_index`: The index of the input to check + /// - `replay_protection`: Replay protection configuration (same format as parseTransactionWithWalletKeys) + /// Can be either `{ outputScripts: Buffer[] }` or `{ addresses: string[] }` + /// + /// # Returns + /// - `Ok(true)` if the input is a replay protection input and has a valid signature + /// - `Ok(false)` if the input is a replay protection input but has no valid signature + /// - `Err(WasmUtxoError)` if the input is not a replay protection input, index is out of bounds, or configuration is invalid + pub fn verify_replay_protection_signature( + &self, + input_index: usize, + replay_protection: JsValue, + ) -> Result { + // Convert replay protection from JsValue, using the PSBT's network + let network = self.psbt.network(); + let replay_protection = replay_protection_from_js_value(&replay_protection, network)?; + + // Create secp context + let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only(); + + // Call the Rust implementation + self.psbt + .verify_replay_protection_signature(&secp, input_index, &replay_protection) + .map_err(|e| { + WasmUtxoError::new(&format!( + "Failed to verify replay protection signature: {}", + e + )) + }) + } } diff --git a/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts new file mode 100644 index 0000000..2e11634 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts @@ -0,0 +1,133 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as utxolib from "@bitgo/utxo-lib"; + +export type SignatureState = "unsigned" | "halfsigned" | "fullsigned"; + +export type Triple = [T, T, T]; + +export type Bip32Derivation = { + masterFingerprint: string; + pubkey: string; + path: string; +}; + +export type TapBip32Derivation = Bip32Derivation & { + leafHashes: string[]; +}; + +export type WitnessUtxo = { + value: string; + script: string; +}; + +export type TapLeafScript = { + controlBlock: string; + script: string; + leafVersion: number; +}; + +export type PsbtInput = { + type: string; + sighashType: number; + redeemScript?: string; + witnessScript?: string; + bip32Derivation?: Bip32Derivation[]; + tapBip32Derivation?: TapBip32Derivation[]; + witnessUtxo?: WitnessUtxo; + tapLeafScript?: TapLeafScript[]; + tapInternalKey?: string; + tapMerkleRoot?: string; + musig2Participants?: { + tapOutputKey: string; + tapInternalKey: string; + participantPubKeys: string[]; + }; + unknownKeyVals?: Array<{ key: string; value: string }>; +}; + +export type Input = { + hash: string; + index: number; + sequence: number; +}; + +export type Output = { + script: string; + value: string; + address?: string; +}; + +export type TapTreeLeaf = { + depth: number; + leafVersion: number; + script: string; +}; + +export type PsbtOutput = { + redeemScript?: string; + witnessScript?: string; + bip32Derivation?: Bip32Derivation[]; + tapBip32Derivation?: TapBip32Derivation[]; + tapInternalKey?: string; + tapTree?: { + leaves: TapTreeLeaf[]; + }; +}; + +export type Fixture = { + walletKeys: [string, string, string]; + psbtBase64: string; + psbtBase64Finalized: string | null; + inputs: Input[]; + psbtInputs: PsbtInput[]; + psbtInputsFinalized: PsbtInput[] | null; + outputs: Output[]; + psbtOutputs: PsbtOutput[]; + extractedTransaction: any | null; +}; + +/** + * Get PSBT buffer from a fixture + */ +export function getPsbtBuffer(fixture: Fixture): Buffer { + return Buffer.from(fixture.psbtBase64, "base64"); +} + +/** + * Load a PSBT fixture from JSON file + */ +export function loadPsbtFixture(network: string, signatureState: string): Fixture { + const fixturePath = path.join( + __dirname, + "..", + "fixtures", + "fixed-script", + `psbt-lite.${network}.${signatureState}.json`, + ); + const fixtureContent = fs.readFileSync(fixturePath, "utf-8"); + return JSON.parse(fixtureContent) as Fixture; +} + +/** + * Load wallet keys from fixture + */ +export function loadWalletKeysFromFixture(network: string): utxolib.bitgo.RootWalletKeys { + const fixturePath = path.join( + __dirname, + "..", + "fixtures", + "fixed-script", + `psbt-lite.${network}.fullsigned.json`, + ); + const fixtureContent = fs.readFileSync(fixturePath, "utf-8"); + const fixture = JSON.parse(fixtureContent) as Fixture; + + // Parse xprvs and convert to xpubs + const xpubs = fixture.walletKeys.map((xprv) => { + const key = utxolib.bip32.fromBase58(xprv); + return key.neutered(); + }); + + return new utxolib.bitgo.RootWalletKeys(xpubs as Triple); +} diff --git a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts index 3469944..7a92a72 100644 --- a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts +++ b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts @@ -1,56 +1,14 @@ import assert from "node:assert"; -import * as fs from "node:fs"; -import * as path from "node:path"; import * as utxolib from "@bitgo/utxo-lib"; import { fixedScriptWallet } from "../../js"; import { BitGoPsbt } from "../../js/fixedScriptWallet"; - -type Triple = [T, T, T]; +import { loadPsbtFixture, loadWalletKeysFromFixture, getPsbtBuffer } from "./fixtureUtil"; function getOtherWalletKeys(): utxolib.bitgo.RootWalletKeys { const otherWalletKeys = utxolib.testutil.getKeyTriple("too many secrets"); return new utxolib.bitgo.RootWalletKeys(otherWalletKeys); } -/** - * Load a PSBT fixture from JSON file and return the PSBT bytes - */ -function loadPsbtFixture(network: string): Buffer { - const fixturePath = path.join( - __dirname, - "..", - "fixtures", - "fixed-script", - `psbt-lite.${network}.fullsigned.json`, - ); - const fixtureContent = fs.readFileSync(fixturePath, "utf-8"); - const fixture = JSON.parse(fixtureContent) as { psbtBase64: string; walletKeys: string[] }; - return Buffer.from(fixture.psbtBase64, "base64"); -} - -/** - * Load wallet keys from fixture - */ -function loadWalletKeysFromFixture(network: string): utxolib.bitgo.RootWalletKeys { - const fixturePath = path.join( - __dirname, - "..", - "fixtures", - "fixed-script", - `psbt-lite.${network}.fullsigned.json`, - ); - const fixtureContent = fs.readFileSync(fixturePath, "utf-8"); - const fixture = JSON.parse(fixtureContent) as { walletKeys: string[] }; - - // Parse xprvs and convert to xpubs - const xpubs = fixture.walletKeys.map((xprv) => { - const key = utxolib.bip32.fromBase58(xprv); - return key.neutered(); - }); - - return new utxolib.bitgo.RootWalletKeys(xpubs as Triple); -} - describe("parseTransactionWithWalletKeys", function () { // Replay protection script that matches Rust tests const replayProtectionScript = Buffer.from( @@ -78,20 +36,20 @@ describe("parseTransactionWithWalletKeys", function () { const networkName = utxolib.getNetworkName(network); describe(`network: ${networkName}`, function () { - let psbtBytes: Buffer; + let fullsignedPsbtBytes: Buffer; let bitgoPsbt: BitGoPsbt; let rootWalletKeys: utxolib.bitgo.RootWalletKeys; before(function () { - psbtBytes = loadPsbtFixture(networkName); - bitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(psbtBytes, networkName); + fullsignedPsbtBytes = getPsbtBuffer(loadPsbtFixture(networkName, "fullsigned")); + bitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(fullsignedPsbtBytes, networkName); rootWalletKeys = loadWalletKeysFromFixture(networkName); }); it("should have matching unsigned transaction ID", function () { const unsignedTxid = bitgoPsbt.unsignedTxid(); const expectedUnsignedTxid = utxolib.bitgo - .createPsbtFromBuffer(psbtBytes, network) + .createPsbtFromBuffer(fullsignedPsbtBytes, network) .getUnsignedTx() .getId(); assert.strictEqual(unsignedTxid, expectedUnsignedTxid); diff --git a/packages/wasm-utxo/test/fixedScript/verifySignature.ts b/packages/wasm-utxo/test/fixedScript/verifySignature.ts new file mode 100644 index 0000000..8269c95 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/verifySignature.ts @@ -0,0 +1,237 @@ +import assert from "node:assert"; +import * as utxolib from "@bitgo/utxo-lib"; +import { fixedScriptWallet } from "../../js"; +import { BitGoPsbt } from "../../js/fixedScriptWallet"; +import { + loadPsbtFixture, + loadWalletKeysFromFixture, + getPsbtBuffer, + type Fixture, +} from "./fixtureUtil"; + +type SignatureStage = "unsigned" | "halfsigned" | "fullsigned"; + +type ExpectedSignatures = + | { hasReplayProtectionSignature: boolean } + | { user: boolean; backup: boolean; bitgo: boolean }; + +/** + * Get expected signature state for an input based on type and signing stage + * @param inputType - The type of input (e.g., "p2shP2pk", "p2trMusig2") + * @param signatureStage - The signing stage (unsigned, halfsigned, fullsigned) + * @returns Expected signature state for replay protection OR multi-key signatures + */ +function getExpectedSignatures( + inputType: string, + signatureStage: SignatureStage, +): ExpectedSignatures { + // p2shP2pk inputs use replay protection signature verification + if (inputType === "p2shP2pk") { + return { + hasReplayProtectionSignature: + signatureStage === "halfsigned" || signatureStage === "fullsigned", + }; + } + + switch (signatureStage) { + case "unsigned": + return { user: false, backup: false, bitgo: false }; + case "halfsigned": + // User signs first + return { user: true, backup: false, bitgo: false }; + case "fullsigned": + // p2trMusig2 uses user + backup for 2-of-2 MuSig2 + if (inputType === "p2trMusig2") { + return { user: true, backup: true, bitgo: false }; + } + // Regular multisig uses user + bitgo + return { user: true, backup: false, bitgo: true }; + default: + throw new Error(`Unknown signature stage: ${signatureStage}`); + } +} + +/** + * Verify signature state for a specific input in a PSBT + * @param bitgoPsbt - The PSBT to verify + * @param rootWalletKeys - Wallet keys for verification + * @param inputIndex - The input index to verify + * @param inputType - The type of input (for replay protection handling) + * @param expectedSignatures - Expected signature state for each key or replay protection + */ +function verifyInputSignatures( + bitgoPsbt: BitGoPsbt, + rootWalletKeys: utxolib.bitgo.RootWalletKeys, + inputIndex: number, + expectedSignatures: ExpectedSignatures, +): void { + // Handle replay protection inputs (P2shP2pk) + if ("hasReplayProtectionSignature" in expectedSignatures) { + const replayProtectionScript = Buffer.from( + "a91420b37094d82a513451ff0ccd9db23aba05bc5ef387", + "hex", + ); + const hasReplaySig = bitgoPsbt.verifyReplayProtectionSignature(inputIndex, { + outputScripts: [replayProtectionScript], + }); + assert.strictEqual( + hasReplaySig, + expectedSignatures.hasReplayProtectionSignature, + `Input ${inputIndex} replay protection signature mismatch`, + ); + return; + } + + // Handle standard multisig inputs + const xpubs = rootWalletKeys.triple; + + const hasUserSig = bitgoPsbt.verifySignature(inputIndex, xpubs[0].toBase58()); + const hasBackupSig = bitgoPsbt.verifySignature(inputIndex, xpubs[1].toBase58()); + const hasBitGoSig = bitgoPsbt.verifySignature(inputIndex, xpubs[2].toBase58()); + + assert.strictEqual( + hasUserSig, + expectedSignatures.user, + `Input ${inputIndex} user key signature mismatch`, + ); + assert.strictEqual( + hasBackupSig, + expectedSignatures.backup, + `Input ${inputIndex} backup key signature mismatch`, + ); + assert.strictEqual( + hasBitGoSig, + expectedSignatures.bitgo, + `Input ${inputIndex} BitGo key signature mismatch`, + ); +} + +describe("verifySignature", function () { + const supportedNetworks = utxolib.getNetworkList().filter((network) => { + return ( + utxolib.isMainnet(network) && + network !== utxolib.networks.bitcoincash && + network !== utxolib.networks.bitcoingold && + network !== utxolib.networks.bitcoinsv && + network !== utxolib.networks.ecash && + network !== utxolib.networks.zcash + ); + }); + + supportedNetworks.forEach((network) => { + const networkName = utxolib.getNetworkName(network); + + describe(`network: ${networkName}`, function () { + let rootWalletKeys: utxolib.bitgo.RootWalletKeys; + let unsignedFixture: Fixture; + let halfsignedFixture: Fixture; + let fullsignedFixture: Fixture; + let unsignedBitgoPsbt: BitGoPsbt; + let halfsignedBitgoPsbt: BitGoPsbt; + let fullsignedBitgoPsbt: BitGoPsbt; + + before(function () { + rootWalletKeys = loadWalletKeysFromFixture(networkName); + unsignedFixture = loadPsbtFixture(networkName, "unsigned"); + halfsignedFixture = loadPsbtFixture(networkName, "halfsigned"); + fullsignedFixture = loadPsbtFixture(networkName, "fullsigned"); + unsignedBitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes( + getPsbtBuffer(unsignedFixture), + networkName, + ); + halfsignedBitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes( + getPsbtBuffer(halfsignedFixture), + networkName, + ); + fullsignedBitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes( + getPsbtBuffer(fullsignedFixture), + networkName, + ); + }); + + describe("unsigned PSBT", function () { + it("should return false for unsigned inputs", function () { + // Verify all xpubs return false for all inputs + unsignedFixture.psbtInputs.forEach((input, index) => { + verifyInputSignatures( + unsignedBitgoPsbt, + rootWalletKeys, + index, + getExpectedSignatures(input.type, "unsigned"), + ); + }); + }); + }); + + describe("half-signed PSBT", function () { + it("should return true for signed xpubs and false for unsigned", function () { + halfsignedFixture.psbtInputs.forEach((input, index) => { + verifyInputSignatures( + halfsignedBitgoPsbt, + rootWalletKeys, + index, + getExpectedSignatures(input.type, "halfsigned"), + ); + }); + }); + }); + + describe("fully signed PSBT", function () { + it("should have 2 signatures (2-of-3 multisig)", function () { + // In fullsigned fixtures, verify 2 signatures exist per multisig input + fullsignedFixture.psbtInputs.forEach((input, index) => { + verifyInputSignatures( + fullsignedBitgoPsbt, + rootWalletKeys, + index, + getExpectedSignatures(input.type, "fullsigned"), + ); + }); + }); + }); + + describe("error handling", function () { + it("should throw error for out of bounds input index", function () { + const xpubs = rootWalletKeys.triple; + + assert.throws( + () => { + fullsignedBitgoPsbt.verifySignature(999, xpubs[0].toBase58()); + }, + (error: Error) => { + return error.message.includes("Input index 999 out of bounds"); + }, + "Should throw error for out of bounds input index", + ); + }); + + it("should throw error for invalid xpub", function () { + assert.throws( + () => { + fullsignedBitgoPsbt.verifySignature(0, "invalid-xpub"); + }, + (error: Error) => { + return error.message.includes("Invalid xpub"); + }, + "Should throw error for invalid xpub", + ); + }); + + it("should return false for xpub not in derivation path", function () { + // Create a different xpub that's not in the wallet + // Use a proper 32-byte seed (256 bits) + const differentSeed = Buffer.alloc(32, 0xaa); // 32 bytes filled with 0xaa + const differentKey = utxolib.bip32.fromSeed(differentSeed, network); + const differentXpub = differentKey.neutered(); + + const result = fullsignedBitgoPsbt.verifySignature(0, differentXpub.toBase58()); + assert.strictEqual( + result, + false, + "Should return false for xpub not in PSBT derivation paths", + ); + }); + }); + }); + }); +});