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"); + }); + }); +});