From 9405b0063806982635294a3d183668b8e6bb5a60 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 23 Apr 2026 16:17:51 +0200 Subject: [PATCH] feat(wasm-utxo): add PSBT fromNetworkFormat Deprecate fromHalfSignedLegacyTransaction in favor of fromNetworkFormat, which accepts unsigned, half-signed, and fully-signed transactions. Signature-count enforcement moves to callers. The new API parses fixed-script inputs (multisig or replay protection) and applies signatures using either positional slot indices (if any OP_0 placeholders exist) or ECDSA verification against wallet pubkeys (if all slots are compact). This enables hydration of finalized transactions that were previously rejected by the legacy parser. For Zcash, use ZcashBitGoPsbt.fromNetworkFormat with block height or explicit consensus branch ID. All legacy variants delegate to the new implementation internally for backward compatibility. Issue: BTC-0 Co-authored-by: llm-git --- .../js/fixedScriptWallet/BitGoPsbt.ts | 48 +- .../js/fixedScriptWallet/ZcashBitGoPsbt.ts | 80 ++- .../bitgo_psbt/fixed_script_input.rs | 235 +++++++ .../bitgo_psbt/legacy_txformat.rs | 158 +---- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 634 +++++++----------- .../bitgo_psbt/zcash_psbt.rs | 122 ++++ .../src/wasm/fixed_script_wallet/mod.rs | 130 +++- .../fromHalfSignedLegacyTransaction.ts | 182 ++++- 8 files changed, 994 insertions(+), 595 deletions(-) create mode 100644 packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/fixed_script_input.rs diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 7bed427c42b..eb08aebb027 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -193,19 +193,22 @@ export class BitGoPsbt extends PsbtBase implements IPsbtWithAddre /** * Convert a half-signed legacy transaction to a psbt-lite. * + * @deprecated Use `fromNetworkFormat()` instead. Signature-count enforcement + * (exactly 1 sig per wallet input) is moving to the caller. + * * Extracts partial signatures from scriptSig/witness and creates a PSBT * with proper wallet metadata (bip32Derivation, scripts, witnessUtxo). * Only supports p2sh, p2shP2wsh, and p2wsh inputs (not taproot). * * Supports both Bitcoin-like coins (BTC, LTC, DOGE) and Dash (DASH). - * Zcash is NOT supported; use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction instead. + * Zcash is NOT supported; use ZcashBitGoPsbt.fromNetworkFormat instead. * * @param txBytesOrTx - Transaction bytes or decoded transaction instance (Bitcoin-like or Dash) * @param network - Network name * @param walletKeys - The wallet's root keys * @param unspents - Chain, index, and value for each input * @param _options - Reserved for future use and signature compatibility with subclasses - * @throws Error if transaction is Zcash (use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction instead) + * @throws Error if transaction is Zcash (use ZcashBitGoPsbt.fromNetworkFormat instead) */ static fromHalfSignedLegacyTransaction( txBytesOrTx: Uint8Array | Transaction | DashTransaction, @@ -243,6 +246,47 @@ export class BitGoPsbt extends PsbtBase implements IPsbtWithAddre return new BitGoPsbt(wasm); } + /** + * Convert a network-format transaction to a PSBT. + * + * Accepts both the half-signed legacy format (5-slot scriptSig/witness with OP_0 + * placeholders) and the fully-signed network format (compact 4-item form). The + * resulting PSBT will contain all partial signatures present in the transaction. + * + * Use this when you don't know ahead of time whether the transaction is half-signed + * or fully-signed. For Zcash, use ZcashBitGoPsbt.fromNetworkFormat() instead. + * + * @param txBytesOrTx - Transaction bytes or decoded Transaction/DashTransaction + * @param network - Network name + * @param walletKeys - The wallet's root keys + * @param unspents - Chain, index, and value for each input + */ + static fromNetworkFormat( + txBytesOrTx: Uint8Array | Transaction | DashTransaction, + network: NetworkName, + walletKeys: WalletKeysArg, + unspents: HydrationUnspent[], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _options?: unknown, + ): BitGoPsbt { + const keys = RootWalletKeys.from(walletKeys); + + const tx = + txBytesOrTx instanceof Uint8Array + ? Transaction.fromBytes(txBytesOrTx, toCoinName(network)) + : txBytesOrTx; + + if (tx instanceof ZcashTransaction) { + throw new Error("Use ZcashBitGoPsbt.fromNetworkFormat() for Zcash transactions"); + } + + const wasm: WasmBitGoPsbt = + tx instanceof DashTransaction + ? WasmBitGoPsbt.from_network_format_dash(tx.wasm, network, keys.wasm, unspents) + : WasmBitGoPsbt.from_network_format(tx.wasm, network, keys.wasm, unspents); + return new BitGoPsbt(wasm); + } + /** * Add an input to the PSBT * diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts index e745da535e3..fc588ecc2ae 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts @@ -145,11 +145,14 @@ export class ZcashBitGoPsbt extends BitGoPsbt { } /** - * Reconstruct a Zcash PSBT from a half-signed legacy transaction + * Reconstruct a Zcash PSBT from a network-format transaction (unsigned, half-signed, or fully-signed). * - * This is the inverse of `getHalfSignedLegacyFormat()` for Zcash. It decodes the Zcash wire - * format (which includes version_group_id, expiry_height, and sapling fields), extracts - * partial signatures, and reconstructs a proper Zcash PSBT with consensus metadata. + * This is the Zcash equivalent of `BitGoPsbt.fromNetworkFormat()`. It decodes the Zcash wire + * format (which includes version_group_id, expiry_height, and sapling fields), extracts any + * partial signatures present, and reconstructs a proper Zcash PSBT with consensus metadata. + * + * Use this as the modern replacement for `fromHalfSignedLegacyTransaction`. Signature-count + * discovery (unsigned / half-signed / fully-signed) is left to the caller. * * Supports two modes for determining consensus_branch_id: * - **Recommended**: Pass `blockHeight` to auto-determine consensus_branch_id via network upgrade activation heights @@ -161,28 +164,53 @@ export class ZcashBitGoPsbt extends BitGoPsbt { * @param unspents - Chain, index, and value for each input * @param options - Either `{ blockHeight: number }` or `{ consensusBranchId: number }` * @returns A ZcashBitGoPsbt instance + */ + static fromNetworkFormat( + txBytesOrTx: Uint8Array | ITransaction, + network: ZcashNetworkName, + walletKeys: WalletKeysArg, + unspents: HydrationUnspent[], + options: { blockHeight: number } | { consensusBranchId: number }, + ): ZcashBitGoPsbt { + const keys = RootWalletKeys.from(walletKeys); + const tx = + txBytesOrTx instanceof Uint8Array + ? ZcashTransaction.fromBytes(txBytesOrTx) + : (txBytesOrTx as ZcashTransaction); + + if ("blockHeight" in options) { + const wasm = WasmBitGoPsbt.from_network_format_zcash_with_block_height( + tx.wasm, + network, + keys.wasm, + unspents, + options.blockHeight, + ); + return new ZcashBitGoPsbt(wasm); + } else { + const wasm = WasmBitGoPsbt.from_network_format_zcash_with_branch_id( + tx.wasm, + network, + keys.wasm, + unspents, + options.consensusBranchId, + ); + return new ZcashBitGoPsbt(wasm); + } + } + + /** + * Reconstruct a Zcash PSBT from a half-signed legacy transaction. * - * @example - * ```typescript - * // Round-trip with block height (recommended) - * const legacyBytes = psbt.getHalfSignedLegacyFormat(); - * const reconstructed = ZcashBitGoPsbt.fromHalfSignedLegacyTransaction( - * legacyBytes, - * "zec", - * walletKeys, - * unspents, - * { blockHeight: 1687105 } // NU5 activation height - * ); + * @deprecated Use `fromNetworkFormat()` instead. Signature-count enforcement + * (exactly 1 sig per wallet input) is moving to the caller. * - * // Or with explicit consensus branch ID - * const reconstructed = ZcashBitGoPsbt.fromHalfSignedLegacyTransaction( - * legacyBytes, - * "zec", - * walletKeys, - * unspents, - * { consensusBranchId: 0xC2D6D0B4 } // NU5 branch ID - * ); - * ``` + * @param txBytesOrTx - Either serialized Zcash transaction bytes or a decoded ZcashTransaction instance + * @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec") + * @param walletKeys - The wallet's root keys + * @param unspents - Chain, index, and value for each input + * @param options - Either `{ blockHeight: number }` or `{ consensusBranchId: number }` + * @returns A ZcashBitGoPsbt instance */ static fromHalfSignedLegacyTransaction( txBytesOrTx: Uint8Array | ITransaction, @@ -198,7 +226,7 @@ export class ZcashBitGoPsbt extends BitGoPsbt { : (txBytesOrTx as ZcashTransaction); if ("blockHeight" in options) { - const wasm = WasmBitGoPsbt.from_half_signed_legacy_transaction_zcash_with_block_height( + const wasm = WasmBitGoPsbt.from_network_format_zcash_with_block_height( tx.wasm, network, keys.wasm, @@ -207,7 +235,7 @@ export class ZcashBitGoPsbt extends BitGoPsbt { ); return new ZcashBitGoPsbt(wasm); } else { - const wasm = WasmBitGoPsbt.from_half_signed_legacy_transaction_zcash_with_branch_id( + const wasm = WasmBitGoPsbt.from_network_format_zcash_with_branch_id( tx.wasm, network, keys.wasm, diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/fixed_script_input.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/fixed_script_input.rs new file mode 100644 index 00000000000..3ad3fb44cad --- /dev/null +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/fixed_script_input.rs @@ -0,0 +1,235 @@ +use crate::fixed_script_wallet::wallet_scripts::{parse_multisig_script_2_of_3, parse_p2pk_script}; +use miniscript::bitcoin::{CompressedPublicKey, Psbt, PublicKey, ScriptBuf, TxIn}; + +/// Coin-specific parameters for computing sighash on compact (no OP_0) inputs. +pub(crate) enum SighashContext { + Bitcoin { + fork_id: Option, + }, + Zcash { + consensus_branch_id: u32, + version_group_id: u32, + expiry_height: u32, + }, +} + +/// The structured content of a fixed-script wallet input (P2SH/P2WSH/P2SH-P2WSH or P2SH-P2PK). +pub(crate) enum FixedScriptInput { + /// 2-of-3 multisig input. + Multisig { + inner_script: ScriptBuf, + /// Sig slots in order. Empty bytes = OP_0 placeholder; non-empty = raw DER sig bytes. + slots: Vec>, + }, + /// P2SH-P2PK replay protection input. + ReplayProtection { + pubkey: CompressedPublicKey, + /// Raw sig bytes, or `None` if the slot is an OP_0 placeholder. + sig_bytes: Option>, + }, +} + +impl FixedScriptInput { + /// Parse a fixed-script wallet input from a transaction input. + /// Does not enforce any minimum slot count — callers decide what's valid. + pub(crate) fn from_txin(tx_in: &TxIn) -> Result { + let has_witness = !tx_in.witness.is_empty(); + let has_script_sig = !tx_in.script_sig.is_empty(); + + let (inner_script, slots) = if has_witness { + let items: Vec<&[u8]> = tx_in.witness.iter().collect(); + if items.len() < 2 { + return Err(format!( + "Expected at least 2 witness items, got {}", + items.len() + )); + } + let inner_script = ScriptBuf::from(items.last().unwrap().to_vec()); + let slots = items[1..items.len() - 1] + .iter() + .map(|s| s.to_vec()) + .collect(); + (inner_script, slots) + } else if has_script_sig { + let instructions: Vec<_> = tx_in + .script_sig + .instructions() + .collect::, _>>() + .map_err(|e| format!("Failed to parse scriptSig: {}", e))?; + if instructions.len() < 2 { + return Err(format!( + "Expected at least 2 scriptSig items, got {}", + instructions.len() + )); + } + let redeem_bytes = match instructions.last().unwrap() { + miniscript::bitcoin::script::Instruction::PushBytes(b) => b.as_bytes().to_vec(), + _ => return Err("Last scriptSig item is not a push".to_string()), + }; + let inner_script = ScriptBuf::from(redeem_bytes); + let slots = instructions[1..instructions.len() - 1] + .iter() + .map(|inst| match inst { + miniscript::bitcoin::script::Instruction::PushBytes(b) => b.as_bytes().to_vec(), + miniscript::bitcoin::script::Instruction::Op(_) => vec![], + }) + .collect(); + (inner_script, slots) + } else { + return Err("Input has neither witness nor scriptSig".to_string()); + }; + + if parse_multisig_script_2_of_3(&inner_script).is_ok() { + Ok(Self::Multisig { + inner_script, + slots, + }) + } else if let Some(pubkey) = parse_p2pk_script(&inner_script) { + let sig_bytes = slots.into_iter().next().filter(|s| !s.is_empty()); + Ok(Self::ReplayProtection { pubkey, sig_bytes }) + } else { + Err( + "scriptSig/witness does not correspond to a known script type \ + (multisig 2-of-3 or P2SH-P2PK)" + .to_string(), + ) + } + } + + /// Insert signatures into `psbt.inputs[index].partial_sigs`. + /// Handles positional (OP_0 placeholders) and compact (no OP_0) cases. + /// For compact inputs the sighash is computed using `ctx`. + pub(crate) fn apply_signatures( + &self, + psbt: &mut Psbt, + index: usize, + ctx: &SighashContext, + ) -> Result<(), String> { + use miniscript::bitcoin::ecdsa::Signature as EcdsaSig; + + match self { + Self::Multisig { + inner_script, + slots, + } => { + let pubkeys = parse_multisig_script_2_of_3(inner_script) + .map_err(|e| format!("Input {}: {}", index, e))?; + + if slots.iter().any(|s| s.is_empty()) { + // Positional: slot j → pubkey j + for (j, slot) in slots.iter().enumerate() { + if slot.is_empty() { + continue; + } + let sig = EcdsaSig::from_slice(slot) + .map_err(|e| format!("Input {}: slot {}: {}", index, j, e))?; + let pk = CompressedPublicKey::from_slice(&pubkeys[j].to_bytes()) + .map_err(|e| format!("Input {}: {}", index, e))?; + psbt.inputs[index] + .partial_sigs + .insert(PublicKey::from(pk), sig); + } + } else { + // Compact: ECDSA verify against coin-specific sighash + let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only(); + let candidate_pks: Vec = psbt.inputs + [index] + .bip32_derivation + .keys() + .cloned() + .collect(); + let message = Self::compute_compact_sighash(psbt, index, ctx)?; + for slot in slots.iter().filter(|s| !s.is_empty()) { + let sig = EcdsaSig::from_slice(slot) + .map_err(|e| format!("Input {}: {}", index, e))?; + let matched_pk = candidate_pks + .iter() + .find(|pk| secp.verify_ecdsa(&message, &sig.signature, pk).is_ok()) + .ok_or_else(|| { + format!("Input {}: sig doesn't match any wallet pubkey", index) + })?; + psbt.inputs[index] + .partial_sigs + .insert(PublicKey::new(*matched_pk), sig); + } + } + } + Self::ReplayProtection { pubkey, sig_bytes } => { + if let Some(bytes) = sig_bytes { + let sig = EcdsaSig::from_slice(bytes) + .map_err(|e| format!("Input {}: {}", index, e))?; + psbt.inputs[index] + .partial_sigs + .insert(PublicKey::from(*pubkey), sig); + } + } + } + Ok(()) + } + + fn compute_compact_sighash( + psbt: &Psbt, + index: usize, + ctx: &SighashContext, + ) -> Result { + use miniscript::bitcoin::sighash::SighashCache; + + let mut cache = SighashCache::new(&psbt.unsigned_tx); + + match ctx { + SighashContext::Bitcoin { fork_id } => { + if let Some(fid) = fork_id { + psbt.sighash_forkid(index, &mut cache, *fid) + .map(|(msg, _)| msg) + .map_err(|e| format!("Input {}: FORKID sighash: {}", index, e)) + } else { + psbt.sighash_ecdsa(index, &mut cache) + .map(|(msg, _)| msg) + .map_err(|e| format!("Input {}: sighash: {}", index, e)) + } + } + SighashContext::Zcash { + consensus_branch_id, + version_group_id, + expiry_height, + } => { + use miniscript::bitcoin::sighash::SighashCacheZcashExt; + let prevout = psbt.unsigned_tx.input[index].previous_output; + let value = + crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::get_output_script_and_value( + &psbt.inputs[index], prevout, + ) + .map(|(_, v)| v) + .unwrap_or(miniscript::bitcoin::Amount::ZERO); + let script = psbt.inputs[index] + .witness_script + .as_ref() + .or(psbt.inputs[index].redeem_script.as_ref()) + .ok_or_else(|| format!("Input {}: no redeem/witness script", index))?; + cache + .p2sh_signature_hash_zcash( + index, + script, + value, + 0x01u32, + *consensus_branch_id, + *version_group_id, + *expiry_height, + ) + .map(|h| { + miniscript::bitcoin::secp256k1::Message::from_digest(h.to_byte_array()) + }) + .map_err(|e| format!("Input {}: Zcash sighash: {}", index, e)) + } + } + } + + /// Parse all inputs from a transaction. + pub(crate) fn parse_all(tx: &miniscript::bitcoin::Transaction) -> Result, String> { + tx.input + .iter() + .enumerate() + .map(|(i, tx_in)| Self::from_txin(tx_in).map_err(|e| format!("Input {}: {}", i, e))) + .collect() + } +} 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 2f78677eac4..ecbeb295669 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 @@ -7,10 +7,9 @@ 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; use miniscript::bitcoin::psbt::Psbt; use miniscript::bitcoin::script::PushBytesBuf; -use miniscript::bitcoin::{CompressedPublicKey, ScriptBuf, Transaction, TxIn, Witness}; +use miniscript::bitcoin::{Transaction, Witness}; /// Build a half-signed transaction in legacy format from a PSBT. /// @@ -185,158 +184,3 @@ pub fn build_half_signed_legacy_tx(psbt: &Psbt) -> Result { Ok(tx) } - -/// A partial signature extracted from a legacy half-signed input. -pub struct LegacyPartialSig { - pub pubkey: CompressedPublicKey, - 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). -/// -/// Returns `(is_p2sh, is_segwit, multisig_script)`. -fn classify_legacy_input(tx_in: &TxIn) -> Result<(bool, bool, ScriptBuf), String> { - let has_witness = !tx_in.witness.is_empty(); - let has_script_sig = !tx_in.script_sig.is_empty(); - - if has_witness { - // Segwit: witness contains [empty, sig0?, sig1?, sig2?, witnessScript] - let witness_items: Vec<&[u8]> = tx_in.witness.iter().collect(); - if witness_items.len() < 5 { - return Err(format!( - "Expected at least 5 witness items, got {}", - witness_items.len() - )); - } - let multisig_script = ScriptBuf::from(witness_items.last().unwrap().to_vec()); - let is_p2sh = has_script_sig; // p2shP2wsh has scriptSig, p2wsh does not - Ok((is_p2sh, true, multisig_script)) - } else if has_script_sig { - // 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() < 2 { - return Err(format!( - "Expected at least 2 scriptSig items, got {}", - instructions.len() - )); - } - let last = instructions.last().unwrap(); - 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(redeem_bytes.to_vec()))) - } else { - Err("Input has neither witness nor scriptSig".to_string()) - } -} - -/// 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`. -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 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) - } - _ => None, // OP_0 or empty push = unsigned placeholder - }; - - 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 20e6779e977..b25bc4bac02 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 @@ -8,7 +8,7 @@ mod legacy_txformat; pub mod p2tr_musig2_input; #[cfg(test)] mod p2tr_musig2_input_utxolib; -mod propkv; +pub(crate) mod propkv; pub mod psbt_wallet_input; pub mod psbt_wallet_output; mod sighash; @@ -103,17 +103,6 @@ pub enum BitGoPsbt { Zcash(ZcashBitGoPsbt, Network), } -/// Options for creating a new Zcash PSBT -#[derive(Debug, Clone, Copy)] -struct ZcashNewOptions { - /// Zcash consensus branch ID (required for sighash computation) - consensus_branch_id: u32, - /// Version group ID (defaults to Sapling: 0x892F2085) - version_group_id: Option, - /// Transaction expiry height - expiry_height: Option, -} - // Re-export types from submodules for convenience pub use psbt_wallet_input::{ InputScriptType, ParsedInput, ReplayProtectionOptions, ScriptId, WalletInputOptions, @@ -296,6 +285,39 @@ pub(crate) fn create_tap_bip32_derivation( map } +mod fixed_script_input; +pub(crate) use fixed_script_input::{FixedScriptInput, SighashContext}; + +/// Build a base Psbt (empty transaction + xpubs). Shared by `BitGoPsbt::new` and +/// `ZcashBitGoPsbt::new`. +pub(crate) fn make_psbt_with_xpubs( + version: i32, + lock_time: u32, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, +) -> Psbt { + use miniscript::bitcoin::{ + absolute::LockTime, bip32::DerivationPath, transaction::Version, Transaction, + }; + use std::collections::BTreeMap; + use std::str::FromStr; + + let tx = Transaction { + version: Version(version), + lock_time: LockTime::from_consensus(lock_time), + input: vec![], + output: vec![], + }; + let mut psbt = Psbt::from_unsigned_tx(tx).expect("empty transaction is valid"); + let mut xpub_map = BTreeMap::new(); + for xpub in &wallet_keys.xpubs { + let fingerprint = xpub.fingerprint(); + let path = DerivationPath::from_str("m").expect("'m' is a valid path"); + xpub_map.insert(*xpub, (fingerprint, path)); + } + psbt.xpub = xpub_map; + psbt +} + impl BitGoPsbt { /// Deserialize a PSBT from bytes, using network-specific logic pub fn deserialize(psbt_bytes: &[u8], network: Network) -> Result { @@ -338,8 +360,7 @@ impl BitGoPsbt { /// Create an empty PSBT with the given network and wallet keys /// - /// For Zcash networks, use [`BitGoPsbt::new_zcash`] instead which requires - /// the consensus branch ID. + /// For Zcash networks, use `BitGoPsbt::new_zcash` instead. /// /// # Arguments /// * `network` - The network this PSBT is for (must not be Zcash) @@ -348,7 +369,7 @@ impl BitGoPsbt { /// * `lock_time` - Lock time (default: 0) /// /// # Panics - /// Panics if called with a Zcash network. Use `new_zcash` instead. + /// Panics if called with a Zcash network. Use `BitGoPsbt::new_zcash` instead. pub fn new( network: Network, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, @@ -356,27 +377,13 @@ impl BitGoPsbt { lock_time: Option, ) -> Self { if matches!(network, Network::Zcash | Network::ZcashTestnet) { - panic!( - "Use BitGoPsbt::new_zcash() for Zcash networks - consensus_branch_id is required" - ); + panic!("Use BitGoPsbt::new_zcash() for Zcash networks"); } - - Self::new_internal(network, wallet_keys, version, lock_time, None) + let psbt = make_psbt_with_xpubs(version.unwrap_or(2), lock_time.unwrap_or(0), wallet_keys); + BitGoPsbt::BitcoinLike(psbt, network) } - /// Create an empty Zcash PSBT with the required consensus branch ID - /// - /// # Arguments - /// * `network` - The Zcash network (Zcash or ZcashTestnet) - /// * `wallet_keys` - The wallet's root keys (used to set global xpubs) - /// * `consensus_branch_id` - The Zcash consensus branch ID (e.g., 0xC2D6D0B4 for NU5) - /// * `version` - Transaction version (default: 4 for Zcash Sapling+) - /// * `lock_time` - Lock time (default: 0) - /// * `version_group_id` - Optional version group ID (defaults to Sapling: 0x892F2085) - /// * `expiry_height` - Optional expiry height - /// - /// # Panics - /// Panics if called with a non-Zcash network. + /// Create an empty Zcash PSBT. Delegates to `ZcashBitGoPsbt::new`. pub fn new_zcash( network: Network, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, @@ -386,45 +393,22 @@ impl BitGoPsbt { version_group_id: Option, expiry_height: Option, ) -> Self { - if !matches!(network, Network::Zcash | Network::ZcashTestnet) { - panic!("new_zcash() can only be used with Zcash networks"); - } - - let zcash_options = ZcashNewOptions { - consensus_branch_id, - version_group_id, - expiry_height, - }; - - Self::new_internal( + BitGoPsbt::Zcash( + ZcashBitGoPsbt::new( + network, + wallet_keys, + consensus_branch_id, + version, + lock_time, + version_group_id, + expiry_height, + ), network, - wallet_keys, - Some(version.unwrap_or(4)), // Zcash Sapling+ uses version 4 - lock_time, - Some(zcash_options), ) } - /// Create an empty Zcash PSBT with consensus branch ID determined from block height - /// - /// This method automatically determines the correct consensus branch ID based on - /// the network and block height using the network upgrade activation heights. - /// - /// # Arguments - /// * `network` - The Zcash network (Zcash or ZcashTestnet) - /// * `wallet_keys` - The wallet's root keys (used to set global xpubs) - /// * `block_height` - Block height to determine consensus rules - /// * `version` - Transaction version (default: 4 for Zcash Sapling+) - /// * `lock_time` - Lock time (default: 0) - /// * `version_group_id` - Optional version group ID (defaults to Sapling: 0x892F2085) - /// * `expiry_height` - Optional expiry height - /// - /// # Returns - /// * `Ok(Self)` - Successfully created PSBT with appropriate consensus branch ID - /// * `Err(String)` - If the block height is before Overwinter activation - /// - /// # Panics - /// Panics if called with a non-Zcash network. + /// Create an empty Zcash PSBT with consensus branch ID resolved from block height. + /// Delegates to `ZcashBitGoPsbt::new_at_height`. #[allow(clippy::too_many_arguments)] pub fn new_zcash_at_height( network: Network, @@ -435,32 +419,17 @@ impl BitGoPsbt { version_group_id: Option, expiry_height: Option, ) -> Result { - if !matches!(network, Network::Zcash | Network::ZcashTestnet) { - panic!("new_zcash_at_height() can only be used with Zcash networks"); - } - - // Determine if this is mainnet or testnet - let is_mainnet = matches!(network, Network::Zcash); - - // Get the consensus branch ID for this block height - let consensus_branch_id = crate::zcash::branch_id_for_height(block_height, is_mainnet) - .ok_or_else(|| { - format!( - "Block height {} is before Overwinter activation on {}", - block_height, - if is_mainnet { "mainnet" } else { "testnet" } - ) - })?; - - // Call the existing new_zcash with the computed branch ID - Ok(Self::new_zcash( + Ok(BitGoPsbt::Zcash( + ZcashBitGoPsbt::new_at_height( + network, + wallet_keys, + block_height, + version, + lock_time, + version_group_id, + expiry_height, + )?, network, - wallet_keys, - consensus_branch_id, - version, - lock_time, - version_group_id, - expiry_height, )) } @@ -487,18 +456,20 @@ impl BitGoPsbt { let lock_time = template.psbt().unsigned_tx.lock_time.to_consensus_u32(); match template { - BitGoPsbt::Zcash(zcash_psbt, _) => { - // For Zcash, extract all required parameters from the template - let consensus_branch_id = propkv::get_zec_consensus_branch_id(&zcash_psbt.psbt) + BitGoPsbt::Zcash(z, _) => { + let branch_id = propkv::get_zec_consensus_branch_id(&z.psbt) .ok_or("Template PSBT missing ZecConsensusBranchId")?; - Ok(Self::new_zcash( + Ok(BitGoPsbt::Zcash( + ZcashBitGoPsbt::new( + network, + wallet_keys, + branch_id, + Some(version), + Some(lock_time), + z.version_group_id, + z.expiry_height, + ), network, - wallet_keys, - consensus_branch_id, - Some(version), - Some(lock_time), - zcash_psbt.version_group_id, - zcash_psbt.expiry_height, )) } _ => Ok(Self::new( @@ -510,115 +481,16 @@ impl BitGoPsbt { } } - /// Convert a half-signed legacy transaction to a psbt-lite. - /// - /// This is the inverse of `get_half_signed_legacy_format()`. It parses the - /// legacy transaction, extracts partial signatures from scriptSig/witness, - /// creates a PSBT with proper wallet metadata (bip32Derivation, scripts, - /// witnessUtxo), and inserts the extracted signatures. - /// - /// 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: &[HydrationUnspentInput], - ) -> Result { - use miniscript::bitcoin::consensus::Decodable; - use miniscript::bitcoin::Transaction; - - let tx = Transaction::consensus_decode(&mut &tx_bytes[..]) - .map_err(|e| format!("Failed to decode transaction: {}", e))?; - - let version = tx.version.0; - let lock_time = tx.lock_time.to_consensus_u32(); - - let mut psbt = Self::new(network, wallet_keys, Some(version), Some(lock_time)); - - Self::hydrate_psbt(&mut psbt, &tx, wallet_keys, unspents)?; - - Ok(psbt) - } - - /// Convert a half-signed legacy Zcash transaction to a PSBT with Zcash metadata. - /// - /// This is the Zcash-specific inverse of `get_half_signed_legacy_format()`. - /// It decodes the Zcash wire format (which includes version_group_id, expiry_height, sapling_fields), - /// extracts partial signatures, and reconstructs a Zcash PSBT. - /// - /// Unlike `from_half_signed_legacy_transaction`, this requires a `block_height` to determine - /// the correct `consensus_branch_id` via network upgrade activation height lookup. - /// - /// # Arguments - /// * `tx_bytes` - Zcash transaction bytes (overwintered format) - /// * `network` - Zcash network (Zcash or ZcashTestnet) - /// * `wallet_keys` - The wallet's root keys - /// * `unspents` - Chain, index, value for each input - /// * `block_height` - Block height to determine consensus branch ID - /// - /// # Returns - /// Thin wrapper over `from_half_signed_legacy_transaction_zcash_with_consensus_branch_id`. - /// Resolves consensus_branch_id from block height. - /// Convert a half-signed legacy Zcash transaction to a PSBT with explicit consensus branch ID. - /// - /// This is similar to `from_half_signed_legacy_transaction_zcash`, but takes an explicit - /// `consensus_branch_id` instead of deriving it from block height. Use this when you - /// already know the consensus branch ID (e.g., 0xC2D6D0B4 for NU5, 0x76B809BB for Sapling). - /// - /// # Arguments - /// * `tx_bytes` - Zcash transaction bytes (overwintered format) - /// * `network` - Zcash network (Zcash or ZcashTestnet) - /// * `wallet_keys` - The wallet's root keys - /// * `unspents` - Chain, index, value for each input - /// * `consensus_branch_id` - Explicit consensus branch ID for sighash computation - /// - /// # Returns - /// A BitGoPsbt::Zcash instance with restored Sapling fields - /// Helper that accepts already-decoded Zcash transaction parts (avoiding re-parsing). - pub fn from_half_signed_legacy_transaction_zcash_with_consensus_branch_id_from_parts( - parts: &crate::zcash::transaction::ZcashTransactionParts, + /// Add inputs and outputs from `tx`/`unspents` into a raw `Psbt`. + /// Shared by `from_tx_parts` (bitcoin-like) and `ZcashBitGoPsbt::from_tx_parts`. + /// Does not insert any signatures. + pub(crate) fn hydrate_psbt( + psbt: &mut Psbt, network: Network, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, - unspents: &[HydrationUnspentInput], - consensus_branch_id: u32, - ) -> Result { - let tx = &parts.transaction; - let version = tx.version.0; - let lock_time = tx.lock_time.to_consensus_u32(); - - // Create Zcash PSBT using explicit consensus_branch_id - let mut psbt = Self::new_zcash( - network, - wallet_keys, - consensus_branch_id, - Some(version), - Some(lock_time), - parts.version_group_id, - parts.expiry_height, - ); - - Self::hydrate_psbt(&mut psbt, tx, wallet_keys, unspents)?; - - // Restore Sapling fields in the Zcash PSBT variant - if let BitGoPsbt::Zcash(ref mut zcash_psbt, _) = psbt { - zcash_psbt.sapling_fields = parts.sapling_fields.clone(); - } - - Ok(psbt) - } - - /// Helper that accepts already-decoded Zcash transaction parts (avoiding re-parsing). - /// Private helper: hydrate inputs and outputs in an already-created PSBT. - /// Shared logic for both Bitcoin-like and Zcash variants. - fn hydrate_psbt( - psbt: &mut BitGoPsbt, tx: &miniscript::bitcoin::Transaction, - wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, unspents: &[HydrationUnspentInput], ) -> Result<(), String> { - use miniscript::bitcoin::PublicKey; - - // Validate input count if tx.input.len() != unspents.len() { return Err(format!( "Input count mismatch: tx has {} inputs, got {} unspents", @@ -627,29 +499,17 @@ impl BitGoPsbt { )); } - // Parse each input from the legacy tx - let input_results: Vec = tx - .input - .iter() - .enumerate() - .map(|(i, tx_in)| { - legacy_txformat::parse_legacy_input(tx_in) - .map_err(|e| format!("Input {}: {}", i, e)) - }) - .collect::, _>>()?; - - // Hydrate inputs for (i, (tx_in, unspent)) in tx.input.iter().zip(unspents.iter()).enumerate() { - match (&input_results[i], unspent) { - ( - legacy_txformat::LegacyInputResult::Multisig(sig), - HydrationUnspentInput::Wallet(sv), - ) => { + match unspent { + HydrationUnspentInput::Wallet(sv) => { let script_id = psbt_wallet_input::ScriptId { chain: sv.chain, index: sv.index, }; - psbt.add_wallet_input( + Self::add_wallet_input_to_psbt( + psbt, + i, + network, tx_in.previous_output.txid, tx_in.previous_output.vout, sv.value, @@ -661,148 +521,126 @@ impl BitGoPsbt { prev_tx: None, }, ) - .map_err(|e| format!("Input {}: failed to add wallet input: {}", i, e))?; - - let pubkey = PublicKey::from(sig.pubkey); - psbt.psbt_mut().inputs[i] - .partial_sigs - .insert(pubkey, sig.sig); + .map_err(|e| format!("Input {}: {}", i, e))?; } - ( - 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 {}", + HydrationUnspentInput::ReplayProtection { + pubkey: expected_pubkey, + value, + } => { + // Validate pubkey matches what's in the transaction + let parsed = FixedScriptInput::from_txin(tx_in) + .map_err(|e| format!("Input {}: {}", i, e))?; + if let FixedScriptInput::ReplayProtection { + pubkey: tx_pubkey, .. + } = &parsed + { + if tx_pubkey.to_bytes() != expected_pubkey.to_bytes() { + return Err(format!("Input {}: replay protection pubkey mismatch", i)); + } + Self::add_replay_protection_input_to_psbt( + psbt, 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); + network, + *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 {}: {}", i, e))?; + } else { + return Err(format!("Input {}: expected replay protection input", i)); } } - _ => { - 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 for tx_out in &tx.output { - psbt.add_output(tx_out.script_pubkey.clone(), tx_out.value.to_sat()); + let index = psbt.unsigned_tx.output.len(); + crate::psbt_ops::insert_output( + psbt, + index, + tx_out.clone(), + miniscript::bitcoin::psbt::Output::default(), + ) + .map_err(|e| format!("Failed to add output: {}", e))?; } Ok(()) } - fn new_internal( + pub(crate) fn validate_half_signed(psbt: &BitGoPsbt) -> Result<(), String> { + for (i, input) in psbt.psbt().inputs.iter().enumerate() { + if input.bip32_derivation.len() >= 3 { + let n = input.partial_sigs.len(); + if n != 1 { + return Err(format!( + "Input {}: expected 1 signature for half-signed transaction, found {}", + i, n + )); + } + } + } + Ok(()) + } + + /// Convert a network-format transaction (0, 1, or 2 signatures per input) to a PSBT. + /// + /// Accepts any fixed-script wallet transaction format: + /// - Unsigned: `[OP_0, OP_0, OP_0, OP_0, redeemScript]` + /// - Half-signed: `[OP_0, sig, OP_0, OP_0, redeemScript]` + /// - Full-signed: `[OP_0, sig_a, sig_b, redeemScript]` + pub fn from_network_format( + tx_bytes: &[u8], network: Network, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, - version: Option, - lock_time: Option, - zcash_options: Option, - ) -> Self { - use miniscript::bitcoin::{ - absolute::LockTime, bip32::DerivationPath, transaction::Version, Transaction, - }; - use std::collections::BTreeMap; - use std::str::FromStr; - - let tx = Transaction { - version: Version(version.unwrap_or(2)), - lock_time: LockTime::from_consensus(lock_time.unwrap_or(0)), - input: vec![], - output: vec![], - }; + unspents: &[HydrationUnspentInput], + ) -> Result { + use miniscript::bitcoin::consensus::Decodable; + use miniscript::bitcoin::Transaction; - let mut psbt = Psbt::from_unsigned_tx(tx).expect("empty transaction should be valid"); + let tx = Transaction::consensus_decode(&mut &tx_bytes[..]) + .map_err(|e| format!("Failed to decode transaction: {}", e))?; - // Set global xpubs from wallet keys - // Each xpub is mapped to (master_fingerprint, derivation_path) - // We use 'm' as the path since these are the root wallet keys - let mut xpub_map = BTreeMap::new(); - for xpub in &wallet_keys.xpubs { - let fingerprint = xpub.fingerprint(); - let path = DerivationPath::from_str("m").expect("'m' is a valid path"); - xpub_map.insert(*xpub, (fingerprint, path)); + let inputs = FixedScriptInput::parse_all(&tx)?; + let mut psbt = Self::from_tx_parts(network, wallet_keys, &tx, unspents)?; + for (i, input) in inputs.iter().enumerate() { + psbt.add_input_signatures(i, input)?; } - psbt.xpub = xpub_map; - - match network { - Network::Zcash | Network::ZcashTestnet => { - let opts = zcash_options.expect("ZcashNewOptions required for Zcash networks"); - - // Store consensus branch ID in PSBT proprietary map - propkv::set_zec_consensus_branch_id(&mut psbt, opts.consensus_branch_id); + Ok(psbt) + } - // Initialize sapling_fields for transparent-only transactions: - // valueBalance (8 bytes, 0) + nShieldedSpend (1 byte, 0) + - // nShieldedOutput (1 byte, 0) + nJoinSplit (1 byte, 0) - let sapling_fields = vec![0u8; 11]; + /// Assemble a PSBT from a transaction and unspents — no signatures. + pub fn from_tx_parts( + network: Network, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + tx: &miniscript::bitcoin::Transaction, + unspents: &[HydrationUnspentInput], + ) -> Result { + let mut psbt = Self::new( + network, + wallet_keys, + Some(tx.version.0), + Some(tx.lock_time.to_consensus_u32()), + ); + Self::hydrate_psbt(psbt.psbt_mut(), network, wallet_keys, tx, unspents)?; + Ok(psbt) + } - BitGoPsbt::Zcash( - ZcashBitGoPsbt { - psbt, - network, - version_group_id: opts.version_group_id, - expiry_height: opts.expiry_height, - sapling_fields, - }, - network, - ) - } - _ => BitGoPsbt::BitcoinLike(psbt, network), - } + /// Insert signatures from a parsed `FixedScriptInput` into this PSBT at `index`. + pub(crate) fn add_input_signatures( + &mut self, + index: usize, + input: &FixedScriptInput, + ) -> Result<(), String> { + let ctx = SighashContext::Bitcoin { + fork_id: sighash::get_sighash_fork_id(self.network()), + }; + input.apply_signatures(self.psbt_mut(), index, &ctx) } /// Add an input to the PSBT @@ -877,16 +715,18 @@ impl BitGoPsbt { /// * `options` - Optional parameters (sequence, sighash_type, prev_tx) /// /// # Returns - /// The index of the newly added input - pub fn add_replay_protection_input_at_index( - &mut self, + /// Add a replay protection input directly to a raw `Psbt`. + #[allow(clippy::too_many_arguments)] + pub(crate) fn add_replay_protection_input_to_psbt( + psbt: &mut Psbt, index: usize, + network: Network, pubkey: miniscript::bitcoin::CompressedPublicKey, txid: Txid, vout: u32, value: u64, options: ReplayProtectionOptions, - ) -> Result { + ) -> Result<(), String> { use crate::fixed_script_wallet::wallet_scripts::ScriptP2shP2pk; use miniscript::bitcoin::consensus::Decodable; use miniscript::bitcoin::psbt::{Input, PsbtSighashType}; @@ -894,8 +734,6 @@ impl BitGoPsbt { transaction::Sequence, Amount, OutPoint, Transaction, TxIn, TxOut, }; - let network = self.network(); - let script = ScriptP2shP2pk::new(pubkey); let output_script = script.output_script(); let redeem_script = script.redeem_script; @@ -907,7 +745,6 @@ impl BitGoPsbt { witness: miniscript::bitcoin::Witness::default(), }; - // Networks with SIGHASH_FORKID use SIGHASH_ALL | SIGHASH_FORKID (0x41) let sighash_type = options .sighash_type .unwrap_or_else(|| match network.mainnet() { @@ -935,7 +772,30 @@ impl BitGoPsbt { }); } - crate::psbt_ops::insert_input(self.psbt_mut(), index, tx_in, psbt_input) + crate::psbt_ops::insert_input(psbt, index, tx_in, psbt_input).map(|_| ()) + } + + pub fn add_replay_protection_input_at_index( + &mut self, + index: usize, + pubkey: miniscript::bitcoin::CompressedPublicKey, + txid: Txid, + vout: u32, + value: u64, + options: ReplayProtectionOptions, + ) -> Result { + let network = self.network(); + Self::add_replay_protection_input_to_psbt( + self.psbt_mut(), + index, + network, + pubkey, + txid, + vout, + value, + options, + )?; + Ok(index) } pub fn add_replay_protection_input( @@ -1019,16 +879,19 @@ impl BitGoPsbt { /// # Returns /// The index of the newly added input #[allow(clippy::too_many_arguments)] - pub fn add_wallet_input_at_index( - &mut self, + /// Add a wallet input at a specific index directly to a raw `Psbt`. + /// Used by `add_wallet_input_at_index` and by `hydrate_psbt`. + pub(crate) fn add_wallet_input_to_psbt( + psbt: &mut Psbt, index: usize, + network: Network, txid: Txid, vout: u32, value: u64, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, script_id: psbt_wallet_input::ScriptId, options: WalletInputOptions, - ) -> Result { + ) -> Result<(), String> { use crate::fixed_script_wallet::to_pub_triple; use crate::fixed_script_wallet::wallet_scripts::{Chain, OutputScriptType, WalletScripts}; use miniscript::bitcoin::psbt::Input; @@ -1037,9 +900,6 @@ impl BitGoPsbt { use p2tr_musig2_input::Musig2Participants; use std::convert::TryFrom; - let network = self.network(); - let psbt = self.psbt_mut(); - let chain = script_id.chain; let derivation_index = script_id.index; @@ -1170,7 +1030,33 @@ impl BitGoPsbt { } } - crate::psbt_ops::insert_input(psbt, index, tx_in, psbt_input) + crate::psbt_ops::insert_input(psbt, index, tx_in, psbt_input).map(|_| ()) + } + + #[allow(clippy::too_many_arguments)] + pub fn add_wallet_input_at_index( + &mut self, + index: usize, + txid: Txid, + vout: u32, + value: u64, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + script_id: psbt_wallet_input::ScriptId, + options: WalletInputOptions, + ) -> Result { + let network = self.network(); + Self::add_wallet_input_to_psbt( + self.psbt_mut(), + index, + network, + txid, + vout, + value, + wallet_keys, + script_id, + options, + )?; + Ok(index) } pub fn add_wallet_input( @@ -1501,11 +1387,13 @@ impl BitGoPsbt { match self { BitGoPsbt::BitcoinLike(_, _) | BitGoPsbt::Dash(_, _) => { - let tx = legacy_txformat::build_half_signed_legacy_tx(self.psbt())?; + let tx = legacy_txformat::build_half_signed_legacy_tx(self.psbt()) + .map_err(|e| e.to_string())?; Ok(serialize(&tx)) } BitGoPsbt::Zcash(zcash_psbt, _) => { - let tx = legacy_txformat::build_half_signed_legacy_tx(&zcash_psbt.psbt)?; + let tx = legacy_txformat::build_half_signed_legacy_tx(&zcash_psbt.psbt) + .map_err(|e| e.to_string())?; // Serialize with Zcash-specific fields let parts = crate::zcash::transaction::ZcashTransactionParts { @@ -3433,9 +3321,7 @@ mod tests { None, ); assert!(result.is_ok(), "Should succeed for Nu5 height"); - let psbt = result.unwrap(); - // Verify it's a Zcash PSBT - assert!(matches!(psbt, BitGoPsbt::Zcash(_, Network::Zcash))); + let _ = result.unwrap(); // Test with Nu6 activation height let nu6_height = NetworkUpgrade::Nu6.mainnet_activation_height(); @@ -3487,9 +3373,7 @@ mod tests { None, ); assert!(result.is_ok(), "Should succeed for Nu5 height on testnet"); - let psbt = result.unwrap(); - // Verify it's a Zcash testnet PSBT - assert!(matches!(psbt, BitGoPsbt::Zcash(_, Network::ZcashTestnet))); + let _ = result.unwrap(); // Test with pre-Overwinter height (should fail) let pre_overwinter_height = NetworkUpgrade::Overwinter.testnet_activation_height() - 1; @@ -4418,13 +4302,9 @@ mod tests { .collect::, String>>()?; // Step 3: Convert back to PSBT - let reconverted = BitGoPsbt::from_half_signed_legacy_transaction( - &legacy_bytes, - network, - &wallet_keys, - &unspents, - ) - .map_err(|e| format!("from_half_signed_legacy_transaction failed: {}", e))?; + let reconverted = + BitGoPsbt::from_network_format(&legacy_bytes, network, &wallet_keys, &unspents) + .map_err(|e| format!("from_network_format failed: {}", e))?; // Verify: same number of inputs/outputs let orig_psbt = bitgo_psbt.psbt(); diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs index 9aaa118df96..96717afbf2f 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs @@ -32,11 +32,104 @@ pub struct ZcashBitGoPsbt { } impl ZcashBitGoPsbt { + /// Create an empty Zcash PSBT directly without going through `BitGoPsbt`. + pub(crate) fn new( + network: crate::Network, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + consensus_branch_id: u32, + version: Option, + lock_time: Option, + version_group_id: Option, + expiry_height: Option, + ) -> Self { + let mut psbt = + super::make_psbt_with_xpubs(version.unwrap_or(4), lock_time.unwrap_or(0), wallet_keys); + super::propkv::set_zec_consensus_branch_id(&mut psbt, consensus_branch_id); + Self { + psbt, + network, + version_group_id, + expiry_height, + sapling_fields: vec![0u8; 11], + } + } + + /// Create an empty Zcash PSBT with consensus branch ID resolved from `block_height`. + pub(crate) fn new_at_height( + network: crate::Network, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + block_height: u32, + version: Option, + lock_time: Option, + version_group_id: Option, + expiry_height: Option, + ) -> Result { + let is_mainnet = matches!(network, crate::Network::Zcash); + let consensus_branch_id = crate::zcash::branch_id_for_height(block_height, is_mainnet) + .ok_or_else(|| { + format!( + "Block height {} is before Overwinter activation on {}", + block_height, + if is_mainnet { "mainnet" } else { "testnet" } + ) + })?; + Ok(Self::new( + network, + wallet_keys, + consensus_branch_id, + version, + lock_time, + version_group_id, + expiry_height, + )) + } + /// Get the network this PSBT is for pub fn network(&self) -> crate::Network { self.network } + /// Assemble a Zcash PSBT from a transaction and unspents — no signatures. + pub fn from_tx_parts( + network: crate::Network, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + tx: &miniscript::bitcoin::Transaction, + unspents: &[super::HydrationUnspentInput], + consensus_branch_id: u32, + version_group_id: Option, + expiry_height: Option, + ) -> Result { + let mut z = Self::new( + network, + wallet_keys, + consensus_branch_id, + Some(tx.version.0), + Some(tx.lock_time.to_consensus_u32()), + version_group_id, + expiry_height, + ); + super::BitGoPsbt::hydrate_psbt(&mut z.psbt, network, wallet_keys, tx, unspents)?; + Ok(z) + } + + /// Insert signatures from a parsed `FixedScriptInput` into this Zcash PSBT at `index`. + pub(crate) fn add_input_signatures( + &mut self, + index: usize, + input: &super::FixedScriptInput, + ) -> Result<(), String> { + let branch_id = super::propkv::get_zec_consensus_branch_id(&self.psbt) + .ok_or_else(|| "missing consensus_branch_id".to_string())?; + let ctx = super::SighashContext::Zcash { + consensus_branch_id: branch_id, + version_group_id: self + .version_group_id + .unwrap_or(ZCASH_SAPLING_VERSION_GROUP_ID), + expiry_height: self.expiry_height.unwrap_or(0), + }; + input.apply_signatures(&mut self.psbt, index, &ctx) + } + /// Serialize a transaction with Zcash-specific fields (version_group_id, expiry_height, sapling_fields) fn serialize_as_zcash_transaction( &self, @@ -384,6 +477,35 @@ impl ZcashBitGoPsbt { Ok(result) } + /// Convert a network-format Zcash transaction (0, 1, or 2 sigs) to a `ZcashBitGoPsbt`. + /// + /// Accepts unsigned, half-signed, and fully-signed Zcash transactions. The caller is + /// responsible for checking the signature count if a specific signing state is required. + pub fn from_network_format( + parts: &crate::zcash::transaction::ZcashTransactionParts, + network: crate::Network, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + unspents: &[super::HydrationUnspentInput], + consensus_branch_id: u32, + ) -> Result { + let tx = &parts.transaction; + let inputs = super::FixedScriptInput::parse_all(tx)?; + let mut z = Self::from_tx_parts( + network, + wallet_keys, + tx, + unspents, + consensus_branch_id, + parts.version_group_id, + parts.expiry_height, + )?; + for (i, input) in inputs.iter().enumerate() { + z.add_input_signatures(i, input)?; + } + z.sapling_fields = parts.sapling_fields.clone(); + Ok(z) + } + /// Convert to the underlying Bitcoin PSBT, consuming self pub fn into_psbt(self) -> Psbt { self.psbt 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 fbb7b8baa44..f64851ab27b 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -439,13 +439,14 @@ impl BitGoPsbt { .collect::, _>>()?; let tx_bytes = tx.to_bytes(); - let psbt = - crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::from_half_signed_legacy_transaction( - &tx_bytes, - network, - wallet_keys, - &parsed_unspents, - ) + let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::from_network_format( + &tx_bytes, + network, + wallet_keys, + &parsed_unspents, + ) + .map_err(|e| WasmUtxoError::new(&e))?; + crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::validate_half_signed(&psbt) .map_err(|e| WasmUtxoError::new(&e))?; Ok(BitGoPsbt { @@ -480,13 +481,14 @@ impl BitGoPsbt { .collect::, _>>()?; let tx_bytes = tx.to_bytes()?; - let psbt = - crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::from_half_signed_legacy_transaction( - &tx_bytes, - network, - wallet_keys, - &parsed_unspents, - ) + let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::from_network_format( + &tx_bytes, + network, + wallet_keys, + &parsed_unspents, + ) + .map_err(|e| WasmUtxoError::new(&e))?; + crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::validate_half_signed(&psbt) .map_err(|e| WasmUtxoError::new(&e))?; Ok(BitGoPsbt { @@ -495,8 +497,87 @@ impl BitGoPsbt { }) } - /// Convert a half-signed legacy Zcash transaction to a psbt-lite (with block height). - /// Thin wrapper: resolves block_height → consensus_branch_id and delegates to the explicit variant. + /// Convert a network-format transaction (half-signed OR fully-signed) to a PSBT. + /// + /// Accepts both the half-signed legacy format (5 items with OP_0 placeholders) and + /// the fully-signed network format (4 items, no placeholders). The PSBT will contain + /// all partial signatures present in the transaction. + /// + /// # Arguments + /// * `tx` - The decoded Bitcoin-like (non-Dash, non-Zcash) transaction + /// * `network` - Network name (e.g., "bitcoin", "btc") + /// * `wallet_keys` - The wallet's root keys + /// * `unspents` - Array of `{ chain: number, index: number, value: bigint }` per input + #[wasm_bindgen] + pub fn from_network_format( + tx: &crate::wasm::transaction::WasmTransaction, + network: &str, + wallet_keys: &WasmRootWalletKeys, + unspents: JsValue, + ) -> Result { + use crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput; + + let network = parse_network(network)?; + let wallet_keys = wallet_keys.inner(); + + let arr = js_sys::Array::from(&unspents); + let parsed_unspents = arr + .iter() + .map(|item| HydrationUnspentInput::try_from_js_value(&item)) + .collect::, _>>()?; + + let tx_bytes = tx.to_bytes(); + let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::from_network_format( + &tx_bytes, + network, + wallet_keys, + &parsed_unspents, + ) + .map_err(|e| WasmUtxoError::new(&e))?; + + Ok(BitGoPsbt { + psbt, + first_rounds: HashMap::new(), + }) + } + + /// Convert a network-format Dash transaction (half-signed OR fully-signed) to a PSBT. + #[wasm_bindgen] + pub fn from_network_format_dash( + tx: &crate::wasm::dash_transaction::WasmDashTransaction, + network: &str, + wallet_keys: &WasmRootWalletKeys, + unspents: JsValue, + ) -> Result { + use crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput; + + let network = parse_network(network)?; + let wallet_keys = wallet_keys.inner(); + + let arr = js_sys::Array::from(&unspents); + let parsed_unspents = arr + .iter() + .map(|item| HydrationUnspentInput::try_from_js_value(&item)) + .collect::, _>>()?; + + let tx_bytes = tx.to_bytes()?; + let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::from_network_format( + &tx_bytes, + network, + wallet_keys, + &parsed_unspents, + ) + .map_err(|e| WasmUtxoError::new(&e))?; + + Ok(BitGoPsbt { + psbt, + first_rounds: HashMap::new(), + }) + } + + /// Convert a network-format Zcash transaction (0, 1, or 2 sigs) to a PSBT (with block height). + /// + /// Accepts unsigned, half-signed, and fully-signed Zcash transactions. /// /// # Arguments /// * `tx` - The decoded Zcash transaction @@ -505,7 +586,7 @@ impl BitGoPsbt { /// * `unspents` - Array of `{ chain: number, index: number, value: bigint }` for each input /// * `block_height` - Block height to determine consensus branch ID #[wasm_bindgen] - pub fn from_half_signed_legacy_transaction_zcash_with_block_height( + pub fn from_network_format_zcash_with_block_height( tx: &crate::wasm::transaction::WasmZcashTransaction, network: &str, wallet_keys: &WasmRootWalletKeys, @@ -515,7 +596,6 @@ impl BitGoPsbt { let network_parsed = parse_network(network)?; let is_mainnet = matches!(network_parsed, crate::networks::Network::Zcash); - // Resolve consensus_branch_id from block height let consensus_branch_id = crate::zcash::branch_id_for_height(block_height, is_mainnet) .ok_or_else(|| { WasmUtxoError::new(&format!( @@ -525,8 +605,7 @@ impl BitGoPsbt { )) })?; - // Delegate to the explicit consensus_branch_id variant - Self::from_half_signed_legacy_transaction_zcash_with_branch_id( + Self::from_network_format_zcash_with_branch_id( tx, network, wallet_keys, @@ -535,7 +614,9 @@ impl BitGoPsbt { ) } - /// Convert a half-signed legacy Zcash transaction to a psbt-lite (with consensus branch ID). + /// Convert a network-format Zcash transaction (0, 1, or 2 sigs) to a PSBT (with consensus branch ID). + /// + /// Accepts unsigned, half-signed, and fully-signed Zcash transactions. /// /// # Arguments /// * `tx` - The decoded Zcash transaction @@ -544,7 +625,7 @@ impl BitGoPsbt { /// * `unspents` - Array of `{ chain: number, index: number, value: bigint }` for each input /// * `consensus_branch_id` - Zcash consensus branch ID #[wasm_bindgen] - pub fn from_half_signed_legacy_transaction_zcash_with_branch_id( + pub fn from_network_format_zcash_with_branch_id( tx: &crate::wasm::transaction::WasmZcashTransaction, network: &str, wallet_keys: &WasmRootWalletKeys, @@ -556,14 +637,13 @@ impl BitGoPsbt { let network = parse_network(network)?; let wallet_keys = wallet_keys.inner(); - // Parse the unspents array using the TryFromJsValue trait let arr = js_sys::Array::from(&unspents); let parsed_unspents = arr .iter() .map(|item| HydrationUnspentInput::try_from_js_value(&item)) .collect::, _>>()?; - let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::from_half_signed_legacy_transaction_zcash_with_consensus_branch_id_from_parts( + let zcash = crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt::from_network_format( &tx.parts, network, wallet_keys, @@ -573,7 +653,7 @@ impl BitGoPsbt { .map_err(|e| WasmUtxoError::new(&e))?; Ok(BitGoPsbt { - psbt, + psbt: crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::Zcash(zcash, network), first_rounds: HashMap::new(), }) } diff --git a/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts b/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts index f067e557189..18c56aae0d4 100644 --- a/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts +++ b/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts @@ -1,13 +1,8 @@ /** - * Tests for BitGoPsbt.fromHalfSignedLegacyTransaction() + * Tests for BitGoPsbt.fromHalfSignedLegacyTransaction() and BitGoPsbt.fromNetworkFormat(). * - * Bug: js_sys::BigInt::from(value_js).as_f64() does an unchecked wrap but then - * JsValue::as_f64() only works for JS Number type — not BigInt. Passing any proper - * JS BigInt value (e.g. 10000n) returned None, so the function always threw - * "'value' must be a bigint" even though the caller did exactly the right thing. - * - * Fix: u64::try_from(js_sys::BigInt::unchecked_from_js(value_js)) uses the - * BigInt-specific conversion path and then safely maps to u64. + * fromHalfSignedLegacyTransaction is deprecated in favour of fromNetworkFormat. This file + * tests both the deprecated path (to verify it keeps working) and the new path. */ import { describe, it } from "mocha"; import * as assert from "assert"; @@ -173,6 +168,33 @@ describe("BitGoPsbt.fromHalfSignedLegacyTransaction", function () { }); }); + describe("Full-signed transaction", function () { + function createFullSignedTxBytes(coinName: CoinName): { + txBytes: Uint8Array; + unspents: HydrationUnspent[]; + } { + const [, , bitgoXprv] = getKeyTriple("default"); + const { psbt, unspents } = createHalfSignedP2msPsbt(coinName); + psbt.sign(bitgoXprv); + psbt.finalizeAllInputs(); + return { txBytes: psbt.extractTransaction().toBytes(), unspents }; + } + + const fullSignedCoins = coinNames.filter(isSupportedCoin); + + for (const coinName of fullSignedCoins) { + it(`${coinName}: throws because fully-signed transaction has 2 signatures`, function () { + const rootWalletKeys = getDefaultWalletKeys(); + const { txBytes, unspents } = createFullSignedTxBytes(coinName); + assert.throws( + () => + BitGoPsbt.fromHalfSignedLegacyTransaction(txBytes, coinName, rootWalletKeys, unspents), + /expected 1 signature for half-signed transaction, found 2/i, + ); + }); + } + }); + describe("Zcash legacy format round-trip", function () { it("should reject Zcash via type check in fromHalfSignedLegacyTransaction", function () { // fromHalfSignedLegacyTransaction validates the transaction type at call time @@ -277,3 +299,147 @@ describe("BitGoPsbt.fromHalfSignedLegacyTransaction", function () { }); }); }); + +describe("BitGoPsbt.fromNetworkFormat", function () { + const [userXprv, , bitgoXprv] = getKeyTriple("default"); + + describe("Half-signed input", function () { + const roundTripCoins = coinNames.filter(isSupportedCoin); + + for (const coinName of roundTripCoins) { + it(`${coinName}: succeeds and PSBT has user signature`, function () { + const rootWalletKeys = getDefaultWalletKeys(); + const { psbt, unspents } = createHalfSignedP2msPsbt(coinName); + const txBytes = psbt.getHalfSignedLegacyFormat(); + + const reconstructed = BitGoPsbt.fromNetworkFormat( + txBytes, + coinName, + rootWalletKeys, + unspents, + ); + + assert.ok(reconstructed.serialize().length > 0, "PSBT serializes"); + assert.ok( + reconstructed.verifySignature(0, userXprv.neutered().toBase58()), + "User signature present in input 0", + ); + }); + } + }); + + describe("Full-signed input", function () { + const fullSignedCoins = coinNames.filter(isSupportedCoin); + + for (const coinName of fullSignedCoins) { + it(`${coinName}: succeeds and PSBT has both user and bitgo signatures`, function () { + const rootWalletKeys = getDefaultWalletKeys(); + const { psbt, unspents } = createHalfSignedP2msPsbt(coinName); + psbt.sign(bitgoXprv); + psbt.finalizeAllInputs(); + const txBytes = psbt.extractTransaction().toBytes(); + + const reconstructed = BitGoPsbt.fromNetworkFormat( + txBytes, + coinName, + rootWalletKeys, + unspents, + ); + + assert.ok(reconstructed.serialize().length > 0, "PSBT serializes"); + assert.ok( + reconstructed.verifySignature(0, userXprv.neutered().toBase58()), + "User signature present in input 0", + ); + assert.ok( + reconstructed.verifySignature(0, bitgoXprv.neutered().toBase58()), + "Bitgo signature present in input 0", + ); + }); + } + }); + + describe("ZcashBitGoPsbt.fromNetworkFormat", function () { + it("zec half-signed: PSBT has user signature (blockHeight)", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const { psbt, unspents } = createHalfSignedP2msPsbt("zec"); + const txBytes = psbt.getHalfSignedLegacyFormat(); + + const reconstructed = ZcashBitGoPsbt.fromNetworkFormat( + txBytes, + "zec", + rootWalletKeys, + unspents, + { blockHeight: ZCASH_NU5_HEIGHT }, + ); + + assert.ok(reconstructed instanceof ZcashBitGoPsbt, "Returns ZcashBitGoPsbt"); + assert.ok(reconstructed.serialize().length > 0, "PSBT serializes"); + assert.ok( + reconstructed.verifySignature(0, userXprv.neutered().toBase58()), + "User signature present in input 0", + ); + }); + + it("zec half-signed: PSBT has user signature (consensusBranchId)", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const { psbt, unspents } = createHalfSignedP2msPsbt("zec"); + const txBytes = psbt.getHalfSignedLegacyFormat(); + + const reconstructed = ZcashBitGoPsbt.fromNetworkFormat( + txBytes, + "zec", + rootWalletKeys, + unspents, + { consensusBranchId: 0xc2d6d0b4 }, + ); + + assert.ok(reconstructed instanceof ZcashBitGoPsbt, "Returns ZcashBitGoPsbt"); + assert.ok(reconstructed.serialize().length > 0, "PSBT serializes"); + assert.ok( + reconstructed.verifySignature(0, userXprv.neutered().toBase58()), + "User signature present in input 0", + ); + }); + + it("zec full-signed: succeeds and PSBT has both user and bitgo signatures", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const { psbt, unspents } = createHalfSignedP2msPsbt("zec"); + psbt.sign(bitgoXprv); + psbt.finalizeAllInputs(); + const txBytes = psbt.extractTransaction().toBytes(); + + const reconstructed = ZcashBitGoPsbt.fromNetworkFormat( + txBytes, + "zec", + rootWalletKeys, + unspents, + { blockHeight: ZCASH_NU5_HEIGHT }, + ); + + assert.ok(reconstructed.serialize().length > 0, "PSBT serializes"); + assert.ok( + reconstructed.verifySignature(0, userXprv.neutered().toBase58()), + "User signature present in input 0", + ); + assert.ok( + reconstructed.verifySignature(0, bitgoXprv.neutered().toBase58()), + "Bitgo signature present in input 0", + ); + }); + + it("zec: accepts pre-decoded ZcashTransaction instance", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const { psbt, unspents } = createHalfSignedP2msPsbt("zec"); + const txBytes = psbt.getHalfSignedLegacyFormat(); + const tx = ZcashTransaction.fromBytes(txBytes); + + const reconstructed = ZcashBitGoPsbt.fromNetworkFormat(tx, "zec", rootWalletKeys, unspents, { + blockHeight: ZCASH_NU5_HEIGHT, + }); + + assert.ok(reconstructed instanceof ZcashBitGoPsbt, "Returns ZcashBitGoPsbt"); + assert.ok(reconstructed.serialize().length > 0, "Serializes without error"); + }); + }); +});