From 202e04eec82435012cc8457573edd3fa11854281 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 15 Apr 2026 16:40:32 +0200 Subject: [PATCH 1/2] refactor: accept Transaction directly in fromHalfSignedLegacyTransaction Changes fromHalfSignedLegacyTransaction to accept Transaction | DashTransaction instead of Uint8Array, avoiding re-serialization round-trips. Bitcoin-like transactions: - Add dynamic dispatch via Transaction.supportsCoin() - Validate transaction type and reject Zcash with helpful error message - Pass WASM transaction directly without re-parsing bytes - Support pre-decoded transaction instances to avoid re-parsing Zcash support: - Add ZcashBitGoPsbt.fromHalfSignedLegacyTransaction() - Support block height mode (recommended) or explicit consensus_branch_id - Implement full Sapling field restoration - Add comprehensive round-trip tests Rust layer: - Refactor hydration logic into shared hydrate_psbt() helper - Add from_half_signed_legacy_transaction_zcash_with_block_height() - Add from_half_signed_legacy_transaction_zcash_with_branch_id() - Extract HydrationUnspentInput parsing into TryFromJsValue trait WASM bindings expose both variants with unified unspent parsing. All fromHalfSignedLegacy tests pass including new Zcash round-trip tests. Co-authored-by: llm-git --- .../js/fixedScriptWallet/BitGoPsbt.ts | 42 +++- .../js/fixedScriptWallet/ZcashBitGoPsbt.ts | 78 +++++++- packages/wasm-utxo/js/transaction.ts | 40 +++- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 103 +++++++++- .../src/wasm/fixed_script_wallet/mod.rs | 184 +++++++++++++----- packages/wasm-utxo/src/wasm/transaction.rs | 2 +- .../wasm-utxo/src/wasm/try_from_js_value.rs | 58 +++++- .../fromHalfSignedLegacyTransaction.ts | 108 ++++++++++ .../test/fixtures_thirdparty/parse.ts | 29 ++- packages/wasm-utxo/test/transaction.ts | 66 ++++++- 10 files changed, 621 insertions(+), 89 deletions(-) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 099d836842f..7bed427c42b 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -11,6 +11,7 @@ import { type BIP32Arg, BIP32, isBIP32Arg } from "../bip32.js"; import { type ECPairArg, ECPair } from "../ecpair.js"; import type { UtxolibName } from "../utxolibCompat.js"; import type { CoinName } from "../coinName.js"; +import { toCoinName } from "../coinName.js"; import type { InputScriptType } from "./scriptType.js"; import { Transaction, @@ -196,24 +197,49 @@ export class BitGoPsbt extends PsbtBase implements IPsbtWithAddre * with proper wallet metadata (bip32Derivation, scripts, witnessUtxo). * Only supports p2sh, p2shP2wsh, and p2wsh inputs (not taproot). * - * @param txBytes - The serialized half-signed legacy transaction + * Supports both Bitcoin-like coins (BTC, LTC, DOGE) and Dash (DASH). + * Zcash is NOT supported; use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction 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) */ static fromHalfSignedLegacyTransaction( - txBytes: Uint8Array, + 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 wasm = WasmBitGoPsbt.from_half_signed_legacy_transaction( - txBytes, - network, - keys.wasm, - unspents, - ); + + // Parse bytes to Transaction if needed + const tx = + txBytesOrTx instanceof Uint8Array + ? Transaction.fromBytes(txBytesOrTx, toCoinName(network)) + : txBytesOrTx; + + // Validate that this is not a Zcash transaction + if (tx instanceof ZcashTransaction) { + throw new Error( + "Use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction() for Zcash transactions", + ); + } + + // Pass WASM transaction directly to avoid serialization round-trip + const wasm: WasmBitGoPsbt = + tx instanceof DashTransaction + ? WasmBitGoPsbt.from_half_signed_legacy_transaction_dash( + tx.wasm, + network, + keys.wasm, + unspents, + ) + : WasmBitGoPsbt.from_half_signed_legacy_transaction(tx.wasm, network, keys.wasm, unspents); return new BitGoPsbt(wasm); } diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts index 3c386d41ec8..e745da535e3 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts @@ -1,7 +1,7 @@ import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js"; import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js"; -import { BitGoPsbt, type CreateEmptyOptions } from "./BitGoPsbt.js"; -import { ZcashTransaction } from "../transaction.js"; +import { BitGoPsbt, type CreateEmptyOptions, type HydrationUnspent } from "./BitGoPsbt.js"; +import { ZcashTransaction, type ITransaction } from "../transaction.js"; /** Zcash network names */ export type ZcashNetworkName = "zcash" | "zcashTest" | "zec" | "tzec"; @@ -144,6 +144,80 @@ export class ZcashBitGoPsbt extends BitGoPsbt { return new ZcashBitGoPsbt(wasm); } + /** + * Reconstruct a Zcash PSBT from a half-signed legacy transaction + * + * 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. + * + * Supports two modes for determining consensus_branch_id: + * - **Recommended**: Pass `blockHeight` to auto-determine consensus_branch_id via network upgrade activation heights + * - **Advanced**: Pass `consensusBranchId` directly if you already know it (e.g., 0xC2D6D0B4 for NU5) + * + * @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 + * + * @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 + * ); + * + * // Or with explicit consensus branch ID + * const reconstructed = ZcashBitGoPsbt.fromHalfSignedLegacyTransaction( + * legacyBytes, + * "zec", + * walletKeys, + * unspents, + * { consensusBranchId: 0xC2D6D0B4 } // NU5 branch ID + * ); + * ``` + */ + static fromHalfSignedLegacyTransaction( + 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_half_signed_legacy_transaction_zcash_with_block_height( + tx.wasm, + network, + keys.wasm, + unspents, + options.blockHeight, + ); + return new ZcashBitGoPsbt(wasm); + } else { + const wasm = WasmBitGoPsbt.from_half_signed_legacy_transaction_zcash_with_branch_id( + tx.wasm, + network, + keys.wasm, + unspents, + options.consensusBranchId, + ); + return new ZcashBitGoPsbt(wasm); + } + } + // --- Zcash-specific getters --- /** diff --git a/packages/wasm-utxo/js/transaction.ts b/packages/wasm-utxo/js/transaction.ts index 26c03e14d86..b703a4d26e7 100644 --- a/packages/wasm-utxo/js/transaction.ts +++ b/packages/wasm-utxo/js/transaction.ts @@ -30,6 +30,14 @@ export class Transaction extends TransactionBase { super(wasm); } + /** + * Check if a coin is supported by this transaction class. + * Bitcoin-like transactions support all coins except Zcash and Dash. + */ + static supportsCoin(coin: CoinName): boolean { + return !ZcashTransaction.supportsCoin(coin) && !DashTransaction.supportsCoin(coin); + } + /** * Create an empty transaction (version 1, locktime 0) */ @@ -37,7 +45,21 @@ export class Transaction extends TransactionBase { return new Transaction(WasmTransaction.create()); } - static fromBytes(bytes: Uint8Array): Transaction { + static fromBytes(bytes: Uint8Array): Transaction; + static fromBytes(bytes: Uint8Array, coin: "zec" | "tzec"): ZcashTransaction; + static fromBytes(bytes: Uint8Array, coin: "dash" | "tdash"): DashTransaction; + static fromBytes( + bytes: Uint8Array, + coin: CoinName, + ): Transaction | ZcashTransaction | DashTransaction; + static fromBytes( + bytes: Uint8Array, + coin?: CoinName, + ): Transaction | ZcashTransaction | DashTransaction { + if (coin !== undefined) { + if (ZcashTransaction.supportsCoin(coin)) return ZcashTransaction.fromBytes(bytes); + if (DashTransaction.supportsCoin(coin)) return DashTransaction.fromBytes(bytes); + } return new Transaction(WasmTransaction.from_bytes(bytes)); } @@ -96,6 +118,14 @@ export class ZcashTransaction extends TransactionBase { super(wasm); } + /** + * Check if a coin is supported by this transaction class. + * Zcash transactions support Zcash mainnet and testnet. + */ + static supportsCoin(coin: CoinName): boolean { + return coin === "zec" || coin === "tzec"; + } + static fromBytes(bytes: Uint8Array): ZcashTransaction { return new ZcashTransaction(WasmZcashTransaction.from_bytes(bytes)); } @@ -121,6 +151,14 @@ export class DashTransaction extends TransactionBase { super(wasm); } + /** + * Check if a coin is supported by this transaction class. + * Dash transactions support Dash mainnet and testnet. + */ + static supportsCoin(coin: CoinName): boolean { + return coin === "dash" || coin === "tdash"; + } + static fromBytes(bytes: Uint8Array): DashTransaction { return new DashTransaction(WasmDashTransaction.from_bytes(bytes)); } 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 1fd2616ceb1..e4e362f5c21 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 @@ -525,11 +525,100 @@ impl BitGoPsbt { unspents: &[HydrationUnspentInput], ) -> Result { use miniscript::bitcoin::consensus::Decodable; - use miniscript::bitcoin::{PublicKey, Transaction}; + 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, + 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", @@ -538,11 +627,6 @@ impl BitGoPsbt { )); } - 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)); - // Parse each input from the legacy tx let input_results: Vec = tx .input @@ -554,6 +638,7 @@ impl BitGoPsbt { }) .collect::, _>>()?; + // Hydrate inputs for (i, (tx_in, unspent)) in tx.input.iter().zip(unspents.iter()).enumerate() { match (&input_results[i], unspent) { ( @@ -573,7 +658,7 @@ impl BitGoPsbt { psbt_wallet_input::WalletInputOptions { sign_path: None, sequence: Some(tx_in.sequence.0), - prev_tx: None, // psbt-lite: no nonWitnessUtxo + prev_tx: None, }, ) .map_err(|e| format!("Input {}: failed to add wallet input: {}", i, e))?; @@ -652,12 +737,12 @@ impl BitGoPsbt { } } - // Add outputs (plain script+value, no wallet metadata) + // Add outputs for tx_out in &tx.output { psbt.add_output(tx_out.script_pubkey.clone(), tx_out.value.to_sat()); } - Ok(psbt) + Ok(()) } fn new_internal( 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 d14e60d724c..fbb7b8baa44 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -416,74 +416,32 @@ impl BitGoPsbt { /// Convert a half-signed legacy transaction to a psbt-lite. /// /// # Arguments - /// * `tx_bytes` - The serialized half-signed legacy transaction + /// * `tx` - The decoded half-signed legacy transaction /// * `network` - Network name (utxolib or coin name) /// * `wallet_keys` - The wallet's root keys /// * `unspents` - Array of `{ chain: number, index: number, value: bigint }` for each input pub fn from_half_signed_legacy_transaction( - tx_bytes: &[u8], + tx: &crate::wasm::transaction::WasmTransaction, network: &str, wallet_keys: &WasmRootWalletKeys, unspents: JsValue, ) -> Result { - use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ScriptIdWithValue; use crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput; let network = parse_network(network)?; let wallet_keys = wallet_keys.inner(); - // Parse the unspents array from JsValue. - // Each element is either: - // { chain: number, index: number, value: bigint } → wallet input - // { value: bigint } → replay protection input - // The presence of `chain` is used to distinguish the two. + // Parse the unspents array using the TryFromJsValue trait let arr = js_sys::Array::from(&unspents); - let mut parsed_unspents = Vec::with_capacity(arr.length() as usize); - for i in 0..arr.length() { - let item = arr.get(i); - let value_js = js_sys::Reflect::get(&item, &"value".into()) - .map_err(|_| WasmUtxoError::new("Missing 'value' field on unspent"))?; - let value = u64::try_from(js_sys::BigInt::unchecked_from_js(value_js)) - .map_err(|_| WasmUtxoError::new("'value' must be a bigint convertible to u64"))?; - - let chain_val = - js_sys::Reflect::get(&item, &"chain".into()).unwrap_or(JsValue::UNDEFINED); - - if chain_val.is_undefined() { - // No 'chain' property → replay protection input; require pubkey - let pubkey_val = js_sys::Reflect::get(&item, &"pubkey".into()).map_err(|_| { - WasmUtxoError::new("Missing 'pubkey' on replay protection unspent") - })?; - let pubkey_bytes = js_sys::Uint8Array::new(&pubkey_val).to_vec(); - let pubkey = miniscript::bitcoin::CompressedPublicKey::from_slice(&pubkey_bytes) - .map_err(|_| { - WasmUtxoError::new( - "'pubkey' is not a valid compressed public key (33 bytes)", - ) - })?; - parsed_unspents.push(HydrationUnspentInput::ReplayProtection { pubkey, value }); - } else { - // Has 'chain' → wallet input; also parse 'index' - let chain = chain_val - .as_f64() - .ok_or_else(|| WasmUtxoError::new("'chain' must be a number"))? - as u32; - let index = js_sys::Reflect::get(&item, &"index".into()) - .map_err(|_| WasmUtxoError::new("Missing 'index' field on wallet unspent"))? - .as_f64() - .ok_or_else(|| WasmUtxoError::new("'index' must be a number"))? - as u32; - parsed_unspents.push(HydrationUnspentInput::Wallet(ScriptIdWithValue { - chain, - index, - value, - })); - } - } + let 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_half_signed_legacy_transaction( - tx_bytes, + &tx_bytes, network, wallet_keys, &parsed_unspents, @@ -496,6 +454,130 @@ impl BitGoPsbt { }) } + /// Convert a half-signed legacy Dash transaction to a psbt-lite. + /// + /// # Arguments + /// * `tx` - The decoded Dash transaction + /// * `network` - Network name ("dash" or "tdash") + /// * `wallet_keys` - The wallet's root keys + /// * `unspents` - Array of `{ chain: number, index: number, value: bigint }` for each input + pub fn from_half_signed_legacy_transaction_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(); + + // 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 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, + ) + .map_err(|e| WasmUtxoError::new(&e))?; + + Ok(BitGoPsbt { + psbt, + first_rounds: HashMap::new(), + }) + } + + /// 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. + /// + /// # Arguments + /// * `tx` - The decoded Zcash transaction + /// * `network` - Network name ("zec" or "tzec") + /// * `wallet_keys` - The wallet's root keys + /// * `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( + tx: &crate::wasm::transaction::WasmZcashTransaction, + network: &str, + wallet_keys: &WasmRootWalletKeys, + unspents: JsValue, + block_height: u32, + ) -> Result { + 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!( + "Block height {} is before Overwinter activation on {}", + block_height, + if is_mainnet { "mainnet" } else { "testnet" } + )) + })?; + + // Delegate to the explicit consensus_branch_id variant + Self::from_half_signed_legacy_transaction_zcash_with_branch_id( + tx, + network, + wallet_keys, + unspents, + consensus_branch_id, + ) + } + + /// Convert a half-signed legacy Zcash transaction to a psbt-lite (with consensus branch ID). + /// + /// # Arguments + /// * `tx` - The decoded Zcash transaction + /// * `network` - Network name ("zec" or "tzec") + /// * `wallet_keys` - The wallet's root keys + /// * `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( + tx: &crate::wasm::transaction::WasmZcashTransaction, + network: &str, + wallet_keys: &WasmRootWalletKeys, + unspents: JsValue, + consensus_branch_id: u32, + ) -> Result { + use crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput; + + 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( + &tx.parts, + network, + wallet_keys, + &parsed_unspents, + consensus_branch_id, + ) + .map_err(|e| WasmUtxoError::new(&e))?; + + Ok(BitGoPsbt { + psbt, + first_rounds: HashMap::new(), + }) + } + /// Add an input to the PSBT /// /// # Arguments diff --git a/packages/wasm-utxo/src/wasm/transaction.rs b/packages/wasm-utxo/src/wasm/transaction.rs index 9791b945a5c..f506954ef91 100644 --- a/packages/wasm-utxo/src/wasm/transaction.rs +++ b/packages/wasm-utxo/src/wasm/transaction.rs @@ -280,7 +280,7 @@ impl WasmTransaction { /// transactions, which use the Overwinter transaction format. #[wasm_bindgen] pub struct WasmZcashTransaction { - parts: crate::zcash::transaction::ZcashTransactionParts, + pub(crate) parts: crate::zcash::transaction::ZcashTransactionParts, } impl WasmZcashTransaction { diff --git a/packages/wasm-utxo/src/wasm/try_from_js_value.rs b/packages/wasm-utxo/src/wasm/try_from_js_value.rs index d8aab945f6a..0e26894e503 100644 --- a/packages/wasm-utxo/src/wasm/try_from_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_from_js_value.rs @@ -3,7 +3,7 @@ use std::ops::Deref; use crate::address::utxolib_compat::{CashAddr, UtxolibNetwork}; use crate::error::WasmUtxoError; use miniscript::bitcoin::psbt::raw; -use wasm_bindgen::JsValue; +use wasm_bindgen::{JsCast, JsValue}; // ============================================================================= // TryFromJsValue trait @@ -227,3 +227,59 @@ impl TryFromJsValue for crate::networks::Network { }) } } + +// ============================================================================= +// HydrationUnspentInput: Wallet or replay protection input +// ============================================================================= + +impl TryFromJsValue for crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput { + fn try_from_js_value(item: &JsValue) -> Result { + use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ScriptIdWithValue; + + // Read 'value' as BigInt (required) + let value_js = js_sys::Reflect::get(item, &"value".into()) + .map_err(|_| WasmUtxoError::new("Missing 'value' field on unspent"))?; + let value = u64::try_from(js_sys::BigInt::unchecked_from_js(value_js)) + .map_err(|_| WasmUtxoError::new("'value' must be a bigint convertible to u64"))?; + + // Check if 'chain' is present; if missing → ReplayProtection, else → Wallet + let chain_val = js_sys::Reflect::get(item, &"chain".into()).unwrap_or(JsValue::UNDEFINED); + + if chain_val.is_undefined() { + // Replay protection input: requires 'pubkey' field + let pubkey_val = js_sys::Reflect::get(item, &"pubkey".into()) + .map_err(|_| WasmUtxoError::new("Missing 'pubkey' on replay protection unspent"))?; + let pubkey_bytes = js_sys::Uint8Array::new(&pubkey_val).to_vec(); + let pubkey = miniscript::bitcoin::CompressedPublicKey::from_slice(&pubkey_bytes) + .map_err(|_| { + WasmUtxoError::new("'pubkey' is not a valid compressed public key (33 bytes)") + })?; + Ok( + crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput::ReplayProtection { + pubkey, + value, + }, + ) + } else { + // Wallet input: requires 'chain' and 'index' fields + let chain = chain_val + .as_f64() + .ok_or_else(|| WasmUtxoError::new("'chain' must be a number"))? + as u32; + let index = js_sys::Reflect::get(item, &"index".into()) + .map_err(|_| WasmUtxoError::new("Missing 'index' field on wallet unspent"))? + .as_f64() + .ok_or_else(|| WasmUtxoError::new("'index' must be a number"))? + as u32; + Ok( + crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput::Wallet( + ScriptIdWithValue { + chain, + index, + value, + }, + ), + ) + } + } +} diff --git a/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts b/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts index 022ea5c98e4..449321cd4e7 100644 --- a/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts +++ b/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts @@ -16,6 +16,7 @@ import { BitGoPsbt, type HydrationUnspent } from "../../js/fixedScriptWallet/Bit import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; import { ChainCode } from "../../js/fixedScriptWallet/chains.js"; import { ECPair } from "../../js/ecpair.js"; +import { Transaction } from "../../js/transaction.js"; import { getDefaultWalletKeys, getKeyTriple } from "../../js/testutils/keys.js"; import { getCoinNameForNetwork } from "../networks.js"; @@ -171,4 +172,111 @@ describe("BitGoPsbt.fromHalfSignedLegacyTransaction", function () { ); }); }); + + describe("Zcash legacy format round-trip", function () { + it("should reject Zcash via dynamic dispatch in fromHalfSignedLegacyTransaction", function () { + // With dynamic dispatch, fromHalfSignedLegacyTransaction now validates the transaction type + // and rejects Zcash early with a clear error message, directing to ZcashBitGoPsbt.createEmpty(). + const rootWalletKeys = getDefaultWalletKeys(); + const { psbt: zcashPsbt, unspents } = createHalfSignedP2msPsbt(utxolib.networks.zcash); + + // Step 1: Extract Zcash PSBT as legacy format + const txBytes = zcashPsbt.getHalfSignedLegacyFormat(); + assert.ok(txBytes.length > 0, "ZcashBitGoPsbt.getHalfSignedLegacyFormat() produces bytes"); + + // Step 2: Call fromHalfSignedLegacyTransaction with Zcash bytes + // Expected: Throws clear error after detecting Zcash transaction via dynamic dispatch + assert.throws( + () => { + BitGoPsbt.fromHalfSignedLegacyTransaction(txBytes, "zec", rootWalletKeys, unspents); + }, + /Use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction\(\) for Zcash transactions/, + ); + }); + + it("should round-trip Zcash PSBT via ZcashBitGoPsbt.fromHalfSignedLegacyTransaction (with blockHeight)", function () { + // This test verifies the round-trip: create Zcash PSBT → extract legacy format → reconstruct PSBT + const rootWalletKeys = getDefaultWalletKeys(); + const { psbt, unspents } = createHalfSignedP2msPsbt(utxolib.networks.zcash); + + // Step 1: Extract half-signed legacy format (this is what would be transmitted) + const legacyBytes = psbt.getHalfSignedLegacyFormat(); + assert.ok(legacyBytes.length > 0, "getHalfSignedLegacyFormat() produces bytes"); + + // Step 2: Reconstruct PSBT from legacy format with block height + const reconstructed = ZcashBitGoPsbt.fromHalfSignedLegacyTransaction( + legacyBytes, + "zec", + rootWalletKeys, + unspents, + { blockHeight: ZCASH_NU5_HEIGHT }, + ); + + // Step 3: Verify reconstruction succeeded + assert.ok(reconstructed, "fromHalfSignedLegacyTransaction() reconstructs PSBT"); + assert.ok(reconstructed instanceof ZcashBitGoPsbt, "Reconstructed PSBT is ZcashBitGoPsbt"); + + // Step 4: Verify Zcash metadata is preserved + assert.strictEqual(reconstructed.version(), 4, "Zcash version preserved as 4 (Overwintered)"); + + // Step 5: Verify serialization works (round-trip complete) + const serialized = reconstructed.serialize(); + assert.ok(serialized.length > 0, "Reconstructed Zcash PSBT serializes without error"); + }); + + it("should round-trip Zcash PSBT via ZcashBitGoPsbt.fromHalfSignedLegacyTransaction (with consensusBranchId)", function () { + // This test verifies the round-trip with explicit consensus branch ID instead of block height + const rootWalletKeys = getDefaultWalletKeys(); + const { psbt, unspents } = createHalfSignedP2msPsbt(utxolib.networks.zcash); + + // Step 1: Extract half-signed legacy format + const legacyBytes = psbt.getHalfSignedLegacyFormat(); + + // Step 2: Reconstruct PSBT from legacy format with explicit consensus branch ID + // 0xC2D6D0B4 is the NU5 consensus branch ID + const reconstructed = ZcashBitGoPsbt.fromHalfSignedLegacyTransaction( + legacyBytes, + "zec", + rootWalletKeys, + unspents, + { consensusBranchId: 0xc2d6d0b4 }, + ); + + // Step 3: Verify reconstruction succeeded with explicit branch ID + assert.ok( + reconstructed, + "fromHalfSignedLegacyTransactionZcash() works with consensusBranchId", + ); + assert.ok(reconstructed instanceof ZcashBitGoPsbt, "Reconstructed PSBT is ZcashBitGoPsbt"); + + // Step 4: Verify serialization works + const serialized = reconstructed.serialize(); + assert.ok(serialized.length > 0, "Reconstructed Zcash PSBT serializes without error"); + }); + + it("should accept pre-decoded transaction instance to avoid re-parsing", function () { + // Dynamic dispatch enhancement: fromHalfSignedLegacyTransaction now accepts + // either txBytes OR a pre-decoded ITransaction instance + const rootWalletKeys = getDefaultWalletKeys(); + const { psbt, unspents } = createHalfSignedP2msPsbt(utxolib.networks.bitcoin); + const txBytes = psbt.getHalfSignedLegacyFormat(); + + // Method 1: Pass raw bytes (uses dynamic dispatch internally) + const psbt1 = BitGoPsbt.fromHalfSignedLegacyTransaction( + txBytes, + "btc", + rootWalletKeys, + unspents, + ); + + // Method 2: Pre-decode transaction and pass instance (avoids re-parsing) + const tx = Transaction.fromBytes(txBytes); + const psbt2 = BitGoPsbt.fromHalfSignedLegacyTransaction(tx, "btc", rootWalletKeys, unspents); + + // Both methods should produce equivalent results + assert.strictEqual(psbt1.inputCount(), psbt2.inputCount(), "Same input count"); + assert.strictEqual(psbt1.outputCount(), psbt2.outputCount(), "Same output count"); + assert.deepStrictEqual(psbt1.serialize(), psbt2.serialize(), "Identical serialization"); + }); + }); }); diff --git a/packages/wasm-utxo/test/fixtures_thirdparty/parse.ts b/packages/wasm-utxo/test/fixtures_thirdparty/parse.ts index 7a1f144495c..663b569f5a8 100644 --- a/packages/wasm-utxo/test/fixtures_thirdparty/parse.ts +++ b/packages/wasm-utxo/test/fixtures_thirdparty/parse.ts @@ -1,9 +1,9 @@ import * as assert from "assert/strict"; import { describe } from "mocha"; -import { getNetworkList, getNetworkName, isZcash } from "../networks.js"; +import { getNetworkList, getNetworkName } from "../networks.js"; import { testFixtureArray, txValidTestFile, TxValidVector } from "./fixtures.js"; - -import { Transaction, ZcashTransaction } from "../../js/index.js"; +import { Transaction } from "../../js/index.js"; +import { toCoinName } from "../../js/coinName.js"; describe("Third-Party Fixtures", function () { getNetworkList().forEach((network) => { @@ -13,18 +13,17 @@ describe("Third-Party Fixtures", function () { const [, /* inputs , */ txHex] = v; const buffer = Buffer.from(txHex, "hex"); - // Parse transaction to verify it's valid - if (isZcash(network)) { - const tx = ZcashTransaction.fromBytes(buffer); - // Round-trip to verify serialization - const serialized = Buffer.from(tx.toBytes()); - assert.deepEqual(serialized, buffer, `Zcash transaction ${i} failed round-trip`); - } else { - const tx = Transaction.fromBytes(buffer); - // Round-trip to verify serialization - const serialized = Buffer.from(tx.toBytes()); - assert.deepEqual(serialized, buffer, `Transaction ${i} failed round-trip`); - } + // Parse transaction using factory dispatch + const coin = toCoinName(getNetworkName(network)); + const tx = Transaction.fromBytes(buffer, coin); + + // Round-trip to verify serialization + const serialized = Buffer.from(tx.toBytes()); + assert.deepEqual( + serialized, + buffer, + `Transaction round-trip failed for ${coin} vector ${i}`, + ); }); }); }); diff --git a/packages/wasm-utxo/test/transaction.ts b/packages/wasm-utxo/test/transaction.ts index 3b8af118d16..b1ed40b9dae 100644 --- a/packages/wasm-utxo/test/transaction.ts +++ b/packages/wasm-utxo/test/transaction.ts @@ -1,6 +1,7 @@ import assert from "node:assert"; -import { Transaction } from "../js/transaction.js"; +import { Transaction, ZcashTransaction, DashTransaction } from "../js/transaction.js"; import { fixedScriptWallet } from "../js/index.js"; +import { coinNames } from "../js/coinName.js"; describe("Transaction builder", function () { it("should create an empty transaction", function () { @@ -83,3 +84,66 @@ describe("Transaction builder", function () { assert.deepStrictEqual(tx2.toBytes(), bytes); }); }); + +describe("supportsCoin", function () { + it("should identify Zcash coins correctly", function () { + assert.ok(ZcashTransaction.supportsCoin("zec"), "zec should be supported by ZcashTransaction"); + assert.ok( + ZcashTransaction.supportsCoin("tzec"), + "tzec should be supported by ZcashTransaction", + ); + }); + + it("should reject non-Zcash coins in ZcashTransaction", function () { + const nonZcashCoins = coinNames.filter((c) => !ZcashTransaction.supportsCoin(c)); + assert.ok(nonZcashCoins.length > 0, "should have non-Zcash coins"); + nonZcashCoins.forEach((coin) => { + assert.ok( + !ZcashTransaction.supportsCoin(coin), + `ZcashTransaction should not support ${coin}`, + ); + }); + }); + + it("should identify Dash coins correctly", function () { + assert.ok(DashTransaction.supportsCoin("dash"), "dash should be supported by DashTransaction"); + assert.ok( + DashTransaction.supportsCoin("tdash"), + "tdash should be supported by DashTransaction", + ); + }); + + it("should reject non-Dash coins in DashTransaction", function () { + const nonDashCoins = coinNames.filter((c) => !DashTransaction.supportsCoin(c)); + assert.ok(nonDashCoins.length > 0, "should have non-Dash coins"); + nonDashCoins.forEach((coin) => { + assert.ok(!DashTransaction.supportsCoin(coin), `DashTransaction should not support ${coin}`); + }); + }); + + it("should identify Bitcoin-like coins correctly", function () { + const bitcoinLikeCoins = coinNames.filter((c) => Transaction.supportsCoin(c)); + assert.ok(bitcoinLikeCoins.length > 0, "should have Bitcoin-like coins"); + bitcoinLikeCoins.forEach((coin) => { + assert.ok( + !ZcashTransaction.supportsCoin(coin) && !DashTransaction.supportsCoin(coin), + `${coin} should only be supported by Transaction`, + ); + }); + }); + + it("should partition all coins into exactly one family", function () { + coinNames.forEach((coin) => { + const zSupports = ZcashTransaction.supportsCoin(coin); + const dSupports = DashTransaction.supportsCoin(coin); + const bSupports = Transaction.supportsCoin(coin); + + const supportCount = [zSupports, dSupports, bSupports].filter(Boolean).length; + assert.strictEqual( + supportCount, + 1, + `${coin} should be supported by exactly one transaction class`, + ); + }); + }); +}); From 6a46f4b3536812f1bdd44e28329832e61b07a06e Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Thu, 16 Apr 2026 10:58:51 +0200 Subject: [PATCH 2/2] test(wasm-utxo): simplify fromHalfSignedLegacy test dependencies Replace utxolib network objects with coin-based utilities throughout tests for `fromHalfSignedLegacyTransaction`. Update helper functions to accept `CoinName` instead of `Network`, and use new coin utilities for mainnet checks and script type validation. - Replace `isSupportedNetwork` with `isSupportedCoin` using `CoinName` - Update `createHalfSignedP2msPsbt` to use coin-based utilities - Use `supportsScriptType` instead of network-based validation - Replace network list iteration with `coinNames` filtering - Remove `getCoinNameForNetwork` dependency - Clarify test descriptions for transaction instance handling Co-authored-by: llm-git --- .../fromHalfSignedLegacyTransaction.ts | 111 +++++++++--------- 1 file changed, 54 insertions(+), 57 deletions(-) diff --git a/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts b/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts index 449321cd4e7..f067e557189 100644 --- a/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts +++ b/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts @@ -11,38 +11,40 @@ */ import { describe, it } from "mocha"; import * as assert from "assert"; -import * as utxolib from "@bitgo/utxo-lib"; import { BitGoPsbt, type HydrationUnspent } from "../../js/fixedScriptWallet/BitGoPsbt.js"; import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; +import { supportsScriptType } from "../../js/fixedScriptWallet/index.js"; import { ChainCode } from "../../js/fixedScriptWallet/chains.js"; import { ECPair } from "../../js/ecpair.js"; -import { Transaction } from "../../js/transaction.js"; +import { Transaction, ZcashTransaction } from "../../js/transaction.js"; +import { coinNames, type CoinName, isMainnet } from "../../js/coinName.js"; import { getDefaultWalletKeys, getKeyTriple } from "../../js/testutils/keys.js"; -import { getCoinNameForNetwork } from "../networks.js"; const ZCASH_NU5_HEIGHT = 1687105; const p2msScriptTypes = ["p2sh", "p2shP2wsh", "p2wsh"] as const; -function isSupportedNetwork(n: utxolib.Network): boolean { - return utxolib.isMainnet(n) && n !== utxolib.networks.bitcoinsv && n !== utxolib.networks.ecash; +// Coins excluded from round-trip tests (use special handling or not supported) +const EXCLUDED_COINS: CoinName[] = ["bsv", "bcha", "zec"]; + +function isSupportedCoin(coin: CoinName): boolean { + return isMainnet(coin) && !EXCLUDED_COINS.includes(coin); } function createHalfSignedP2msPsbt( - network: utxolib.Network, + coinName: CoinName, valueOverride?: bigint, ): { psbt: BitGoPsbt; unspents: HydrationUnspent[] } { - const coinName = getCoinNameForNetwork(network); const rootWalletKeys = getDefaultWalletKeys(); const [userXprv] = getKeyTriple("default"); const supportedTypes = p2msScriptTypes.filter((scriptType) => - utxolib.bitgo.outputScripts.isSupportedScriptType(network, scriptType), + supportsScriptType(coinName, scriptType), ); - const isZcash = utxolib.getMainnet(network) === utxolib.networks.zcash; + const isZcash = coinName === "zec" || coinName === "tzec"; const psbt = isZcash - ? ZcashBitGoPsbt.createEmpty(coinName as "zec" | "tzec", rootWalletKeys, { + ? ZcashBitGoPsbt.createEmpty(coinName, rootWalletKeys, { version: 4, lockTime: 0, blockHeight: ZCASH_NU5_HEIGHT, @@ -79,11 +81,12 @@ describe("BitGoPsbt.fromHalfSignedLegacyTransaction", function () { // because BigInt::from(value_js).as_f64() calls JsValue::as_f64(), which // returns None for JS BigInt (it only works for JS Number). const rootWalletKeys = getDefaultWalletKeys(); - const { psbt, unspents } = createHalfSignedP2msPsbt(utxolib.networks.bitcoin); + const { psbt, unspents } = createHalfSignedP2msPsbt("btc"); const txBytes = psbt.getHalfSignedLegacyFormat(); + const tx = Transaction.fromBytes(txBytes, "btc"); assert.doesNotThrow(() => { - BitGoPsbt.fromHalfSignedLegacyTransaction(txBytes, "btc", rootWalletKeys, unspents); + BitGoPsbt.fromHalfSignedLegacyTransaction(tx, "btc", rootWalletKeys, unspents); }, "fromHalfSignedLegacyTransaction must not throw for valid JS BigInt values"); }); @@ -93,33 +96,29 @@ describe("BitGoPsbt.fromHalfSignedLegacyTransaction", function () { const rootWalletKeys = getDefaultWalletKeys(); // 21 million BTC in satoshis — the maximum possible UTXO value const maxSats = 21_000_000n * 100_000_000n; - const { psbt, unspents } = createHalfSignedP2msPsbt(utxolib.networks.bitcoin, maxSats); + const { psbt, unspents } = createHalfSignedP2msPsbt("btc", maxSats); const txBytes = psbt.getHalfSignedLegacyFormat(); + const tx = Transaction.fromBytes(txBytes, "btc"); assert.doesNotThrow(() => { - BitGoPsbt.fromHalfSignedLegacyTransaction(txBytes, "btc", rootWalletKeys, unspents); + BitGoPsbt.fromHalfSignedLegacyTransaction(tx, "btc", rootWalletKeys, unspents); }, "fromHalfSignedLegacyTransaction must handle large satoshi values"); }); }); describe("Round-trip: getHalfSignedLegacyFormat → fromHalfSignedLegacyTransaction", function () { - // Zcash uses a non-standard transaction format (version 4 overwintered) that - // fromHalfSignedLegacyTransaction does not support; skip it here. - const roundTripNetworks = utxolib - .getNetworkList() - .filter(isSupportedNetwork) - .filter((n) => utxolib.getMainnet(n) !== utxolib.networks.zcash); - - for (const network of roundTripNetworks) { - const networkName = utxolib.getNetworkName(network); - it(`${networkName}: reconstructed PSBT serializes without error`, function () { + // Supported coins for round-trip: all mainnet UTXO coins except special formats + const roundTripCoins = coinNames.filter(isSupportedCoin); + + for (const coinName of roundTripCoins) { + it(`${coinName}: reconstructed PSBT serializes without error`, function () { const rootWalletKeys = getDefaultWalletKeys(); - const coinName = getCoinNameForNetwork(network); - const { psbt, unspents } = createHalfSignedP2msPsbt(network); + const { psbt, unspents } = createHalfSignedP2msPsbt(coinName); const txBytes = psbt.getHalfSignedLegacyFormat(); + const tx = Transaction.fromBytes(txBytes, coinName); const reconstructed = BitGoPsbt.fromHalfSignedLegacyTransaction( - txBytes, + tx, coinName, rootWalletKeys, unspents, @@ -152,13 +151,14 @@ describe("BitGoPsbt.fromHalfSignedLegacyTransaction", function () { psbt.sign(userXprv); const txBytes = psbt.getHalfSignedLegacyFormat(); + const tx = Transaction.fromBytes(txBytes, "btc"); const unspents: HydrationUnspent[] = [ { chain: 0, index: 0, value: BigInt(10000) }, // wallet { pubkey: ecpair.publicKey, value: BigInt(1000) }, // replay protection ]; const reconstructed = BitGoPsbt.fromHalfSignedLegacyTransaction( - txBytes, + tx, "btc", rootWalletKeys, unspents, @@ -174,30 +174,31 @@ describe("BitGoPsbt.fromHalfSignedLegacyTransaction", function () { }); describe("Zcash legacy format round-trip", function () { - it("should reject Zcash via dynamic dispatch in fromHalfSignedLegacyTransaction", function () { - // With dynamic dispatch, fromHalfSignedLegacyTransaction now validates the transaction type - // and rejects Zcash early with a clear error message, directing to ZcashBitGoPsbt.createEmpty(). + it("should reject Zcash via type check in fromHalfSignedLegacyTransaction", function () { + // fromHalfSignedLegacyTransaction validates the transaction type at call time + // and rejects Zcash with a clear error message. const rootWalletKeys = getDefaultWalletKeys(); - const { psbt: zcashPsbt, unspents } = createHalfSignedP2msPsbt(utxolib.networks.zcash); + const { psbt: zcashPsbt, unspents } = createHalfSignedP2msPsbt("zec"); // Step 1: Extract Zcash PSBT as legacy format const txBytes = zcashPsbt.getHalfSignedLegacyFormat(); assert.ok(txBytes.length > 0, "ZcashBitGoPsbt.getHalfSignedLegacyFormat() produces bytes"); - // Step 2: Call fromHalfSignedLegacyTransaction with Zcash bytes - // Expected: Throws clear error after detecting Zcash transaction via dynamic dispatch - assert.throws( - () => { - BitGoPsbt.fromHalfSignedLegacyTransaction(txBytes, "zec", rootWalletKeys, unspents); - }, - /Use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction\(\) for Zcash transactions/, - ); + // Step 2: Parse the transaction (will be ZcashTransaction) + const tx = Transaction.fromBytes(txBytes, "zec"); + assert.ok(tx instanceof ZcashTransaction, "Parsed transaction is ZcashTransaction"); + + // Step 3: Call fromHalfSignedLegacyTransaction with Zcash transaction + // Expected: Throws clear error after detecting Zcash transaction + assert.throws(() => { + BitGoPsbt.fromHalfSignedLegacyTransaction(tx, "zec", rootWalletKeys, unspents); + }, /Use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction\(\) for Zcash transactions/); }); it("should round-trip Zcash PSBT via ZcashBitGoPsbt.fromHalfSignedLegacyTransaction (with blockHeight)", function () { // This test verifies the round-trip: create Zcash PSBT → extract legacy format → reconstruct PSBT const rootWalletKeys = getDefaultWalletKeys(); - const { psbt, unspents } = createHalfSignedP2msPsbt(utxolib.networks.zcash); + const { psbt, unspents } = createHalfSignedP2msPsbt("zec"); // Step 1: Extract half-signed legacy format (this is what would be transmitted) const legacyBytes = psbt.getHalfSignedLegacyFormat(); @@ -227,7 +228,7 @@ describe("BitGoPsbt.fromHalfSignedLegacyTransaction", function () { it("should round-trip Zcash PSBT via ZcashBitGoPsbt.fromHalfSignedLegacyTransaction (with consensusBranchId)", function () { // This test verifies the round-trip with explicit consensus branch ID instead of block height const rootWalletKeys = getDefaultWalletKeys(); - const { psbt, unspents } = createHalfSignedP2msPsbt(utxolib.networks.zcash); + const { psbt, unspents } = createHalfSignedP2msPsbt("zec"); // Step 1: Extract half-signed legacy format const legacyBytes = psbt.getHalfSignedLegacyFormat(); @@ -254,26 +255,22 @@ describe("BitGoPsbt.fromHalfSignedLegacyTransaction", function () { assert.ok(serialized.length > 0, "Reconstructed Zcash PSBT serializes without error"); }); - it("should accept pre-decoded transaction instance to avoid re-parsing", function () { - // Dynamic dispatch enhancement: fromHalfSignedLegacyTransaction now accepts - // either txBytes OR a pre-decoded ITransaction instance + it("should accept pre-decoded transaction instance", function () { + // fromHalfSignedLegacyTransaction accepts a pre-decoded Transaction instance. + // This is more efficient than parsing bytes twice. const rootWalletKeys = getDefaultWalletKeys(); - const { psbt, unspents } = createHalfSignedP2msPsbt(utxolib.networks.bitcoin); + const { psbt, unspents } = createHalfSignedP2msPsbt("btc"); const txBytes = psbt.getHalfSignedLegacyFormat(); - // Method 1: Pass raw bytes (uses dynamic dispatch internally) - const psbt1 = BitGoPsbt.fromHalfSignedLegacyTransaction( - txBytes, - "btc", - rootWalletKeys, - unspents, - ); + // Parse transaction once and pass the instance + const tx = Transaction.fromBytes(txBytes, "btc"); + const psbt1 = BitGoPsbt.fromHalfSignedLegacyTransaction(tx, "btc", rootWalletKeys, unspents); - // Method 2: Pre-decode transaction and pass instance (avoids re-parsing) - const tx = Transaction.fromBytes(txBytes); - const psbt2 = BitGoPsbt.fromHalfSignedLegacyTransaction(tx, "btc", rootWalletKeys, unspents); + // Parse again to compare + const tx2 = Transaction.fromBytes(txBytes, "btc"); + const psbt2 = BitGoPsbt.fromHalfSignedLegacyTransaction(tx2, "btc", rootWalletKeys, unspents); - // Both methods should produce equivalent results + // Both should produce equivalent results assert.strictEqual(psbt1.inputCount(), psbt2.inputCount(), "Same input count"); assert.strictEqual(psbt1.outputCount(), psbt2.outputCount(), "Same output count"); assert.deepStrictEqual(psbt1.serialize(), psbt2.serialize(), "Identical serialization");