From 6222f4e7d05d4f3d6e6090f3b4802a60a7f4e8f3 Mon Sep 17 00:00:00 2001 From: Ranjna Ganesh Ram Date: Tue, 27 Oct 2026 21:01:50 +0530 Subject: [PATCH 1/2] feat(wasm-utxo): support v6 transaction size calculation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make extract_unsigned_zcash_transaction v6-aware so get_unsigned_tx_bytes (the JS getUnsignedTransaction()) can size and diff unsigned Ironwood transactions the way utxo-lib does for v4 — unblocking the wallet-platform pending-approval fee check, which currently has to fall back to the serialized-PSBT length because the wasm layer traps unreachable on Zcash PSBTs. For a v6 PSBT the embedded unsigned v6 transaction is serialized via encode_v6_transaction — the same construction unsigned_v6_txid commits to: transparent skeleton plus current action data (bundle-less for transparent-only PSBTs). Two unsigned-state compromises, both size-honest and txid-neutral: per-action spend-auth slots are padded with zero signatures and the Halo2 proof slot with zero bytes of orchard::Proof::expected_proof_size(n_actions) — each a fixed wire slot the finalized transaction carries anyway, so the byte length equals the final form's (an empty proof slot would under-price a shielded spend by several KB in fee checks built on this size). The proof itself is unknowable before combine_ironwood_proof; ZIP-244 commits to neither slot, so the txid is unchanged. compute_txid keeps refusing a v6 PSBT, now via an explicit guard with its own rationale: it applies double-SHA256 (ZIP-243 v4 semantics) to the extracted bytes, and hashing v6 wire bytes that way would produce a meaningless value — v6 txids are ZIP-244 via unsigned_v6_txid/v6_txid. Ticket: CSHLD-2073 --- .../bitgo_psbt/zcash/mod.rs | 58 +++++++++- .../bitgo_psbt/zcash/tests/v6.rs | 103 +++++++++++++++++- 2 files changed, 151 insertions(+), 10 deletions(-) diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/mod.rs index 649354d7cbf..69a1ee6153f 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/mod.rs @@ -183,8 +183,9 @@ impl ZcashBitGoPsbt { &self, tx: &Transaction, ) -> Result, super::DeserializeError> { - // Chokepoint for the v4 wire encoding — also covers - // `extract_unsigned_zcash_transaction`, `compute_txid` and `serialize`. + // Chokepoint for the v4 wire encoding — also covers `compute_txid` and `serialize`. + // `extract_unsigned_zcash_transaction` is v6-aware and routes around this chokepoint + // (see its doc comment). if self.is_ironwood_v6() { return Err(super::DeserializeError::Network( V6_NOT_SUPPORTED_BY_V4_PATH.to_string(), @@ -205,7 +206,47 @@ impl ZcashBitGoPsbt { } /// Reconstruct the unsigned Zcash transaction bytes from the PSBT + /// + /// v6-aware, unlike the v4-only extraction paths (`compute_txid`, `extract_tx`) which keep + /// refusing a v6 PSBT: for a v6 (Ironwood) PSBT this serializes the embedded unsigned v6 + /// transaction — the same construction [`Self::unsigned_v6_txid`] commits to (the + /// transparent skeleton plus the current action data, or a bundle-less v6 tx for + /// transparent-only PSBTs) — so `get_unsigned_tx_bytes` can size and diff unsigned v6 + /// transactions the way utxo-lib does for v4. + /// + /// Two unsigned-state compromises, both size-honest and txid-neutral: the per-action + /// spend-auth slots are padded with zero signatures and the Halo2 proof slot with + /// zero bytes of `orchard::Proof::expected_proof_size(n_actions)` — each a fixed wire + /// slot the finalized transaction carries anyway, so the byte length equals the + /// final form's (what the fee/size calculation consumes; an empty proof slot would + /// under-price a shielded spend by several KB). The proof itself is unknowable + /// before `combine_ironwood_proof`; ZIP-244 commits to neither slot, so the txid + /// is unchanged. pub fn extract_unsigned_zcash_transaction(&self) -> Result, super::DeserializeError> { + if self.is_ironwood_v6() { + let no_shielded_output = self + .require_no_shielded_output_ever_added() + .map_err(super::DeserializeError::Network)?; + let bundle = if no_shielded_output { + None + } else { + let mut action_data = self + .ironwood_action_data() + .map_err(super::DeserializeError::Network)?; + action_data.spend_auth_sigs = vec![[0u8; 64]; action_data.actions.len()]; + // Size-honest proof slot: the finalized transaction carries + // expected_proof_size(n_actions) bytes here. The proof is unknowable + // before combine_ironwood_proof, but the slot length is not. + action_data.proof = + vec![0u8; orchard::Proof::expected_proof_size(action_data.actions.len())]; + Some(action_data) + }; + let tx = self + .to_v6_transaction(self.psbt.unsigned_tx.clone(), bundle) + .map_err(super::DeserializeError::Network)?; + return crate::zcash::v6::encode_v6_transaction(&tx) + .map_err(|e| super::DeserializeError::Network(e.to_string())); + } self.serialize_as_zcash_transaction(&self.psbt.unsigned_tx) } @@ -274,11 +315,20 @@ impl ZcashBitGoPsbt { crate::zcash::transaction::encode_zcash_transaction_parts(&parts) .map_err(super::DeserializeError::Network) } - /// Compute the transaction ID for the unsigned Zcash transaction /// - /// The txid is the double SHA256 of the full Zcash transaction bytes. + /// The txid is the double SHA256 of the full Zcash transaction bytes — v4 (ZIP-243) + /// semantics only. Still refuses a v6 (Ironwood) PSBT even though + /// [`Self::extract_unsigned_zcash_transaction`] is v6-aware: a v6 txid is ZIP-244 + /// (BLAKE2b), not double-SHA256, so hashing the extracted v6 wire bytes here would + /// produce a meaningless value. Use [`Self::unsigned_v6_txid`]/[`Self::v6_txid`] + /// (the JS `getId()` path) for v6. pub fn compute_txid(&self) -> Result<[u8; 32], super::DeserializeError> { + if self.is_ironwood_v6() { + return Err(super::DeserializeError::Network( + V6_NOT_SUPPORTED_BY_V4_PATH.to_string(), + )); + } use miniscript::bitcoin::hashes::{sha256d, Hash}; let tx_bytes = self.extract_unsigned_zcash_transaction()?; let hash = sha256d::Hash::hash(&tx_bytes); diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs index dee9516dd70..4ea95800570 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs @@ -379,16 +379,14 @@ fn combine_uses_only_required_signatures() { /// The v4 code paths refuse a v6 PSBT instead of emitting a v4-shaped transaction (which would /// be unbroadcastable) or a ZIP-243 signature (which could never verify). /// -/// `serialize()` is deliberately excluded: it is v6-aware (routes to `serialize_v6()` when -/// `is_ironwood_v6()`), so a v6 PSBT round-trips through the standard `serialize`/`fromBytes` -/// path without the v4 tx-replacement dance — see `serialize()`'s doc comment. +/// `serialize()` and `extract_unsigned_zcash_transaction()` are deliberately excluded: both are +/// v6-aware (they route to the v6 encoder when `is_ironwood_v6()`), so a v6 PSBT round-trips +/// through the standard `serialize`/`fromBytes` path and yields its embedded unsigned v6 +/// transaction — see their doc comments. #[test] fn v4_paths_reject_a_v6_psbt() { let z = build_shield_psbt("v6_v4_guard"); for err in [ - z.extract_unsigned_zcash_transaction() - .unwrap_err() - .to_string(), z.compute_txid().unwrap_err().to_string(), z.clone().extract_tx().unwrap_err().to_string(), ] { @@ -418,6 +416,99 @@ fn v4_paths_reject_a_v6_psbt() { ); } +/// `extract_unsigned_zcash_transaction` is v6-aware: for a transparent-only v6 PSBT (the shape +/// IMS builds for post-NU7 t->t sends) it returns the bundle-less unsigned v6 transaction, +/// whose ZIP-244 txid equals `unsigned_v6_txid()` — the same invariant the JS-level +/// `getUnsignedTransaction()` relies on for sizing and diffing. +#[test] +fn extract_unsigned_v6_tx_transparent_only_round_trips() { + let seed = "v6_unsigned_transparent"; + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys(seed)); + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + + let mut psbt = + BitGoPsbt::new_zcash_v6_at_height(Network::ZcashTestnet, &wallet_keys, nu6_3, None, None) + .unwrap(); + psbt.add_wallet_input( + Txid::from_byte_array([0x44u8; 32]), + 0, + 200_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + psbt.add_wallet_output(0, 1, 199_900_000, &wallet_keys) + .unwrap(); + + let BitGoPsbt::Zcash(z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + assert!(z.require_no_shielded_output_ever_added().unwrap()); + + let raw = z.extract_unsigned_zcash_transaction().unwrap(); + let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap(); + assert_eq!(tx.transparent.input.len(), 1); + assert_eq!(tx.transparent.output.len(), 1); + assert!( + tx.transparent.input[0].script_sig.is_empty(), + "unsigned: no signatures yet" + ); + assert!( + tx.ironwood_bundle.is_none(), + "transparent-only v6 tx has no Ironwood bundle" + ); + assert_eq!( + crate::zcash::v6::compute_v6_txid(&tx), + z.unsigned_v6_txid().unwrap(), + "serialized bytes must commit to the same ZIP-244 txid as unsigned_v6_txid" + ); +} + +/// Same v6-awareness for a shielding v6 PSBT: extraction succeeds pre-signing and pre-proof +/// (unlike `extract_transparent_only_v6_tx`/`combine_ironwood_proof`) and is deterministic. +/// +/// Size honesty: the zero-padded spend-auth slots and proof slot make the unsigned bytes +/// structurally complete — they round-trip the wire decoder, the proof field carries exactly +/// `Proof::expected_proof_size(n_actions)` bytes (what the finalized transaction will, so a +/// fee check on these bytes prices the Halo2 proof correctly instead of under-counting it +/// by several KB), and the ZIP-244 txid still equals `unsigned_v6_txid()` since ZIP-244 +/// commits to neither slot. +#[test] +fn extract_unsigned_v6_tx_shielded_succeeds_pre_proof() { + let z = build_shield_psbt("v6_unsigned_shield"); + assert!(!z.require_no_shielded_output_ever_added().unwrap()); + + let raw = z.extract_unsigned_zcash_transaction().unwrap(); + assert!(!raw.is_empty(), "unsigned v6 tx bytes must be non-empty"); + let raw_again = z.extract_unsigned_zcash_transaction().unwrap(); + assert_eq!(raw, raw_again, "extraction must be deterministic"); + + let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap(); + let bundle = tx + .ironwood_bundle + .as_ref() + .expect("shielded extraction carries the Ironwood bundle"); + assert_eq!( + bundle.proof.len(), + orchard::Proof::expected_proof_size(bundle.actions.len()), + "proof slot must be padded to the finalized size, not empty" + ); + assert!( + bundle.spend_auth_sigs.iter().all(|sig| sig == &[0u8; 64]), + "unsigned: spend-auth slots are zero-padded" + ); + assert!( + bundle.binding_sig == [0u8; 64], + "unsigned: binding signature slot is zero" + ); + assert_eq!( + crate::zcash::v6::compute_v6_txid(&tx), + z.unsigned_v6_txid().unwrap(), + "padded slots are txid-neutral: ZIP-244 commits to neither" + ); +} + /// A client passes a Unified Address as the recipient on a legacy v4 PSBT; /// `add_transparent_output` resolves its transparent receiver to a scriptPubkey, verifies it /// against the caller-supplied script, stores the UA, and adds the ordinary transparent From 38590024e311121f05e8b266ef31c3f43520d4bf Mon Sep 17 00:00:00 2001 From: Ranjna Ganesh Ram Date: Tue, 27 Oct 2026 21:01:55 +0530 Subject: [PATCH 2/2] build(wasm-utxo): unblock CI on new RustSec advisories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctutils 0.4.3 -> 0.4.6 fixes RUSTSEC-2026-0385 (patched release in the ^0.4 line, lockfile only). RUSTSEC-2026-0387 (crypto-bigint constant-time issues) is ignored in deny.toml: the patched version (>=0.7.6) is unreachable behind k256 0.13.4 (musig2/secp), and wasm-utxo's k256 usage does not expose the affected operations to attacker-controlled input. Master fails the same check — both advisories were published after the last green run, so this is advisory-DB drift, not a code change. Ticket: CSHLD-2073 --- packages/wasm-utxo/Cargo.lock | 4 ++-- packages/wasm-utxo/deny.toml | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/wasm-utxo/Cargo.lock b/packages/wasm-utxo/Cargo.lock index edff78a4e1d..6c4edb741fb 100644 --- a/packages/wasm-utxo/Cargo.lock +++ b/packages/wasm-utxo/Cargo.lock @@ -840,9 +840,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.3" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" dependencies = [ "cmov", ] diff --git a/packages/wasm-utxo/deny.toml b/packages/wasm-utxo/deny.toml index b16fa95048c..89dbb2c3346 100644 --- a/packages/wasm-utxo/deny.toml +++ b/packages/wasm-utxo/deny.toml @@ -1,3 +1,12 @@ +# RustSec advisories: the patched crypto-bigint (>=0.7.6, RUSTSEC-2026-0387) is unreachable +# behind k256 0.13.4 (musig2/secp), and wasm-utxo's use of k256 ECDSA does not expose the +# affected constant-time operations to attacker-controlled input — ignore until k256 moves +# to elliptic-curve 0.14+, then delete this entry (cargo-deny warns on unmatched ignores). +# ctutils (RUSTSEC-2026-0385) is fixed by its 0.4.6 lockfile bump, no ignore needed. +[advisories] +ignore = [ + { id = "RUSTSEC-2026-0387", reason = "crypto-bigint >=0.7.6 is unreachable behind k256 0.13.4 (musig2/secp); the affected constant-time ops are not exposed on attacker-controlled input in wasm-utxo's k256 usage" }, +] # Deny multiple versions of dependencies [bans] multiple-versions = "deny"