From 42c58921adf47e6299764236dbcef8c749cfc83b Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 2 Apr 2026 11:49:21 +0200 Subject: [PATCH] feat(wasm-utxo): support P2SH-P2PK replay protection inputs in legacy format hydration Extend legacy transaction hydration to handle P2SH-P2PK replay protection inputs alongside standard multisig wallet inputs. Update TypeScript HydrationUnspent type to accept either wallet inputs (with chain/index) or replay protection inputs (with pubkey). Add parsing and serialization logic for P2PK scripts in legacy format conversion. BTC-2768 Co-authored-by: llm-git --- .../js/fixedScriptWallet/BitGoPsbt.ts | 8 +- .../bitgo_psbt/legacy_txformat.rs | 351 +++++++++++------- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 146 ++++++-- .../fixed_script_wallet/wallet_scripts/mod.rs | 2 +- .../wallet_scripts/singlesig.rs | 13 + .../src/wasm/fixed_script_wallet/mod.rs | 56 ++- .../fromHalfSignedLegacyTransaction.ts | 43 +++ .../fixedScript/halfSignedLegacyFormat.ts | 33 ++ 8 files changed, 467 insertions(+), 185 deletions(-) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 0c4936e0d6d..099d836842f 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -124,11 +124,9 @@ export type ParseOutputsOptions = { payGoPubkeys?: ECPairArg[]; }; -export type HydrationUnspent = { - chain: number; - index: number; - value: bigint; -}; +export type HydrationUnspent = + | { chain: number; index: number; value: bigint } // wallet input + | { pubkey: Uint8Array; value: bigint }; // P2SH-P2PK replay protection input export class BitGoPsbt extends PsbtBase implements IPsbtWithAddress { protected constructor(wasm: WasmBitGoPsbt) { diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/legacy_txformat.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/legacy_txformat.rs index a3780416865..2f78677eac4 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/legacy_txformat.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/legacy_txformat.rs @@ -4,7 +4,7 @@ //! legacy format used by utxo-lib and bitcoinjs-lib, where signatures are placed //! in scriptSig/witness with OP_0 placeholders for missing signatures. -use crate::fixed_script_wallet::wallet_scripts::parse_multisig_script_2_of_3; +use crate::fixed_script_wallet::wallet_scripts::{parse_multisig_script_2_of_3, parse_p2pk_script}; use miniscript::bitcoin::blockdata::opcodes::all::OP_PUSHBYTES_0; use miniscript::bitcoin::blockdata::script::Builder; use miniscript::bitcoin::ecdsa::Signature as EcdsaSig; @@ -26,19 +26,19 @@ pub fn build_half_signed_legacy_tx(psbt: &Psbt) -> Result { let mut tx = psbt.unsigned_tx.clone(); for (input_index, psbt_input) in psbt.inputs.iter().enumerate() { - // Determine script type and get the multisig script - let (is_p2sh, is_p2wsh, multisig_script) = + // Determine script type and get the redeem/multisig script + let (is_p2sh, is_p2wsh, redeem_or_multisig_script) = if let Some(ref witness_script) = psbt_input.witness_script { // p2wsh or p2shP2wsh - witness_script contains the multisig script let is_p2sh = psbt_input.redeem_script.is_some(); (is_p2sh, true, witness_script.clone()) } else if let Some(ref redeem_script) = psbt_input.redeem_script { - // p2sh only - redeem_script contains the multisig script + // p2sh only - redeem_script may be multisig or P2PK (replay protection) (true, false, redeem_script.clone()) } else { return Err(format!( "Input {}: unsupported script type (no witness_script or redeem_script found). \ - Only p2ms-based types (p2sh, p2shP2wsh, p2wsh) are supported.", + Only p2ms-based types (p2sh, p2shP2wsh, p2wsh) and P2SH-P2PK are supported.", input_index )); }; @@ -51,98 +51,135 @@ pub fn build_half_signed_legacy_tx(psbt: &Psbt) -> Result { )); } - // Validate exactly 1 partial signature - let sig_count = psbt_input.partial_sigs.len(); - if sig_count != 1 { - return Err(format!( - "Input {}: expected exactly 1 partial signature, got {}", - input_index, sig_count - )); - } - - // Get the single partial signature - let (sig_pubkey, ecdsa_sig) = psbt_input.partial_sigs.iter().next().unwrap(); - - // Parse the multisig script to get the 3 public keys - let pubkeys = parse_multisig_script_2_of_3(&multisig_script).map_err(|e| { - format!( - "Input {}: failed to parse multisig script: {}", - input_index, e - ) - })?; - - // Find which key index (0, 1, 2) matches the signature's pubkey - let sig_key_index = pubkeys - .iter() - .position(|pk| pk.to_bytes() == sig_pubkey.to_bytes()[..]) - .ok_or_else(|| { - format!( - "Input {}: signature pubkey not found in multisig script", - input_index - ) - })?; - - // Serialize the signature - let sig_bytes = ecdsa_sig.to_vec(); - - // Build the signatures array with the signature in the correct position - // Format: [OP_0, sig_or_empty, sig_or_empty, sig_or_empty] - let mut sig_stack: Vec> = vec![vec![]]; // Start with OP_0 (empty) - for i in 0..3 { - if i == sig_key_index { - sig_stack.push(sig_bytes.clone()); - } else { - sig_stack.push(vec![]); // OP_0 placeholder + // Try to parse as 2-of-3 multisig first + if let Ok(pubkeys) = parse_multisig_script_2_of_3(&redeem_or_multisig_script) { + // Multisig wallet input: require exactly 1 partial signature + let sig_count = psbt_input.partial_sigs.len(); + if sig_count != 1 { + return Err(format!( + "Input {}: expected exactly 1 partial signature, got {}", + input_index, sig_count + )); } - } - // Build scriptSig and/or witness based on script type - if is_p2wsh { - // p2wsh or p2shP2wsh: witness = [empty, sigs..., witnessScript] - let mut witness_items = sig_stack; - witness_items.push(multisig_script.to_bytes()); - tx.input[input_index].witness = Witness::from_slice(&witness_items); - - if is_p2sh { - // p2shP2wsh: also need scriptSig = [redeemScript] - // The redeemScript is the p2wsh script (hash of witness script) - let redeem_script = psbt_input.redeem_script.as_ref().unwrap(); - let redeem_script_bytes = PushBytesBuf::try_from(redeem_script.to_bytes()) - .map_err(|e| { - format!( - "Input {}: failed to convert redeem script to push bytes: {}", - input_index, e - ) - })?; - let script_sig = Builder::new().push_slice(redeem_script_bytes).into_script(); - tx.input[input_index].script_sig = script_sig; - } - } else { - // p2sh only: scriptSig = [OP_0, sigs..., redeemScript] - let mut builder = Builder::new().push_opcode(OP_PUSHBYTES_0); + let (sig_pubkey, ecdsa_sig) = psbt_input.partial_sigs.iter().next().unwrap(); + + // Find which key index (0, 1, 2) matches the signature's pubkey + let sig_key_index = pubkeys + .iter() + .position(|pk| pk.to_bytes() == sig_pubkey.to_bytes()[..]) + .ok_or_else(|| { + format!( + "Input {}: signature pubkey not found in multisig script", + input_index + ) + })?; + + let sig_bytes = ecdsa_sig.to_vec(); + + // Build the signatures array with the signature in the correct position + // Format: [OP_0, sig_or_empty, sig_or_empty, sig_or_empty] + let mut sig_stack: Vec> = vec![vec![]]; // Start with OP_0 (empty) for i in 0..3 { if i == sig_key_index { - let sig_push_bytes = - PushBytesBuf::try_from(sig_bytes.clone()).map_err(|e| { + sig_stack.push(sig_bytes.clone()); + } else { + sig_stack.push(vec![]); // OP_0 placeholder + } + } + + // Build scriptSig and/or witness based on script type + if is_p2wsh { + // p2wsh or p2shP2wsh: witness = [empty, sigs..., witnessScript] + let mut witness_items = sig_stack; + witness_items.push(redeem_or_multisig_script.to_bytes()); + tx.input[input_index].witness = Witness::from_slice(&witness_items); + + if is_p2sh { + // p2shP2wsh: also need scriptSig = [redeemScript] + let redeem_script = psbt_input.redeem_script.as_ref().unwrap(); + let redeem_script_bytes = PushBytesBuf::try_from(redeem_script.to_bytes()) + .map_err(|e| { format!( - "Input {}: failed to convert signature to push bytes: {}", + "Input {}: failed to convert redeem script to push bytes: {}", input_index, e ) })?; - builder = builder.push_slice(sig_push_bytes); - } else { - builder = builder.push_opcode(OP_PUSHBYTES_0); + let script_sig = Builder::new().push_slice(redeem_script_bytes).into_script(); + tx.input[input_index].script_sig = script_sig; } + } else { + // p2sh only: scriptSig = [OP_0, sigs..., redeemScript] + let mut builder = Builder::new().push_opcode(OP_PUSHBYTES_0); + for i in 0..3 { + if i == sig_key_index { + let sig_push_bytes = + PushBytesBuf::try_from(sig_bytes.clone()).map_err(|e| { + format!( + "Input {}: failed to convert signature to push bytes: {}", + input_index, e + ) + })?; + builder = builder.push_slice(sig_push_bytes); + } else { + builder = builder.push_opcode(OP_PUSHBYTES_0); + } + } + let multisig_push_bytes = + PushBytesBuf::try_from(redeem_or_multisig_script.to_bytes()).map_err(|e| { + format!( + "Input {}: failed to convert multisig script to push bytes: {}", + input_index, e + ) + })?; + builder = builder.push_slice(multisig_push_bytes); + tx.input[input_index].script_sig = builder.into_script(); + } + } else if parse_p2pk_script(&redeem_or_multisig_script).is_some() { + // P2SH-P2PK replay protection input: 0 or 1 partial sigs allowed + let sig_count = psbt_input.partial_sigs.len(); + if sig_count > 1 { + return Err(format!( + "Input {}: P2SH-P2PK replay protection input has {} signatures, expected 0 or 1", + input_index, sig_count + )); } - let multisig_push_bytes = - PushBytesBuf::try_from(multisig_script.to_bytes()).map_err(|e| { + + let redeem_push_bytes = PushBytesBuf::try_from(redeem_or_multisig_script.to_bytes()) + .map_err(|e| { format!( - "Input {}: failed to convert multisig script to push bytes: {}", + "Input {}: failed to convert P2PK redeem script to push bytes: {}", input_index, e ) })?; - builder = builder.push_slice(multisig_push_bytes); - tx.input[input_index].script_sig = builder.into_script(); + + let script_sig = if sig_count == 1 { + // Signed: scriptSig = + let (_, ecdsa_sig) = psbt_input.partial_sigs.iter().next().unwrap(); + let sig_bytes = ecdsa_sig.to_vec(); + let sig_push_bytes = PushBytesBuf::try_from(sig_bytes).map_err(|e| { + format!( + "Input {}: failed to convert RP signature to push bytes: {}", + input_index, e + ) + })?; + Builder::new() + .push_slice(sig_push_bytes) + .push_slice(redeem_push_bytes) + .into_script() + } else { + // Unsigned: scriptSig = OP_0 + Builder::new() + .push_opcode(OP_PUSHBYTES_0) + .push_slice(redeem_push_bytes) + .into_script() + }; + tx.input[input_index].script_sig = script_sig; + } else { + return Err(format!( + "Input {}: redeem_script is neither a valid 2-of-3 multisig nor a P2PK script", + input_index + )); } } @@ -155,6 +192,19 @@ pub struct LegacyPartialSig { pub sig: EcdsaSig, } +/// The result of parsing a legacy input — either a multisig wallet input or a +/// P2SH-P2PK replay protection input. +pub enum LegacyInputResult { + /// Standard p2ms wallet input (p2sh, p2shP2wsh, p2wsh) with exactly 1 sig. + Multisig(LegacyPartialSig), + /// P2SH-P2PK replay protection input with the pubkey and an optional signature + /// (None when the input was serialized unsigned with an OP_0 placeholder). + ReplayProtection { + pubkey: CompressedPublicKey, + sig: Option, + }, +} + /// Determines whether a legacy input uses segwit (witness data) and whether it /// has a p2sh wrapper (scriptSig pushing a redeem script). /// @@ -176,78 +226,117 @@ fn classify_legacy_input(tx_in: &TxIn) -> Result<(bool, bool, ScriptBuf), String let is_p2sh = has_script_sig; // p2shP2wsh has scriptSig, p2wsh does not Ok((is_p2sh, true, multisig_script)) } else if has_script_sig { - // p2sh only: scriptSig = [OP_0, sig0?, sig1?, sig2?, redeemScript] - // Parse the scriptSig instructions to extract the redeemScript (last push) + // p2sh: scriptSig items vary by type (multisig: ≥5 items, P2PK: 2 items) let instructions: Vec<_> = tx_in .script_sig .instructions() .collect::, _>>() .map_err(|e| format!("Failed to parse scriptSig: {}", e))?; - if instructions.len() < 5 { + if instructions.len() < 2 { return Err(format!( - "Expected at least 5 scriptSig items, got {}", + "Expected at least 2 scriptSig items, got {}", instructions.len() )); } let last = instructions.last().unwrap(); - let multisig_bytes = match last { + let redeem_bytes = match last { miniscript::bitcoin::script::Instruction::PushBytes(bytes) => bytes.as_bytes(), _ => return Err("Last scriptSig item is not a push".to_string()), }; - Ok((true, false, ScriptBuf::from(multisig_bytes.to_vec()))) + Ok((true, false, ScriptBuf::from(redeem_bytes.to_vec()))) } else { Err("Input has neither witness nor scriptSig".to_string()) } } -/// Extract a partial signature from a legacy half-signed input. +/// Parse a legacy half-signed input and return either a multisig or replay protection result. /// /// This is the inverse of the signature placement in `build_half_signed_legacy_tx`. -/// It parses the scriptSig/witness to find the single signature and its position -/// in the 2-of-3 multisig, then returns the corresponding pubkey and signature. -pub fn unsign_legacy_input(tx_in: &TxIn) -> Result { - let (_, is_segwit, multisig_script) = classify_legacy_input(tx_in)?; - - let pubkeys = parse_multisig_script_2_of_3(&multisig_script)?; - - // Extract the 3 signature slots (index 1..=3, skipping the leading OP_0/empty) - let sig_slots: Vec> = if is_segwit { - let items: Vec<&[u8]> = tx_in.witness.iter().collect(); - // witness = [empty, sig0?, sig1?, sig2?, witnessScript] - items[1..=3].iter().map(|s| s.to_vec()).collect() - } else { - // scriptSig = [OP_0, sig0?, sig1?, sig2?, redeemScript] +pub fn parse_legacy_input(tx_in: &TxIn) -> Result { + let (_, is_segwit, redeem_or_multisig_script) = classify_legacy_input(tx_in)?; + + // Try to parse as multisig first + if let Ok(pubkeys) = parse_multisig_script_2_of_3(&redeem_or_multisig_script) { + // Extract the 3 signature slots (index 1..=3, skipping the leading OP_0/empty) + let sig_slots: Vec> = if is_segwit { + let items: Vec<&[u8]> = tx_in.witness.iter().collect(); + if items.len() < 5 { + return Err(format!( + "Expected at least 5 witness items for multisig, got {}", + items.len() + )); + } + // witness = [empty, sig0?, sig1?, sig2?, witnessScript] + items[1..=3].iter().map(|s| s.to_vec()).collect() + } else { + // scriptSig = [OP_0, sig0?, sig1?, sig2?, redeemScript] + let instructions: Vec<_> = tx_in + .script_sig + .instructions() + .collect::, _>>() + .map_err(|e| format!("Failed to parse scriptSig: {}", e))?; + if instructions.len() < 5 { + return Err(format!( + "Expected at least 5 scriptSig items for multisig, got {}", + instructions.len() + )); + } + // instructions[0] = OP_0, [1..=3] = sigs, [4] = redeemScript + instructions[1..=3] + .iter() + .map(|inst| match inst { + miniscript::bitcoin::script::Instruction::PushBytes(bytes) => { + bytes.as_bytes().to_vec() + } + miniscript::bitcoin::script::Instruction::Op(_) => vec![], + }) + .collect() + }; + + // Find the non-empty signature slot + let mut found_sig = None; + for (i, slot) in sig_slots.iter().enumerate() { + if !slot.is_empty() { + if found_sig.is_some() { + return Err("Expected exactly 1 signature, found multiple".to_string()); + } + let sig = EcdsaSig::from_slice(slot) + .map_err(|e| format!("Failed to parse signature at position {}: {}", i, e))?; + let pubkey = CompressedPublicKey::from_slice(&pubkeys[i].to_bytes()) + .map_err(|e| format!("Failed to convert pubkey: {}", e))?; + found_sig = Some(LegacyPartialSig { pubkey, sig }); + } + } + + let sig = found_sig.ok_or_else(|| "No signature found in multisig input".to_string())?; + Ok(LegacyInputResult::Multisig(sig)) + } else if let Some(pubkey) = parse_p2pk_script(&redeem_or_multisig_script) { + // P2SH-P2PK replay protection input + // scriptSig = [ ] let instructions: Vec<_> = tx_in .script_sig .instructions() .collect::, _>>() - .map_err(|e| format!("Failed to parse scriptSig: {}", e))?; - // instructions[0] = OP_0, [1..=3] = sigs, [4] = redeemScript - instructions[1..=3] - .iter() - .map(|inst| match inst { - miniscript::bitcoin::script::Instruction::PushBytes(bytes) => { - bytes.as_bytes().to_vec() - } - miniscript::bitcoin::script::Instruction::Op(_) => vec![], - }) - .collect() - }; - - // Find the non-empty signature slot - let mut found_sig = None; - for (i, slot) in sig_slots.iter().enumerate() { - if !slot.is_empty() { - if found_sig.is_some() { - return Err("Expected exactly 1 signature, found multiple".to_string()); + .map_err(|e| format!("Failed to parse P2PK scriptSig: {}", e))?; + + // instructions[0] = sig or OP_0 placeholder, instructions[1] = redeemScript + let sig = match instructions.first() { + Some(miniscript::bitcoin::script::Instruction::PushBytes(bytes)) + if !bytes.is_empty() => + { + let ecdsa_sig = EcdsaSig::from_slice(bytes.as_bytes()) + .map_err(|e| format!("Failed to parse P2PK signature: {}", e))?; + Some(ecdsa_sig) } - let sig = EcdsaSig::from_slice(slot) - .map_err(|e| format!("Failed to parse signature at position {}: {}", i, e))?; - let pubkey = CompressedPublicKey::from_slice(&pubkeys[i].to_bytes()) - .map_err(|e| format!("Failed to convert pubkey: {}", e))?; - found_sig = Some(LegacyPartialSig { pubkey, sig }); - } - } + _ => None, // OP_0 or empty push = unsigned placeholder + }; - found_sig.ok_or_else(|| "No signature found in input".to_string()) + Ok(LegacyInputResult::ReplayProtection { pubkey, sig }) + } else { + Err( + "scriptSig/witness does not correspond to a known script type \ + (multisig 2-of-3 or P2SH-P2PK)" + .to_string(), + ) + } } 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 db1057248ee..1fd2616ceb1 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 @@ -120,6 +120,18 @@ pub use psbt_wallet_input::{ }; pub use psbt_wallet_output::ParsedOutput; +/// Describes a single input for `from_half_signed_legacy_transaction`. +pub enum HydrationUnspentInput { + /// A regular wallet input with derivation chain, index, and value. + Wallet(psbt_wallet_input::ScriptIdWithValue), + /// A P2SH-P2PK replay protection input. The caller provides the expected pubkey so it can be + /// validated against the redeemScript embedded in the legacy transaction. + ReplayProtection { + pubkey: miniscript::bitcoin::CompressedPublicKey, + value: u64, + }, +} + /// Parsed transaction with wallet information #[derive(Debug, Clone)] pub struct ParsedTransaction { @@ -505,12 +517,12 @@ impl BitGoPsbt { /// creates a PSBT with proper wallet metadata (bip32Derivation, scripts, /// witnessUtxo), and inserts the extracted signatures. /// - /// Only supports p2sh, p2shP2wsh, and p2wsh inputs (not taproot). + /// Supports p2sh, p2shP2wsh, p2wsh, and P2SH-P2PK (replay protection) inputs. pub fn from_half_signed_legacy_transaction( tx_bytes: &[u8], network: Network, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, - unspents: &[psbt_wallet_input::ScriptIdWithValue], + unspents: &[HydrationUnspentInput], ) -> Result { use miniscript::bitcoin::consensus::Decodable; use miniscript::bitcoin::{PublicKey, Transaction}; @@ -531,43 +543,113 @@ impl BitGoPsbt { let mut psbt = Self::new(network, wallet_keys, Some(version), Some(lock_time)); - // Extract signatures before adding inputs (we need the raw tx_in data) - let partial_sigs: Vec = tx + // Parse each input from the legacy tx + let input_results: Vec = tx .input .iter() .enumerate() .map(|(i, tx_in)| { - legacy_txformat::unsign_legacy_input(tx_in) + legacy_txformat::parse_legacy_input(tx_in) .map_err(|e| format!("Input {}: {}", i, e)) }) .collect::, _>>()?; - // Add wallet inputs (populates bip32Derivation, scripts, witnessUtxo) for (i, (tx_in, unspent)) in tx.input.iter().zip(unspents.iter()).enumerate() { - let script_id = psbt_wallet_input::ScriptId { - chain: unspent.chain, - index: unspent.index, - }; - psbt.add_wallet_input( - tx_in.previous_output.txid, - tx_in.previous_output.vout, - unspent.value, - wallet_keys, - script_id, - psbt_wallet_input::WalletInputOptions { - sign_path: None, - sequence: Some(tx_in.sequence.0), - prev_tx: None, // psbt-lite: no nonWitnessUtxo - }, - ) - .map_err(|e| format!("Input {}: failed to add wallet input: {}", i, e))?; + match (&input_results[i], unspent) { + ( + legacy_txformat::LegacyInputResult::Multisig(sig), + HydrationUnspentInput::Wallet(sv), + ) => { + let script_id = psbt_wallet_input::ScriptId { + chain: sv.chain, + index: sv.index, + }; + psbt.add_wallet_input( + tx_in.previous_output.txid, + tx_in.previous_output.vout, + sv.value, + wallet_keys, + script_id, + psbt_wallet_input::WalletInputOptions { + sign_path: None, + sequence: Some(tx_in.sequence.0), + prev_tx: None, // psbt-lite: no nonWitnessUtxo + }, + ) + .map_err(|e| format!("Input {}: failed to add wallet input: {}", i, e))?; - // Insert the extracted partial signature - let sig = &partial_sigs[i]; - let pubkey = PublicKey::from(sig.pubkey); - psbt.psbt_mut().inputs[i] - .partial_sigs - .insert(pubkey, sig.sig); + let pubkey = PublicKey::from(sig.pubkey); + psbt.psbt_mut().inputs[i] + .partial_sigs + .insert(pubkey, sig.sig); + } + ( + legacy_txformat::LegacyInputResult::ReplayProtection { + pubkey: tx_pubkey, + sig, + }, + HydrationUnspentInput::ReplayProtection { + pubkey: expected_pubkey, + value, + }, + ) => { + if tx_pubkey.to_bytes() != expected_pubkey.to_bytes() { + return Err(format!( + "Input {}: replay protection pubkey mismatch: \ + tx has {}, expected {}", + i, + tx_pubkey + .to_bytes() + .iter() + .map(|b| format!("{:02x}", b)) + .collect::(), + expected_pubkey + .to_bytes() + .iter() + .map(|b| format!("{:02x}", b)) + .collect::(), + )); + } + psbt.add_replay_protection_input_at_index( + i, + *tx_pubkey, + tx_in.previous_output.txid, + tx_in.previous_output.vout, + *value, + ReplayProtectionOptions { + sequence: Some(tx_in.sequence.0), + prev_tx: None, + sighash_type: None, + }, + ) + .map_err(|e| { + format!("Input {}: failed to add replay protection input: {}", i, e) + })?; + + if let Some(ecdsa_sig) = sig { + let pk = PublicKey::from(*tx_pubkey); + psbt.psbt_mut().inputs[i] + .partial_sigs + .insert(pk, *ecdsa_sig); + } + } + _ => { + return Err(format!( + "Input {}: mismatch between tx input type and provided unspent type \ + (tx has {}, unspent is {})", + i, + match &input_results[i] { + legacy_txformat::LegacyInputResult::Multisig(_) => "multisig", + legacy_txformat::LegacyInputResult::ReplayProtection { .. } => + "replay protection", + }, + match unspent { + HydrationUnspentInput::Wallet(_) => "wallet", + HydrationUnspentInput::ReplayProtection { .. } => "replay protection", + } + )); + } + } } // Add outputs (plain script+value, no wallet metadata) @@ -4197,7 +4279,7 @@ mod tests { // Step 2: Build unspents from bip32 derivation paths in the PSBT // The derivation path is m// - let unspents: Vec = psbt + let unspents: Vec = psbt .inputs .iter() .enumerate() @@ -4219,11 +4301,11 @@ mod tests { .ok_or_else(|| format!("Input {} has no witnessUtxo", i))? .value .to_sat(); - Ok(ScriptIdWithValue { + Ok(HydrationUnspentInput::Wallet(ScriptIdWithValue { chain, index, value, - }) + })) }) .collect::, String>>()?; diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs index 89bce8b4cee..1d29743a218 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs @@ -13,7 +13,7 @@ pub use checksigverify::{ build_p2tr_ns_script, build_tap_tree_for_output, create_tap_bip32_derivation_for_output, ScriptP2tr, }; -pub use singlesig::{build_p2pk_script, ScriptP2shP2pk}; +pub use singlesig::{build_p2pk_script, parse_p2pk_script, ScriptP2shP2pk}; use crate::address::networks::OutputScriptSupport; use crate::bitcoin::bip32::{ChildNumber, DerivationPath}; 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 b1ae45ca431..bb33fd548f0 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 @@ -12,6 +12,19 @@ pub fn build_p2pk_script(key: CompressedPublicKey) -> ScriptBuf { .into_script() } +/// Parse a bare P2PK script (` OP_CHECKSIG`) and return the pubkey if valid. +/// +/// P2PK format: `0x21 <33-byte compressed pubkey> 0xac` +pub fn parse_p2pk_script(script: &ScriptBuf) -> Option { + let b = script.as_bytes(); + // 0x21 = push 33 bytes, 0xac = OP_CHECKSIG + if b.len() == 35 && b[0] == 0x21 && b[34] == 0xac { + CompressedPublicKey::from_slice(&b[1..34]).ok() + } else { + None + } +} + #[derive(Debug)] pub struct ScriptP2shP2pk { pub redeem_script: ScriptBuf, diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index 5868a49375d..d14e60d724c 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -427,34 +427,58 @@ impl BitGoPsbt { unspents: JsValue, ) -> Result { use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ScriptIdWithValue; + use crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput; let network = parse_network(network)?; let wallet_keys = wallet_keys.inner(); - // Parse the unspents array from JsValue + // Parse the unspents array from JsValue. + // Each element is either: + // { chain: number, index: number, value: bigint } → wallet input + // { value: bigint } → replay protection input + // The presence of `chain` is used to distinguish the two. let arr = js_sys::Array::from(&unspents); let mut parsed_unspents = Vec::with_capacity(arr.length() as usize); for i in 0..arr.length() { let item = arr.get(i); - let chain = js_sys::Reflect::get(&item, &"chain".into()) - .map_err(|_| WasmUtxoError::new("Missing 'chain' field on unspent"))? - .as_f64() - .ok_or_else(|| WasmUtxoError::new("'chain' must be a number"))? - as u32; - let index = js_sys::Reflect::get(&item, &"index".into()) - .map_err(|_| WasmUtxoError::new("Missing 'index' field on unspent"))? - .as_f64() - .ok_or_else(|| WasmUtxoError::new("'index' must be a number"))? - as u32; let value_js = js_sys::Reflect::get(&item, &"value".into()) .map_err(|_| WasmUtxoError::new("Missing 'value' field on unspent"))?; let value = u64::try_from(js_sys::BigInt::unchecked_from_js(value_js)) .map_err(|_| WasmUtxoError::new("'value' must be a bigint convertible to u64"))?; - parsed_unspents.push(ScriptIdWithValue { - chain, - index, - value, - }); + + let chain_val = + js_sys::Reflect::get(&item, &"chain".into()).unwrap_or(JsValue::UNDEFINED); + + if chain_val.is_undefined() { + // No 'chain' property → replay protection input; require pubkey + let pubkey_val = js_sys::Reflect::get(&item, &"pubkey".into()).map_err(|_| { + WasmUtxoError::new("Missing 'pubkey' on replay protection unspent") + })?; + let pubkey_bytes = js_sys::Uint8Array::new(&pubkey_val).to_vec(); + let pubkey = miniscript::bitcoin::CompressedPublicKey::from_slice(&pubkey_bytes) + .map_err(|_| { + WasmUtxoError::new( + "'pubkey' is not a valid compressed public key (33 bytes)", + ) + })?; + parsed_unspents.push(HydrationUnspentInput::ReplayProtection { pubkey, value }); + } else { + // Has 'chain' → wallet input; also parse 'index' + let chain = chain_val + .as_f64() + .ok_or_else(|| WasmUtxoError::new("'chain' must be a number"))? + as u32; + let index = js_sys::Reflect::get(&item, &"index".into()) + .map_err(|_| WasmUtxoError::new("Missing 'index' field on wallet unspent"))? + .as_f64() + .ok_or_else(|| WasmUtxoError::new("'index' must be a number"))? + as u32; + parsed_unspents.push(HydrationUnspentInput::Wallet(ScriptIdWithValue { + chain, + index, + value, + })); + } } let psbt = diff --git a/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts b/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts index e1e30ea1d90..022ea5c98e4 100644 --- a/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts +++ b/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts @@ -15,6 +15,7 @@ import * as utxolib from "@bitgo/utxo-lib"; import { BitGoPsbt, type HydrationUnspent } from "../../js/fixedScriptWallet/BitGoPsbt.js"; import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; import { ChainCode } from "../../js/fixedScriptWallet/chains.js"; +import { ECPair } from "../../js/ecpair.js"; import { getDefaultWalletKeys, getKeyTriple } from "../../js/testutils/keys.js"; import { getCoinNameForNetwork } from "../networks.js"; @@ -128,4 +129,46 @@ describe("BitGoPsbt.fromHalfSignedLegacyTransaction", function () { }); } }); + + describe("Round-trip with replay protection input", function () { + it("reconstructs PSBT from legacy tx with wallet + replay protection input", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const [userXprv] = getKeyTriple("default"); + const ecpair = ECPair.fromPublicKey(rootWalletKeys.userKey().publicKey); + + const psbt = BitGoPsbt.createEmpty("btc", rootWalletKeys, { version: 2, lockTime: 0 }); + psbt.addWalletInput( + { txid: "00".repeat(32), vout: 0, value: BigInt(10000), sequence: 0xfffffffd }, + rootWalletKeys, + { scriptId: { chain: 0, index: 0 } }, + ); + psbt.addReplayProtectionInput( + { txid: "aa".repeat(32), vout: 0, value: BigInt(1000), sequence: 0xfffffffd }, + ecpair, + ); + psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) }); + // sign() only signs wallet inputs; replay protection input gets 0 sigs + psbt.sign(userXprv); + + const txBytes = psbt.getHalfSignedLegacyFormat(); + + const unspents: HydrationUnspent[] = [ + { chain: 0, index: 0, value: BigInt(10000) }, // wallet + { pubkey: ecpair.publicKey, value: BigInt(1000) }, // replay protection + ]; + const reconstructed = BitGoPsbt.fromHalfSignedLegacyTransaction( + txBytes, + "btc", + rootWalletKeys, + unspents, + ); + + assert.ok(reconstructed.serialize().length > 0, "Reconstructed PSBT serializes"); + assert.strictEqual(reconstructed.inputCount(), 2, "Both inputs present"); + assert.ok( + reconstructed.verifySignature(0, rootWalletKeys.userKey().neutered().toBase58()), + "Wallet input signature preserved", + ); + }); + }); }); diff --git a/packages/wasm-utxo/test/fixedScript/halfSignedLegacyFormat.ts b/packages/wasm-utxo/test/fixedScript/halfSignedLegacyFormat.ts index 0e6d6cf80a4..6b7c1c55e4f 100644 --- a/packages/wasm-utxo/test/fixedScript/halfSignedLegacyFormat.ts +++ b/packages/wasm-utxo/test/fixedScript/halfSignedLegacyFormat.ts @@ -7,6 +7,7 @@ import * as utxolib from "@bitgo/utxo-lib"; import { BitGoPsbt } from "../../js/fixedScriptWallet/BitGoPsbt.js"; import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; import { ChainCode } from "../../js/fixedScriptWallet/chains.js"; +import { ECPair } from "../../js/ecpair.js"; import { getDefaultWalletKeys, getKeyTriple } from "../../js/testutils/keys.js"; import { getCoinNameForNetwork } from "../networks.js"; @@ -248,4 +249,36 @@ describe("getHalfSignedLegacyFormat", function () { ); }); }); + + describe("Replay protection inputs", function () { + it("serializes PSBT with wallet + replay protection input to legacy format", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const [userXprv] = getKeyTriple("default"); + const ecpair = ECPair.fromPublicKey(rootWalletKeys.userKey().publicKey); + + const psbt = BitGoPsbt.createEmpty("btc", rootWalletKeys, { version: 2, lockTime: 0 }); + psbt.addWalletInput( + { txid: "00".repeat(32), vout: 0, value: BigInt(10000), sequence: 0xfffffffd }, + rootWalletKeys, + { scriptId: { chain: 0, index: 0 } }, + ); + psbt.addReplayProtectionInput( + { txid: "aa".repeat(32), vout: 0, value: BigInt(1000), sequence: 0xfffffffd }, + ecpair, + ); + psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) }); + // sign() only signs wallet inputs (bip32_derivation-based); replay protection gets 0 sigs + psbt.sign(userXprv); + + const txBytes = psbt.getHalfSignedLegacyFormat(); + assert.ok(txBytes.length > 0, "Should produce non-empty bytes"); + + const tx = utxolib.bitgo.createTransactionFromBuffer( + Buffer.from(txBytes), + utxolib.networks.bitcoin, + { amountType: "bigint" }, + ); + assert.strictEqual(tx.ins.length, 2, "Both inputs (wallet + replay protection) serialized"); + }); + }); });