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 c3d5634146e..1c863cf3f52 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 @@ -160,6 +160,8 @@ pub enum ParseTransactionError { FeeCalculation, /// Failed to read the shielded (Ironwood) output's value ShieldedOutput(String), + /// Failed to read the shielded (Ironwood) spends' values, or their total overflowed + ShieldedSpend(String), } impl std::fmt::Display for ParseTransactionError { @@ -186,6 +188,9 @@ impl std::fmt::Display for ParseTransactionError { ParseTransactionError::ShieldedOutput(error) => { write!(f, "Shielded output: {}", error) } + ParseTransactionError::ShieldedSpend(error) => { + write!(f, "Shielded spend: {}", error) + } } } } @@ -2894,6 +2899,27 @@ impl BitGoPsbt { .collect() } + /// Total value (zatoshi) of this PSBT's real shielded (Ironwood) spends — `0` if this isn't a + /// v6 (Ironwood) PSBT, or no shielded spend has been added. Like the shielded outputs, the + /// spends live in the proprietary-map PCZT, invisible to transparent-input parsing, so + /// `parse_transaction_with_wallet_keys` folds this into its input total. + fn shielded_spend_total(&self) -> Result { + let BitGoPsbt::Zcash(z, _) = self else { + return Ok(0); + }; + z.ironwood_shielded_spends_info() + .map_err(ParseTransactionError::ShieldedSpend)? + .iter() + .try_fold(0u64, |total, spend| { + total.checked_add(spend.value).ok_or_else(|| { + ParseTransactionError::ShieldedSpend(format!( + "action {}: value overflow", + spend.action_index + )) + }) + }) + } + /// Calculate total input value from parsed inputs /// /// # Returns @@ -3654,7 +3680,13 @@ impl BitGoPsbt { let mut parsed_outputs = self.parse_outputs(wallet_keys, paygo_pubkeys)?; // Calculate totals - let total_input_value = Self::sum_input_values(&parsed_inputs)?; + // Shielded spends fund the transaction alongside the transparent inputs (unshielding / + // mixed transactions); without them `miner_fee` underflows. + let total_input_value = Self::sum_input_values(&parsed_inputs)? + .checked_add(self.shielded_spend_total()?) + .ok_or_else(|| { + ParseTransactionError::ShieldedSpend("total input value overflow".to_string()) + })?; let (mut total_output_value, mut spend_amount) = Self::sum_output_values(&psbt.unsigned_tx.output, &parsed_outputs)?; 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 1f090a66750..5d15c58d4be 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 @@ -668,21 +668,23 @@ impl ZcashBitGoPsbt { } } -// ---- Zcash v6 (Ironwood / NU6.3) shielding ---- +// ---- Zcash v6 (Ironwood / NU6.3) ---- // -// A v6 shielding PSBT keeps its transparent inputs/outputs in `psbt.unsigned_tx` (a version-6 -// rust-bitcoin `Transaction`) exactly like the v4 path, and carries the shielded side as an -// `orchard` PCZT in the proprietary map, under the `BITGO_ZEC_V6` namespace's `IronwoodPczt` -// subtype. The v6 header params (`version_group_id`, `expiry_height`) live under that same -// namespace's `VersionGroupId`/`ExpiryHeight` subtypes; `version_group_id`'s presence marks the -// PSBT as v6 so it round-trips through a plain PSBT serialization without the v4 tx-replacement -// dance. +// A v6 PSBT keeps its transparent inputs/outputs in `psbt.unsigned_tx` (a version-6 rust-bitcoin +// `Transaction`) exactly like the v4 path, and carries the shielded side as an `orchard` PCZT in +// the proprietary map, under the `BITGO_ZEC_V6` namespace's `IronwoodPczt` subtype. The v6 header +// params (`version_group_id`, `expiry_height`) live under that same namespace's +// `VersionGroupId`/`ExpiryHeight` subtypes; `version_group_id`'s presence marks the PSBT as v6 so +// it round-trips through a plain PSBT serialization without the v4 tx-replacement dance. // -// The lifecycle mirrors the microservice PSBT flow: the server builds (Constructor, no keys), the -// client + HSM sign the transparent inputs over the ZIP-244 sighash this module exposes, the signed -// PSBT goes to the external proof service for the Halo2 `zkproof`, and `combine_ironwood_proof` -// finalizes the transparent inputs and splices in the proof + shielded bundle to produce the -// broadcast-ready v6 transaction. See `crate::zcash::ironwood_build` for the PCZT role bridge. +// The lifecycle mirrors the microservice PSBT flow: the server builds (Constructor, no keys). For +// real spends, the signing service then delivers each spend's randomized `rk` +// (`replace_ironwood_spend_rk`) before any sighash is fixed, and later its spend-auth signature +// (`apply_ironwood_spend_signature`). The client + HSM sign the transparent inputs over the +// ZIP-244 sighash this module exposes, the signed PSBT goes to the external proof service for the +// Halo2 `zkproof`, and `combine_ironwood_proof` finalizes the transparent inputs and splices in the +// proof + shielded bundle to produce the broadcast-ready v6 transaction. See +// `crate::zcash::ironwood_build` for the PCZT role bridge. /// One requested Ironwood shielded output, as passed to /// [`ZcashBitGoPsbt::add_ironwood_outputs`]. @@ -709,6 +711,76 @@ pub struct IronwoodOutputRequest { pub unified_address: Option, } +/// One Ironwood note to spend, as passed to +/// [`ZcashBitGoPsbt::add_ironwood_spends`]/[`ZcashBitGoPsbt::add_ironwood_actions`]. +/// +/// `recipient`/`value`/`rho`/`rseed`/`cmx` are note plaintext from the wallet's own note record; +/// `witness` comes from the note-commitment tree. The FVK is passed transiently to the Constructor +/// (to check note ownership and derive the nullifier) and omitted from the serialized PCZT; its +/// `ak` is used as the placeholder `rk` until the signing service delivers the randomized one +/// (see [`ZcashBitGoPsbt::replace_ironwood_spend_rk`]). +#[derive(Clone)] +pub struct IronwoodSpendRequest { + /// Raw full viewing key, 96 bytes: `ak ‖ nk ‖ rivk` (orchard's `FullViewingKey::to_bytes` + /// layout). All spends in one bundle must share the same FVK. + pub fvk: crate::zcash::ironwood_build::FvkBytes, + /// The spent note's recipient: 43-byte raw Orchard/Ironwood address. + pub recipient: crate::zcash::ironwood_build::OrchardAddressBytes, + /// Note value in zatoshi. + pub value: u64, + /// The spent note's `rho` — the nullifier of the action that created the note. + pub rho: [u8; 32], + /// The spent note's random seed. + pub rseed: [u8; 32], + /// The note commitment the spent note's on-chain action carried; the reconstructed note must + /// commit to exactly this. + pub cmx: [u8; 32], + /// Merkle witness for `cmx` at the bundle's anchor. + pub witness: crate::zcash::ironwood_build::IronwoodWitness, +} + +/// One real spend in the bundle, for the caller's accounting. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IronwoodSpendInfo { + /// Action index the spend landed at in the bundle. + pub action_index: usize, + /// Note value in zatoshi. + pub value: u64, + /// The nullifier this spend reveals (committed in the action data). + pub nullifier: [u8; 32], +} + +/// The spend/output/fee accounting view of the stored PCZT: real spends, real outputs, the +/// bundle's value balance, and the transaction-level fee. +/// +/// `BundleType::UNPADDED` ⇒ no padding actions: each action pairs one real spend with one real +/// output, one real spend with a dummy output (spend-only), or a dummy spend with one real +/// output (output-only). A spend is fabricated iff it carries a `dummy_sk`; an output is +/// fabricated iff it is zero-valued and paired with a real spend (see +/// [`ZcashBitGoPsbt::ironwood_shielded_outputs_info`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IronwoodBundleInfo { + pub spends: Vec, + /// `(action_index, value, recipient)` per real output — the same shape + /// [`ZcashBitGoPsbt::ironwood_shielded_outputs_info`] returns. + pub outputs: Vec<( + usize, + u64, + crate::zcash::ironwood_build::OrchardAddressBytes, + )>, + /// The bundle's value balance in zatoshi: `Σv_spend − Σv_out` (negative when the shielded + /// pool gains value). + pub value_balance: i64, + /// Total transparent-input value in zatoshi. + pub transparent_in: i64, + /// Total transparent-output value in zatoshi. + pub transparent_out: i64, + /// `(transparent_in + Σv_spend) − (transparent_out + Σv_out)` in zatoshi — the fee. A + /// negative value means the transaction does not conserve value; + /// [`ZcashBitGoPsbt::combine_ironwood_proof`] rejects it. + pub fee: i64, +} + impl ZcashBitGoPsbt { /// Create an empty Zcash **v6 (Ironwood)** shielding PSBT, with the consensus branch id resolved /// from `block_height`, which must be at or after NU6.3 activation. @@ -841,64 +913,11 @@ impl ZcashBitGoPsbt { anchor: &crate::zcash::ironwood_build::AnchorBytes, rng: R, ) -> Result, String> { - if super::propkv::get_ironwood_pczt(&self.psbt).is_some() { - return Err( - "an Ironwood shielded output is already present; only one call is supported" - .to_string(), - ); - } if requests.is_empty() { return Err("at least one Ironwood output is required".to_string()); } - for req in requests { - let Some(ua) = &req.unified_address else { - continue; - }; - let parsed = crate::zcash::unified_address::UnifiedAddress::parse( - ua, - self.network.to_coin_name(), - ) - .map_err(|e| format!("invalid unified_address: {e}"))?; - let orchard = parsed - .orchard_receiver() - .map_err(|e| format!("invalid unified_address: {e}"))? - .ok_or_else(|| { - "unified_address has no Orchard receiver, but recipient is an Orchard address" - .to_string() - })?; - if orchard.as_slice() != req.recipient.as_slice() { - return Err( - "unified_address's Orchard receiver does not match recipient".to_string(), - ); - } - } - - let specs: Vec = requests - .iter() - .map(|req| crate::zcash::ironwood_build::IronwoodOutputSpec { - recipient: req.recipient, - amount: req.amount, - ovk: req.ovk, - memo: req.memo, - }) - .collect(); - let (pczt, action_indices) = - crate::zcash::ironwood_build::construct_shield_pczt_multi(&specs, anchor, rng) - .map_err(|e| e.to_string())?; - let bytes = - crate::zcash::ironwood_pczt::serialize_pczt(&pczt).map_err(|e| e.to_string())?; - super::propkv::set_ironwood_pczt(&mut self.psbt, bytes); - // Clear any UAs left over from a previous batch (this can run again on a PSBT whose PCZT - // was previously extracted — mark_ironwood_extracted/take_ironwood_pczt drop only the PCZT - // key, not these) before writing the new batch's, so a since-gone action index's stale UA - // can never be silently misattributed to a different recipient in the new bundle. - super::propkv::clear_ironwood_unified_addresses(&mut self.psbt); - for (req, &action_index) in requests.iter().zip(action_indices.iter()) { - if let Some(ua) = &req.unified_address { - super::propkv::set_ironwood_unified_address(&mut self.psbt, action_index, ua); - } - } - Ok(action_indices) + let (_, output_indices) = self.add_ironwood_actions(&[], requests, anchor, rng)?; + Ok(output_indices) } /// Constructor role: build a single shielded output as an orchard PCZT and store it in the @@ -929,6 +948,133 @@ impl ZcashBitGoPsbt { Ok(action_indices[0]) } + /// Constructor role: build an orchard PCZT from real Ironwood spends and/or requested shielded + /// outputs, and store it in the PSBT — the general form of [`Self::add_ironwood_outputs`], + /// covering unshielding (spends only), shielded-to-shielded transfer, and shielding (outputs + /// only — what [`Self::add_ironwood_outputs`] delegates to). + /// + /// Only one call to this (or [`Self::add_ironwood_outputs`]/[`Self::add_ironwood_spends`]) is + /// supported per PSBT — calling it again is an error rather than a silent overwrite of the + /// first batch (whose value the transparent side would still be funding). Multiple spends and + /// outputs in one transaction must all be passed in this single call. + /// + /// The returned index vectors give, for each `spends[i]`/`outputs[i]`, the action index it + /// landed at in the bundle — the orchard builder reorders actions (see + /// [`crate::zcash::ironwood_build::construct_spend_pczt_multi`]), so request order does not + /// equal action order. + /// + /// Real spends leave the placeholder `rk` (= the spend's `ak`) in the stored PCZT. + pub fn add_ironwood_actions( + &mut self, + spends: &[IronwoodSpendRequest], + outputs: &[IronwoodOutputRequest], + anchor: &crate::zcash::ironwood_build::AnchorBytes, + rng: R, + ) -> Result<(Vec, Vec), String> { + use crate::zcash::{ironwood_build, ironwood_pczt}; + + if super::propkv::get_ironwood_pczt(&self.psbt).is_some() { + return Err( + "an Ironwood PCZT is already present; only one call is supported".to_string(), + ); + } + if spends.is_empty() && outputs.is_empty() { + return Err("at least one Ironwood spend or output is required".to_string()); + } + if !spends.is_empty() { + for (i, req) in outputs.iter().enumerate() { + if req.ovk.is_some() { + return Err(format!( + "output {i}: an explicit ovk is not supported in a bundle with real \ + spends; the spending FVK's external ovk is used" + )); + } + } + } + for req in outputs { + if let Some(ua) = &req.unified_address { + self.check_unified_address_matches_recipient(ua, &req.recipient)?; + } + } + + let spend_specs: Vec = spends + .iter() + .map(|req| ironwood_build::IronwoodSpendSpec { + fvk: req.fvk, + recipient: req.recipient, + value: req.value, + rho: req.rho, + rseed: req.rseed, + cmx: req.cmx, + witness: req.witness, + }) + .collect(); + let output_specs: Vec = outputs + .iter() + .map(|req| ironwood_build::IronwoodOutputSpec { + recipient: req.recipient, + amount: req.amount, + ovk: req.ovk, + memo: req.memo, + }) + .collect(); + let (pczt, spend_indices, output_indices) = + ironwood_build::construct_spend_pczt_multi(&spend_specs, &output_specs, anchor, rng) + .map_err(|e| e.to_string())?; + let bytes = ironwood_pczt::serialize_pczt(&pczt).map_err(|e| e.to_string())?; + super::propkv::set_ironwood_pczt(&mut self.psbt, bytes); + // Clear any UAs left over from a previous batch (this can run again on a PSBT whose PCZT + // was previously extracted — mark_ironwood_extracted/take_ironwood_pczt drop only the + // PCZT key, not these) before writing the new batch's, so a since-gone action index's + // stale UA can never be silently misattributed to a different recipient in the new bundle. + super::propkv::clear_ironwood_unified_addresses(&mut self.psbt); + for (req, &action_index) in outputs.iter().zip(output_indices.iter()) { + if let Some(ua) = &req.unified_address { + super::propkv::set_ironwood_unified_address(&mut self.psbt, action_index, ua); + } + } + Ok((spend_indices, output_indices)) + } + + /// Reject a caller-supplied Unified Address whose Orchard receiver is not exactly `recipient`: + /// it is stored verbatim and later returned by output parsing in place of `recipient`. + fn check_unified_address_matches_recipient( + &self, + ua: &str, + recipient: &crate::zcash::ironwood_build::OrchardAddressBytes, + ) -> Result<(), String> { + let parsed = + crate::zcash::unified_address::UnifiedAddress::parse(ua, self.network.to_coin_name()) + .map_err(|e| format!("invalid unified_address: {e}"))?; + let orchard = parsed + .orchard_receiver() + .map_err(|e| format!("invalid unified_address: {e}"))? + .ok_or_else(|| { + "unified_address has no Orchard receiver, but recipient is an Orchard address" + .to_string() + })?; + if orchard.as_slice() != recipient.as_slice() { + return Err("unified_address's Orchard receiver does not match recipient".to_string()); + } + Ok(()) + } + + /// Constructor role: build real Ironwood spends (no shielded outputs) as an orchard PCZT and + /// store it in the PSBT. A thin wrapper over [`Self::add_ironwood_actions`]; see it for the + /// general contract, including the "only one call per PSBT" rule. + pub fn add_ironwood_spends( + &mut self, + requests: &[IronwoodSpendRequest], + anchor: &crate::zcash::ironwood_build::AnchorBytes, + rng: R, + ) -> Result, String> { + if requests.is_empty() { + return Err("at least one Ironwood spend is required".to_string()); + } + let (spend_indices, _) = self.add_ironwood_actions(requests, &[], anchor, rng)?; + Ok(spend_indices) + } + /// Add a plain transparent output. Just `script`/`value` (`unified_address: None`) is exactly /// the legacy `add_output` behavior, unchanged, on either v4 or v6. /// @@ -1145,6 +1291,155 @@ impl ZcashBitGoPsbt { Ok(()) } + /// Whether `pczt` has real spends. Real spends have no `dummy_sk`; the builder marks + /// fabricated padding spends with one. Other note plaintext fields may also be present on + /// dummies, so they are not reliable discriminators. + fn bundle_has_real_spends(pczt: &orchard::pczt::Bundle) -> bool { + pczt.actions() + .iter() + .any(|a| a.spend().dummy_sk().is_none()) + } + + /// Whether a real spend's `rk` has been replaced by the signing service's randomized one + /// (`false` while it is still the Constructor's placeholder, or carries no state marker). + fn spend_rk_replaced(spend: &orchard::pczt::Spend) -> Result { + match spend + .proprietary() + .get(crate::zcash::ironwood_pczt::SPEND_RK_STATE_PROPRIETARY_KEY) + .map(|state| state.as_slice()) + { + Some([1]) => Ok(true), + Some([0]) | None => Ok(false), + Some(_) => Err("invalid Ironwood spend rk state marker".to_string()), + } + } + + /// Whether any real spend in `pczt` is still marked with the Constructor's placeholder `rk`. + fn bundle_has_unpatched_spend_rk(pczt: &orchard::pczt::Bundle) -> Result { + for action in pczt.actions().iter() { + let spend = action.spend(); + if spend.dummy_sk().is_none() && !Self::spend_rk_replaced(spend)? { + return Ok(true); + } + } + Ok(false) + } + + /// Ingest the randomized `rk = ak.randomize(alpha)` for one real spend, replacing the + /// placeholder the Constructor left in the stored PCZT. + /// + /// Must run before any signature over the ZIP-244 digest exists — the digest commits `rk` — so + /// this is rejected once a transparent signature has been collected (the same ordering + /// contract [`Self::set_ironwood_out_ciphertext`] enforces) or a spend-auth signature has been + /// applied. Re-delivering the same `rk` is idempotent; delivering the placeholder itself (the + /// spend's unrandomized `ak`) is rejected. + pub fn replace_ironwood_spend_rk( + &mut self, + action_index: usize, + rk: [u8; 32], + ) -> Result<(), String> { + use crate::zcash::ironwood_pczt; + + if self + .psbt + .inputs + .iter() + .any(|input| !input.partial_sigs.is_empty()) + { + return Err( + "cannot replace a spend's rk after a transparent signature has been collected: \ + rk is sighash-committed, so this would invalidate it" + .to_string(), + ); + } + + let bytes = super::propkv::get_ironwood_pczt(&self.psbt) + .ok_or_else(|| "no Ironwood PCZT stored in PSBT".to_string())?; + let pczt = ironwood_pczt::deserialize_pczt(&bytes).map_err(|e| e.to_string())?; + // Dummy spends are only signed inside `combine_ironwood_proof`, so any stored + // spend-auth signature belongs to a real spend. + if pczt + .actions() + .iter() + .any(|a| a.spend().spend_auth_sig().is_some()) + { + return Err( + "cannot replace a spend's rk after a spend-auth signature has been applied: \ + rk is sighash-committed, so this would invalidate it" + .to_string(), + ); + } + let action = pczt + .actions() + .get(action_index) + .ok_or_else(|| format!("action {action_index} out of range"))?; + let spend = action.spend(); + if spend.dummy_sk().is_some() { + return Err(format!( + "action {action_index} is a fabricated dummy spend, not a real spend" + )); + } + if !Self::spend_rk_replaced(spend)? && <[u8; 32]>::from(spend.rk()) == rk { + return Err(format!( + "action {action_index}: rk is the Constructor's placeholder (the spend's \ + unrandomized ak), not a randomized rk" + )); + } + + let patched = + ironwood_pczt::with_spend_rk(&bytes, action_index, rk).map_err(|e| e.to_string())?; + super::propkv::set_ironwood_pczt(&mut self.psbt, patched); + Ok(()) + } + + /// Apply the RedPallas `spend_auth_sig` for one real spend, after verifying + /// it against that spend's (already replaced) `rk` over the ZIP-244 sig digest. A rejected + /// signature is not stored. + pub fn apply_ironwood_spend_signature( + &mut self, + action_index: usize, + sig: [u8; 64], + ) -> Result<(), String> { + use orchard::primitives::redpallas::{self, SpendAuth}; + + use crate::zcash::ironwood_pczt; + + let bytes = super::propkv::get_ironwood_pczt(&self.psbt) + .ok_or_else(|| "no Ironwood PCZT stored in PSBT".to_string())?; + let mut pczt = ironwood_pczt::deserialize_pczt(&bytes).map_err(|e| e.to_string())?; + + // A spend-auth signature commits the sighash, which commits `rk`: ingesting one while a + // real spend still carries the Constructor's placeholder `rk` (= its `ak`) would let an + // unrandomized — linkable — rk reach the chain. A RedPallas signature made with + // `alpha = 0` (or over the pre-replacement digest) verifies fine against `rk = ak`, and + // the prover's `rk == ak.randomize(alpha)` guardrail passes for `alpha = 0` too — so + // this gate is the client-side backstop, not covered by signature verification. + if Self::bundle_has_unpatched_spend_rk(&pczt).map_err(|e| e.to_string())? { + return Err( + "cannot apply a spend signature while any real spend rk is still the placeholder" + .to_string(), + ); + } + + let (amounts, scripts) = self.transparent_input_amounts_and_scripts()?; + let bundle = + crate::zcash::ironwood_build::pczt_action_data(&pczt).map_err(|e| e.to_string())?; + let tx = self.to_v6_transaction(self.psbt.unsigned_tx.clone(), Some(bundle))?; + let sighash = crate::zcash::v6::compute_v6_sig_digest(&tx, &amounts, &scripts); + + let actions = pczt.actions_mut(); + let action = actions + .get_mut(action_index) + .ok_or_else(|| format!("action {action_index} out of range"))?; + action + .apply_signature(sighash, redpallas::Signature::::from(sig)) + .map_err(|e| format!("action {action_index}: {e}"))?; + + let patched = ironwood_pczt::serialize_pczt(&pczt).map_err(|e| e.to_string())?; + super::propkv::set_ironwood_pczt(&mut self.psbt, patched); + Ok(()) + } + /// Sign every transparent input this key resolves a private key for, over the ZIP-244 /// transparent sighash. Client-side counterpart to the generic `BitGoPsbt::sign()` (which /// rejects v6 PSBTs outright, since it only knows the ZIP-243 digest). @@ -1216,18 +1511,26 @@ impl ZcashBitGoPsbt { return Ok(Vec::new()); } - // First signing round: finalize EVERY action's `out_ciphertext` under this wallet's ovk - // before any sighash is computed — one wallet ovk covers the whole bundle, and re-encrypting - // an action under the same ovk is deterministic (same ovk ⇒ same bytes), so the loop is - // idempotent. Only the user key may open the round — checked once, up front, so it holds - // regardless of the action count and signing out of order fails loudly here instead of - // shipping an `out_ciphertext` nobody can decrypt. let already_signed = self .psbt .inputs .iter() .any(|input| !input.partial_sigs.is_empty()); - if !already_signed && !self.require_no_shielded_output_ever_added()? { + // A bundle with real spends has its out_ciphertexts fixed under the spending FVK's ovk + // (checked during construction, while the FVK was transient), so it skips the client-ovk + // round. An absent PCZT counts as no real spends, so the round below fails loudly on it. + let has_real_spends = match super::propkv::get_ironwood_pczt(&self.psbt) { + Some(_) => Self::bundle_has_real_spends(&self.ironwood_pczt()?), + None => false, + }; + if !already_signed && !has_real_spends && !self.require_no_shielded_output_ever_added()? { + // First signing round of a shielding build: finalize EVERY action's `out_ciphertext` + // under this wallet's ovk before any sighash is computed — one wallet ovk covers the + // whole bundle, and re-encrypting an action under the same ovk is deterministic + // (same ovk ⇒ same bytes), so the loop is idempotent. Only the user key may open the + // round — checked once, up front, so it holds regardless of the action count and + // signing out of order fails loudly here instead of shipping an `out_ciphertext` + // nobody can decrypt. Self::check_user_root_key(xpriv, root_wallet_keys, secp) .map_err(|e| format!("{e} (the user must sign a v6 shielding PSBT first)"))?; // Fail loud for a v6 PSBT with no actions: the first round must not sign a @@ -1270,6 +1573,22 @@ impl ZcashBitGoPsbt { .map_err(|e| e.to_string()) } + /// [`Self::ironwood_action_data`], but only once it is final enough to sign: errors while any + /// real spend's `rk` is still the Constructor's placeholder, since the ZIP-244 digest commits + /// `rk`. + fn final_ironwood_action_data(&self) -> Result { + let pczt = self.ironwood_pczt()?; + if Self::bundle_has_unpatched_spend_rk(&pczt)? { + return Err( + "this PSBT spends real Ironwood notes whose rk is still the placeholder: the \ + signing service must deliver the randomized rk (replace_ironwood_spend_rk) \ + before any sighash is computed — the ZIP-244 digest commits rk" + .to_string(), + ); + } + crate::zcash::ironwood_build::pczt_action_data(&pczt).map_err(|e| e.to_string()) + } + /// Distinguishes "no shielded output has ever been added" (`Ok(true)`) from "a shielded output /// is currently present" (`Ok(false)`) from "one was added and then extracted via /// [`Self::combine_ironwood_proof`]/`mark_ironwood_extracted`" (`Err`) — the latter two look @@ -1303,10 +1622,12 @@ impl ZcashBitGoPsbt { /// note — because the PCZT keeps the Constructor's plaintext `recipient`/`value` fields /// in-memory for the Prover/Signer roles; nothing needs decrypting. /// - /// Every action here is a real requested output: [`Self::add_ironwood_outputs`] builds with - /// `BundleType::UNPADDED` and zero requested spends, so (per `orchard`'s cross-address-disabled - /// pairing) each action pairs a fabricated *dummy spend* with one real output — there are no - /// dummy-*output* actions to filter out, unlike a bundle that also spends real notes. + /// Only real requested outputs are returned. Bundles are built with `BundleType::UNPADDED`, so + /// (per `orchard`'s cross-address-disabled pairing) a shielding-only bundle pairs a fabricated + /// *dummy spend* with every real output, while a bundle with more real spends than outputs + /// pairs the surplus spends with fabricated zero-valued *dummy outputs* — those are filtered + /// out (a zero-valued output paired with a real spend). Use + /// [`Self::ironwood_shielded_spends_info`] for the spend side. /// /// The returned action index is what [`super::propkv::get_ironwood_unified_address`] is keyed /// by, for callers that want the original Unified Address (if one was stored) rather than just @@ -1327,29 +1648,153 @@ impl ZcashBitGoPsbt { if self.require_no_shielded_output_ever_added()? { return Ok(Vec::new()); } - // `orchard::pczt::Bundle` only exposes a mutable actions accessor; we only read from it. - let mut pczt = self.ironwood_pczt()?; - let actions = pczt.actions_mut(); - actions + Self::bundle_real_outputs(&self.ironwood_pczt()?) + } + + /// The action index, value (zatoshi), and nullifier of every real Ironwood spend added via + /// [`Self::add_ironwood_spends`]/[`Self::add_ironwood_actions`] — empty if no shielded data is + /// present yet (or the bundle only shields). + /// + /// Like the outputs, the spends live in the proprietary-map PCZT rather than + /// `unsigned_tx.input`, so transparent-only input parsing never sees them; callers fold these + /// values into their input totals for fee accounting. + /// + /// Errors (rather than returning empty) if the PCZT is absent because it was already + /// extracted — see [`Self::require_no_shielded_output_ever_added`]. + pub fn ironwood_shielded_spends_info(&self) -> Result, String> { + if self.require_no_shielded_output_ever_added()? { + return Ok(Vec::new()); + } + Self::bundle_real_spends(&self.ironwood_pczt()?) + } + + /// Every real spend in `pczt` (no `dummy_sk`, see [`Self::bundle_has_real_spends`]); a real + /// spend always carries its plaintext value. + fn bundle_real_spends(pczt: &orchard::pczt::Bundle) -> Result, String> { + pczt.actions() .iter() .enumerate() + .filter(|(_, action)| action.spend().dummy_sk().is_none()) .map(|(action_index, action)| { - let output = action.output(); - let value = output + let spend = action.spend(); + let value = spend .value() .map(|v| v.inner()) - .ok_or_else(|| "shielded output is missing its plaintext value".to_string())?; - let recipient = output - .recipient() - .map(|address| address.to_raw_address_bytes()) - .ok_or_else(|| { - "shielded output is missing its plaintext recipient".to_string() - })?; - Ok((action_index, value, recipient)) + .ok_or_else(|| "real spend is missing its plaintext value".to_string())?; + Ok(IronwoodSpendInfo { + action_index, + value, + nullifier: spend.nullifier().to_bytes(), + }) }) .collect() } + /// Every real output in `pczt`: all but the fabricated zero-valued outputs the builder pairs + /// with surplus real spends (see [`Self::ironwood_shielded_outputs_info`]). + fn bundle_real_outputs( + pczt: &orchard::pczt::Bundle, + ) -> Result< + Vec<( + usize, + u64, + crate::zcash::ironwood_build::OrchardAddressBytes, + )>, + String, + > { + let mut outputs = Vec::new(); + for (action_index, action) in pczt.actions().iter().enumerate() { + let output = action.output(); + let value = output + .value() + .map(|v| v.inner()) + .ok_or_else(|| "shielded output is missing its plaintext value".to_string())?; + if value == 0 && action.spend().dummy_sk().is_none() { + continue; + } + let recipient = output + .recipient() + .map(|address| address.to_raw_address_bytes()) + .ok_or_else(|| "shielded output is missing its plaintext recipient".to_string())?; + outputs.push((action_index, value, recipient)); + } + Ok(outputs) + } + + /// The spend/output/fee accounting for the bundle behind this PSBT — see + /// [`IronwoodBundleInfo`]. `value_balance`/`fee` come from the bundle's committed + /// `value_sum`, cross-checked against the plaintext note values. Errors if the PCZT is + /// absent (no spend/output was ever added, or it was already extracted). + pub fn ironwood_bundle_info(&self) -> Result { + if self.require_no_shielded_output_ever_added()? { + return Err("no Ironwood PCZT stored in PSBT".to_string()); + } + let pczt = self.ironwood_pczt()?; + let spends = Self::bundle_real_spends(&pczt)?; + let outputs = Self::bundle_real_outputs(&pczt)?; + // The bundle's committed value balance (the wire `value_sum`): the quantity the IO + // Finalizer enforces against the proof-bound `cv_net`s and the ZIP-244 digest commits. + // The plaintext note values below are NOT bound after deserialization — the proofs bind + // only `cv_net`/`cm` — so they are cross-checked against the committed sum before any + // accounting flows out of here. + let value_balance = crate::zcash::ironwood_build::pczt_action_data(&pczt) + .map_err(|e| e.to_string())? + .value_balance; + let spend_total = spends.iter().map(|s| s.value as i128).sum::(); + let output_total = outputs.iter().map(|(_, v, _)| *v as i128).sum::(); + if spend_total - output_total != value_balance as i128 { + return Err(format!( + "plaintext note values are inconsistent with the bundle's committed value_sum: \ + spends {spend_total} - outputs {output_total} != value_balance {value_balance}" + )); + } + let transparent_in = self + .transparent_input_amounts_and_scripts()? + .0 + .iter() + .sum::(); + let transparent_out = self + .psbt + .unsigned_tx + .output + .iter() + .map(|o| { + i64::try_from(o.value.to_sat()) + .map_err(|_| "transparent output value exceeds i64 range".to_string()) + }) + .collect::, _>>()? + .iter() + .sum::(); + // The fee composes the committed shielded balance with the transparent sides, in checked + // arithmetic: consensus-impossible magnitudes error instead of wrapping. + let fee = transparent_in + .checked_sub(transparent_out) + .and_then(|net| net.checked_add(value_balance)) + .ok_or_else(|| "transaction value magnitudes exceed the i64 fee range".to_string())?; + Ok(IronwoodBundleInfo { + spends, + outputs, + value_balance, + transparent_in, + transparent_out, + fee, + }) + } + + /// Fail before the (expensive) shielded finalize/proof/combine when the transaction doesn't + /// conserve value: a negative fee means outputs exceed inputs. Anchored to the bundle's + /// committed `value_sum` — see [`Self::ironwood_bundle_info`]. + fn check_ironwood_value_conservation(&self) -> Result<(), String> { + let info = self.ironwood_bundle_info()?; + if info.fee < 0 { + return Err(format!( + "transaction does not conserve value: fee {} zatoshi < 0", + info.fee + )); + } + Ok(()) + } + /// The spent-output value (zatoshi, as i64) and scriptPubKey of every transparent input, in /// input order — the amounts/scripts ZIP-244 commits to. fn transparent_input_amounts_and_scripts( @@ -1370,7 +1815,10 @@ impl ZcashBitGoPsbt { let (script, value) = super::psbt_wallet_input::get_output_script_and_value(input, prevout) .map_err(|e| format!("input {i}: missing UTXO value/script: {e}"))?; - amounts.push(value.to_sat() as i64); + amounts.push( + i64::try_from(value.to_sat()) + .map_err(|_| format!("input {i}: transparent value out of i64 range"))?, + ); scripts.push(script.clone()); } Ok((amounts, scripts)) @@ -1443,7 +1891,7 @@ impl ZcashBitGoPsbt { let bundle = if self.require_no_shielded_output_ever_added()? { None } else { - Some(self.ironwood_action_data()?) + Some(self.final_ironwood_action_data()?) }; let tx = self.to_v6_transaction(self.psbt.unsigned_tx.clone(), bundle)?; let input = self @@ -1461,6 +1909,22 @@ impl ZcashBitGoPsbt { .map_err(|e| e.to_string()) } + /// The ZIP-244 sig digest over the complete transaction (transparent I/O + shielded action + /// data) — the message spend-auth signatures are computed over, and which + /// [`Self::combine_ironwood_proof`] binds. Transparent inputs sign their own per-input digest + /// ([`Self::v6_transparent_sighash`]), which commits the same shielded action data. + /// + /// Errors while any real spend's `rk` is still the placeholder — the digest must be final + /// before it is signed. + pub fn v6_sig_digest(&self) -> Result<[u8; 32], String> { + let (amounts, scripts) = self.transparent_input_amounts_and_scripts()?; + let bundle = self.final_ironwood_action_data()?; + let tx = self.to_v6_transaction(self.psbt.unsigned_tx.clone(), Some(bundle))?; + Ok(crate::zcash::v6::compute_v6_sig_digest( + &tx, &amounts, &scripts, + )) + } + /// Ingest a transparent-input signature returned by the client/HSM into `partial_sigs`, after /// verifying it against [`Self::v6_transparent_sighash`] for that input. `sig` is a DER ECDSA /// signature with the trailing SIGHASH_ALL byte (as it appears in a scriptSig). @@ -1723,6 +2187,9 @@ impl ZcashBitGoPsbt { let sig_tx = self.to_v6_transaction(self.psbt.unsigned_tx.clone(), Some(action_bundle))?; let sighash = crate::zcash::v6::compute_v6_sig_digest(&sig_tx, &amounts, &scripts); + // Pre-flight value-conservation guard, shared with the local-combine path. + self.check_ironwood_value_conservation()?; + // Signer + IO finalizer: sign the dummy spend(s) and derive the binding signing key. let mut pczt = self.ironwood_pczt()?; ironwood_build::finalize_shield_io(&mut pczt, sighash, &mut rng) @@ -1778,9 +2245,22 @@ impl ZcashBitGoPsbt { #[cfg(feature = "orchard-proving")] pub fn combine_ironwood_proof_locally( self, + rng: R, + ) -> Result, String> { + self.combine_ironwood_proof_locally_with_alphas(&[], rng) + } + + /// Spend-bundle variant of [`Self::combine_ironwood_proof_locally`]: the Prover role derives + /// `rk` inside the circuit from `ak` + `alpha`, so proving a bundle with real spends requires + /// each spend's `alpha` — delivered here as `(action_index, alpha)` pairs. The alphas are + /// patched into the in-memory PCZT only and never reach the PSBT or the produced transaction. + #[cfg(feature = "orchard-proving")] + pub fn combine_ironwood_proof_locally_with_alphas( + self, + alphas: &[(usize, [u8; 32])], mut rng: R, ) -> Result, String> { - use crate::zcash::ironwood_build; + use crate::zcash::{ironwood_build, ironwood_pczt}; let transparent = self.finalized_transparent_tx()?; @@ -1789,7 +2269,20 @@ impl ZcashBitGoPsbt { let sig_tx = self.to_v6_transaction(self.psbt.unsigned_tx.clone(), Some(action_bundle))?; let sighash = crate::zcash::v6::compute_v6_sig_digest(&sig_tx, &amounts, &scripts); - let mut pczt = self.ironwood_pczt()?; + // Same pre-flight guard as the external-proof path: fail before the (expensive) local + // proof generation when the transaction doesn't conserve value. + self.check_ironwood_value_conservation()?; + + // Patch the alphas into the wire form (transiently — the result is consumed in-memory + // and never stored back into the PSBT). + let mut bytes = super::propkv::get_ironwood_pczt(&self.psbt) + .ok_or_else(|| "no Ironwood PCZT stored in PSBT".to_string())?; + for (action_index, alpha) in alphas { + bytes = ironwood_pczt::with_spend_alpha(&bytes, *action_index, Some(*alpha)) + .map_err(|e| e.to_string())?; + } + let mut pczt = ironwood_pczt::deserialize_pczt(&bytes).map_err(|e| e.to_string())?; + ironwood_build::finalize_shield_io(&mut pczt, sighash, &mut rng) .map_err(|e| e.to_string())?; ironwood_build::create_proof(&mut pczt, &mut rng).map_err(|e| e.to_string())?; @@ -2039,8 +2532,13 @@ mod ironwood_v6_tests { use crate::fixed_script_wallet::RootWalletKeys; use crate::networks::Network; use crate::zcash::NetworkUpgrade; + use ff::Field; + use orchard::keys::SpendAuthorizingKey; use orchard::keys::{FullViewingKey, Scope, SpendingKey}; + use orchard::note::{ExtractedNoteCommitment, Note, RandomSeed, Rho}; + use orchard::primitives::redpallas::{SpendAuth, VerificationKey}; use orchard::tree::Anchor; + use orchard::value::NoteValue; use orchard::Proof; use rand::rngs::OsRng; use std::str::FromStr; @@ -2056,8 +2554,8 @@ mod ironwood_v6_tests { /// The three wallet signing keys derived at `chain/index`, matching `get_test_wallet_keys` /// (same `seed.N` scheme), so their pubkeys equal the multisig redeem script's. /// - /// `RootWalletKeys::new` derives every (chain, index) key under a fixed `m/0/0` prefix (see - /// `RootWalletKeys::new`), so the path here must match: `m/0/0//`. + /// `RootWalletKeys::new` derives the same keys from the seed (the path is + /// `m/0/0//`), so the two must stay in sync. fn signing_secret_keys(seed: &str, chain: u32, index: u32) -> [SecretKey; 3] { let secp = Secp256k1::new(); let prefix = DerivationPath::from_str("m/0/0").unwrap(); @@ -2071,6 +2569,84 @@ mod ironwood_v6_tests { keys.try_into().unwrap() } + /// Golden vector pinning the Ironwood PCZT wire format — the postcard-encoded bundle the + /// client ships to the external prover. Guards the cross-repo contract with + /// `kms-crypto-worker`'s `crypto_zcash::ironwood_pczt` mirror: a field-order, field-set, or + /// encoding change here fails this test instead of failing live proof requests. + /// + /// The construction is fully deterministic: fixed wallet keys, the fixed spending key and + /// note of [`test_spend_request`], and a seeded RNG standing in for `OsRng` (the builder's + /// per-build randomness — `alpha`, `rk`, value-commitment trapdoors, ephemeral keys — is the + /// only source of variance). + /// + /// If the wire format changes intentionally, bump + /// [`FORMAT_VERSION`](crate::zcash::ironwood_pczt::FORMAT_VERSION) and regenerate the fixture: + /// + /// ```text + /// WASM_UTXO_REGEN_PCZT_GOLDEN=1 cargo test --lib ironwood_pczt_wire_golden_vector + /// ``` + #[test] + fn ironwood_pczt_wire_golden_vector() { + use rand::rngs::StdRng; + use rand::SeedableRng; + + let seed = "ironwood_pczt_wire_golden"; + let wallet_keys = root_wallet_keys(seed); + let mut z = ZcashBitGoPsbt::new_v6( + Network::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu6_3.branch_id(), + None, + None, + ); + + // One real spend (keyless after the constructor's scrub: fvk omitted, alpha cleared, + // placeholder rk) self-shielded back to the spent note's own address, minus the fee. + let (req, anchor, _ask) = test_spend_request(); + let mut memo = [0u8; 512]; + memo[0] = 0xf6; // ZIP-302 "no memo" + let out = IronwoodOutputRequest { + recipient: req.recipient, + amount: req.value - 10_000, + ovk: None, + memo, + unified_address: None, + }; + let mut rng = StdRng::seed_from_u64(0x1de0_77ea); + z.add_ironwood_actions(&[req], &[out], &anchor, &mut rng) + .unwrap(); + + let bytes = z.raw_ironwood_pczt_bytes().unwrap(); + assert_eq!( + bytes[0], + crate::zcash::ironwood_pczt::FORMAT_VERSION, + "PCZT wire format version changed" + ); + let actual = hex::encode(&bytes); + + let fixture_name = "ironwood_pczt_wire_golden.hex"; + if std::env::var("WASM_UTXO_REGEN_PCZT_GOLDEN").is_ok() { + let path = format!( + "{}/test/fixtures/zcash/{}", + env!("CARGO_MANIFEST_DIR"), + fixture_name + ); + std::fs::write(&path, format!("{actual}\n")).unwrap(); + println!("regenerated {path}"); + } + let expected = crate::fixed_script_wallet::test_utils::fixtures::load_fixture(&format!( + "zcash/{fixture_name}" + )) + .unwrap(); + assert_eq!( + actual, + expected.trim(), + "PCZT wire format changed — if intentional, bump FORMAT_VERSION and regenerate the \ + golden fixture with WASM_UTXO_REGEN_PCZT_GOLDEN=1 cargo test --lib \ + ironwood_pczt_wire_golden_vector" + ); + } + #[test] fn build_sign_combine_produces_valid_v6_tx() { let seed = "ironwood_v6_psbt"; @@ -4361,6 +4937,11 @@ mod ironwood_v6_tests { .actions .iter() .map(|a| { + let mut spend_proprietary = BTreeMap::new(); + spend_proprietary.insert( + crate::zcash::ironwood_pczt::SPEND_RK_STATE_PROPRIETARY_KEY.to_string(), + vec![1], + ); let spend = PcztSpend::parse( a.nullifier, a.rk, @@ -4375,7 +4956,7 @@ mod ironwood_v6_tests { None, None, note_version, - BTreeMap::new(), + spend_proprietary, ) .expect("valid spend"); let spend_nullifier = @@ -4804,4 +5385,472 @@ mod ironwood_v6_tests { ); } } + + // ---- Real-spend PSBT flow (FROST signing-service stand-in) ---- + + use crate::zcash::ironwood_build::test_field_bytes as field_bytes; + + /// A real-spend fixture for the PSBT layer: a wallet note (fvk/recipient/value/rho/rseed) + /// with a self-consistent witness around its cmx, the bundle anchor the witness recomputes + /// to, and the note's own spend authorizing key for the FROST stand-in. + fn test_spend_request() -> ( + IronwoodSpendRequest, + crate::zcash::ironwood_build::AnchorBytes, + SpendAuthorizingKey, + ) { + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External).to_raw_address_bytes(); + let rho = Option::::from(Rho::from_bytes(&field_bytes(100))).unwrap(); + let rseed = Option::::from(RandomSeed::from_bytes([42u8; 32], &rho)).unwrap(); + let address = + Option::::from(orchard::Address::from_raw_address_bytes(&recipient)) + .unwrap(); + let note = Option::::from(Note::from_parts( + address, + NoteValue::from_raw(100_000_000), + rho, + rseed, + orchard::bundle::BundleVersion::ironwood_v3().note_version(), + )) + .unwrap(); + let cmx = ExtractedNoteCommitment::from(note.commitment()).to_bytes(); + + let position = 5u32; + let auth_path: crate::zcash::ironwood_build::WitnessAuthPath = + std::array::from_fn(|i| field_bytes(i as u64 + 1)); + let sibling_hashes: [orchard::tree::MerkleHashOrchard; 32] = auth_path + .map(|s| Option::from(orchard::tree::MerkleHashOrchard::from_bytes(&s)).unwrap()); + let anchor = orchard::tree::MerklePath::from_parts(position, sibling_hashes) + .root( + Option::::from(ExtractedNoteCommitment::from_bytes(&cmx)) + .unwrap(), + ) + .to_bytes(); + + ( + IronwoodSpendRequest { + fvk: fvk.to_bytes(), + recipient, + value: 100_000_000, + rho: rho.to_bytes(), + rseed: *rseed.as_bytes(), + cmx, + witness: crate::zcash::ironwood_build::IronwoodWitness { + position, + auth_path, + }, + }, + anchor, + SpendAuthorizingKey::from(&sk), + ) + } + + /// A fresh v6 PSBT with one real Ironwood spend (100M zatoshi) and a transparent payout of + /// 99_999_000 — an unshielding transaction with a 1_000-zatoshi fee and no transparent + /// inputs. + fn build_unshield_psbt(seed: &str) -> (ZcashBitGoPsbt, RootWalletKeys) { + let wallet_keys = root_wallet_keys(seed); + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + let psbt = BitGoPsbt::new_zcash_v6_at_height( + Network::ZcashTestnet, + &wallet_keys, + nu6_3, + None, + None, + ) + .unwrap(); + let BitGoPsbt::Zcash(mut z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + let (req, anchor, _ask) = test_spend_request(); + z.add_ironwood_spends(&[req], &anchor, OsRng).unwrap(); + z.add_transparent_output( + miniscript::bitcoin::ScriptBuf::from(vec![0x76u8, 0xa9, 0x14, 0x88, 0xac]), + 99_999_000, + None, + ) + .unwrap(); + (z, wallet_keys) + } + + #[test] + fn add_ironwood_spends_placeholder_state_and_round_trip() { + let (mut z, _keys) = build_unshield_psbt("ironwood_v6_spends_roundtrip"); + + // The PCZT keeps note plaintext and placeholder-rk state, but not the full viewing key. + let bytes = z.raw_ironwood_pczt_bytes().unwrap(); + let pczt = crate::zcash::ironwood_pczt::deserialize_pczt(&bytes).unwrap(); + let spend = pczt.actions()[0].spend(); + let (req, _anchor, _ask) = test_spend_request(); + assert!(spend.fvk().is_none()); + assert!(!bytes + .windows(req.fvk.len()) + .any(|window| window == &req.fvk[..])); + assert!(spend.alpha().is_none()); + assert_eq!( + <[u8; 32]>::from(spend.rk()), + <[u8; 32]>::try_from(&req.fvk[0..32]).unwrap() + ); + assert_eq!(spend.value().map(|v| v.inner()), Some(req.value)); + assert_eq!(spend.rho().map(|r| r.to_bytes()), Some(req.rho)); + assert_eq!(spend.rseed().map(|r| *r.as_bytes()), Some(req.rseed)); + assert_eq!( + spend + .proprietary() + .get(crate::zcash::ironwood_build::CMX_PROPRIETARY_KEY), + Some(&req.cmx.to_vec()) + ); + + let round = + ZcashBitGoPsbt::deserialize_v6(&z.serialize_v6(), Network::ZcashTestnet).unwrap(); + assert_eq!( + round.raw_ironwood_pczt_bytes().unwrap(), + bytes, + "the PCZT round-trips the PSBT bytes losslessly" + ); + + // A second constructor call is rejected. + let (req, anchor, _ask) = test_spend_request(); + assert!(z.add_ironwood_spends(&[req], &anchor, OsRng).is_err()); + } + + #[test] + fn sighash_gates_on_unpatched_rk_and_replace_rejects_bad_context() { + let (mut z, _keys) = build_unshield_psbt("ironwood_v6_spends_guard"); + + let err = z.v6_sig_digest().unwrap_err(); + assert!(err.contains("placeholder"), "unexpected: {err}"); + + // Replacing on a non-existent action is rejected. + assert!(z.replace_ironwood_spend_rk(1, [0u8; 32]).is_err()); + // A non-canonical rk is rejected (the wire patcher validates the point). + assert!(z.replace_ironwood_spend_rk(0, [0xffu8; 32]).is_err()); + + // FROST stand-in: randomized rk from a fresh randomizer. + let (_req, _anchor, ask) = test_spend_request(); + let mut rng = OsRng; + let alpha = pasta_curves::pallas::Scalar::random(&mut rng); + let rsk = ask.randomize(&alpha); + let rk = <[u8; 32]>::from(VerificationKey::::from(&rsk)); + z.replace_ironwood_spend_rk(0, rk).unwrap(); + + // The digest is now fixed, and re-patching is idempotent. + z.v6_sig_digest().unwrap(); + z.replace_ironwood_spend_rk(0, rk).unwrap(); + } + + #[test] + fn unshield_frost_flow_produces_a_valid_v6_tx() { + let (mut z, _keys) = build_unshield_psbt("ironwood_v6_unshield_e2e"); + + // FROST stand-in round 1: the coordinator derives alpha, computes rk, delivers it. + let (_req, _anchor, ask) = test_spend_request(); + let mut rng = OsRng; + let alpha = pasta_curves::pallas::Scalar::random(&mut rng); + let rsk = ask.randomize(&alpha); + let rk = <[u8; 32]>::from(VerificationKey::::from(&rsk)); + z.replace_ironwood_spend_rk(0, rk).unwrap(); + + // The digest commits the final rk; the spend-auth signature is over exactly this digest. + let sighash = z.v6_sig_digest().unwrap(); + + // Accounting: 100M shielded in, 99.999M transparent out, no transparent inputs, + // fee 1_000 zatoshi; the bundle value balance is net +100M out of the pool. + let info = z.ironwood_bundle_info().unwrap(); + assert_eq!(info.spends.len(), 1); + assert_eq!(info.spends[0].value, 100_000_000); + assert!(info.outputs.is_empty()); + assert_eq!(info.transparent_in, 0); + assert_eq!(info.transparent_out, 99_999_000); + assert_eq!(info.fee, 1_000); + assert_eq!(info.value_balance, 100_000_000); + + // FROST stand-in round 2: RedPallas signature over the digest. + let sig = rsk.sign(rng, &sighash); + z.apply_ironwood_spend_signature(0, <[u8; 64]>::from(&sig)) + .unwrap(); + // A second application with a different digest-shaped signature fails loudly and + // leaves the stored signature untouched. + let bad = rsk.sign(rng, &[0u8; 32]); + assert!(z + .apply_ironwood_spend_signature(0, <[u8; 64]>::from(&bad)) + .is_err()); + + let proof = vec![0u8; Proof::expected_proof_size(1)]; + let raw = z.combine_ironwood_proof(proof, OsRng).unwrap(); + let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap(); + let bundle = tx.ironwood_bundle.as_ref().unwrap(); + assert_eq!( + bundle.actions[0].rk, rk, + "the randomized rk reached the wire" + ); + + use zebra_chain::serialization::ZcashDeserialize; + use zebra_chain::transaction::Transaction as ZebraTx; + let zebra = ZebraTx::zcash_deserialize(&raw[..]).expect("zebra decodes the unshield tx"); + assert_eq!(zebra.version(), 6); + assert_eq!(zebra.ironwood_actions().count(), 1); + } + + #[test] + fn combine_rejects_a_transaction_that_does_not_conserve_value() { + let wallet_keys = root_wallet_keys("ironwood_v6_unshield_negative_fee"); + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + let psbt = BitGoPsbt::new_zcash_v6_at_height( + Network::ZcashTestnet, + &wallet_keys, + nu6_3, + None, + None, + ) + .unwrap(); + let BitGoPsbt::Zcash(mut z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + let (req, anchor, _ask) = test_spend_request(); + z.add_ironwood_spends(&[req], &anchor, OsRng).unwrap(); + // Payout exceeds the spend by 1 zatoshi: fee -1. + z.add_transparent_output( + miniscript::bitcoin::ScriptBuf::from(vec![0x76u8, 0xa9, 0x14, 0x88, 0xac]), + 100_000_001, + None, + ) + .unwrap(); + assert_eq!(z.ironwood_bundle_info().unwrap().fee, -1); + + let err = z + .combine_ironwood_proof(vec![0u8; Proof::expected_proof_size(1)], OsRng) + .unwrap_err(); + assert!(err.contains("does not conserve value"), "unexpected: {err}"); + } + + #[test] + fn ironwood_bundle_info_rejects_a_tampered_plaintext_value() { + // The spend's plaintext value is not bound by the proofs (only `cv_net`/`cm` are), so a + // plaintext that disagrees with the bundle's committed `value_sum` must be caught by the + // accounting cross-check rather than silently flowing into the fee. + let (mut z, _keys) = build_unshield_psbt("ironwood_v6_tampered_plaintext"); + + // The honest bundle accounts cleanly: fee = 0 - 99_999_000 + 100_000_000. + assert_eq!(z.ironwood_bundle_info().unwrap().fee, 1_000); + + // Tamper the spend plaintext value (+1 zatoshi) without touching the committed + // value_sum, re-store, and expect the cross-check to reject it. + let bytes = + crate::fixed_script_wallet::bitgo_psbt::propkv::get_ironwood_pczt(&z.psbt).unwrap(); + let tampered = + crate::zcash::ironwood_pczt::with_spend_value(&bytes, 0, 100_000_001).unwrap(); + crate::fixed_script_wallet::bitgo_psbt::propkv::set_ironwood_pczt(&mut z.psbt, tampered); + let err = z.ironwood_bundle_info().unwrap_err(); + assert!( + err.contains("inconsistent with the bundle's committed value_sum"), + "unexpected: {err}" + ); + } + + #[cfg(feature = "orchard-proving")] + #[test] + fn combine_ironwood_proof_locally_rejects_a_transaction_that_does_not_conserve_value() { + // Mirror of the external-path negative-fee test: the local path must hit the same + // pre-flight conservation guard before any (expensive) proof generation runs. + let wallet_keys = root_wallet_keys("ironwood_v6_local_negative_fee"); + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + let psbt = BitGoPsbt::new_zcash_v6_at_height( + Network::ZcashTestnet, + &wallet_keys, + nu6_3, + None, + None, + ) + .unwrap(); + let BitGoPsbt::Zcash(mut z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + let (req, anchor, _ask) = test_spend_request(); + z.add_ironwood_spends(&[req], &anchor, OsRng).unwrap(); + // Payout exceeds the spend by 1 zatoshi: fee -1. + z.add_transparent_output( + miniscript::bitcoin::ScriptBuf::from(vec![0x76u8, 0xa9, 0x14, 0x88, 0xac]), + 100_000_001, + None, + ) + .unwrap(); + let err = z.combine_ironwood_proof_locally(OsRng).unwrap_err(); + assert!(err.contains("does not conserve value"), "unexpected: {err}"); + } + + #[test] + fn mixed_transparent_and_shielded_signs_and_combines() { + let seed = "ironwood_v6_mixed"; + let secp = Secp256k1::new(); + let wallet_keys = root_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([0x33u8; 32]), + 0, + 200_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + let BitGoPsbt::Zcash(mut z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + // 200M transparent in + 100M shielded in; 299.999M transparent out + 1_000 fee. + z.add_transparent_output( + miniscript::bitcoin::ScriptBuf::from(vec![0x76u8, 0xa9, 0x14, 0x88, 0xac]), + 299_999_000, + None, + ) + .unwrap(); + let (req, anchor, ask) = test_spend_request(); + let (spend_indices, _out) = z.add_ironwood_actions(&[req], &[], &anchor, OsRng).unwrap(); + assert_eq!(spend_indices, vec![0]); + + // rk before any transparent signature (the replace guard enforces the ordering). + let mut rng = OsRng; + let alpha = pasta_curves::pallas::Scalar::random(&mut rng); + let rsk = ask.randomize(&alpha); + let rk = <[u8; 32]>::from(VerificationKey::::from(&rsk)); + z.replace_ironwood_spend_rk(0, rk).unwrap(); + + // Mixed flows skip the client-ovk first round: any key may open the round. + let bitgo_xpriv = test_wallet_xpriv(seed, 2); + let signed = z + .sign_ironwood_v6(&bitgo_xpriv, &wallet_keys, &secp) + .unwrap(); + assert_eq!( + signed, + vec![0], + "bitgo key signed the transparent input first" + ); + + let sighash = z.v6_sig_digest().unwrap(); + let sig = rsk.sign(rng, &sighash); + z.apply_ironwood_spend_signature(0, <[u8; 64]>::from(&sig)) + .unwrap(); + + let user_xpriv = test_wallet_xpriv(seed, 0); + z.sign_ironwood_v6(&user_xpriv, &wallet_keys, &secp) + .unwrap(); + assert_eq!(z.psbt.inputs[0].partial_sigs.len(), 2); + + let proof = vec![0u8; Proof::expected_proof_size(1)]; + let raw = z.combine_ironwood_proof(proof, OsRng).unwrap(); + let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap(); + assert_eq!(tx.ironwood_bundle.as_ref().unwrap().actions[0].rk, rk); + + use zebra_chain::serialization::ZcashDeserialize; + use zebra_chain::transaction::Transaction as ZebraTx; + let zebra = ZebraTx::zcash_deserialize(&raw[..]).expect("zebra decodes the mixed tx"); + assert_eq!(zebra.ironwood_actions().count(), 1); + } + + /// Randomize the test note's spend authorizing key and return `(rsk, rk)` — the FROST + /// signing-service stand-in for one real spend. + fn randomized_spend_key( + ask: &SpendAuthorizingKey, + ) -> ( + orchard::primitives::redpallas::SigningKey, + [u8; 32], + ) { + let alpha = pasta_curves::pallas::Scalar::random(OsRng); + let rsk = ask.randomize(&alpha); + let rk = <[u8; 32]>::from(VerificationKey::::from(&rsk)); + (rsk, rk) + } + + #[test] + fn spend_bundle_accounting_skips_dummy_outputs_and_counts_spends() { + let (z, wallet_keys) = build_unshield_psbt("ironwood_v6_unshield_parse"); + + // The spend-only action's fabricated output is not a real output. + assert!(z.ironwood_shielded_outputs_info().unwrap().is_empty()); + let spends = z.ironwood_shielded_spends_info().unwrap(); + assert_eq!(spends.len(), 1); + assert_eq!(spends[0].value, 100_000_000); + + // Transaction parsing folds the shielded spend into the input total, so the unshield's + // fee is the 1_000-zatoshi difference rather than an underflow. + let psbt = BitGoPsbt::Zcash(z, Network::ZcashTestnet); + let parsed = psbt + .parse_transaction_with_wallet_keys( + &wallet_keys, + &crate::fixed_script_wallet::ReplayProtection::new(vec![]), + &[], + ) + .unwrap(); + assert_eq!(parsed.miner_fee, 1_000); + assert!(parsed + .outputs + .iter() + .all(|output| output.is_shielded != Some(true))); + } + + /// `apply_ironwood_spend_signature` must refuse to ingest a spend-auth signature while a + /// real spend still carries the Constructor's placeholder `rk` (= its `ak`): a RedPallas + /// signature made with `alpha = 0` verifies against `rk = ak`, so signature verification + /// alone cannot catch it — the gate is what keeps an unrandomized (linkable) rk off the + /// chain. Fires before any signature bytes are examined. + #[test] + fn apply_spend_signature_rejects_a_placeholder_rk() { + let (mut z, _keys) = build_unshield_psbt("ironwood_v6_apply_rk_guard"); + // Placeholder rk (marker [0]): any 64 bytes would be "verifiable" if the signer used + // alpha = 0, so the gate must fire on the STATE, not on signature validity. + let err = z.apply_ironwood_spend_signature(0, [7u8; 64]).unwrap_err(); + assert!( + err.contains("placeholder"), + "expected the placeholder-rk gate, got: {err}" + ); + + // After the rk is replaced, the same bytes pass the gate (and fail RedPallas + // verification instead, which is the correct next layer). + let (req, _anchor, ask) = test_spend_request(); + let _ = req; + let alpha = pasta_curves::pallas::Scalar::random(OsRng); + let rsk = ask.randomize(&alpha); + let rk = <[u8; 32]>::from(VerificationKey::::from(&rsk)); + z.replace_ironwood_spend_rk(0, rk).unwrap(); + let err = z.apply_ironwood_spend_signature(0, [7u8; 64]).unwrap_err(); + assert!( + !err.contains("placeholder"), + "the gate must not fire once the rk is replaced, got: {err}" + ); + } + + #[test] + fn replace_rk_rejects_the_placeholder_and_runs_only_before_spend_signatures() { + let (mut z, _keys) = build_unshield_psbt("ironwood_v6_replace_rk_guard"); + let (req, _anchor, ask) = test_spend_request(); + + // The placeholder itself (the unrandomized ak) is not a randomized rk. + let ak = <[u8; 32]>::try_from(&req.fvk[0..32]).unwrap(); + let err = z.replace_ironwood_spend_rk(0, ak).unwrap_err(); + assert!(err.contains("placeholder"), "unexpected: {err}"); + + let (rsk, rk) = randomized_spend_key(&ask); + z.replace_ironwood_spend_rk(0, rk).unwrap(); + let sighash = z.v6_sig_digest().unwrap(); + z.apply_ironwood_spend_signature(0, <[u8; 64]>::from(&rsk.sign(OsRng, &sighash))) + .unwrap(); + + // No transparent inputs exist to trip the partial_sigs guard, but the applied spend-auth + // signature commits rk: replacing it now (even with the same value) is rejected. + let (_rsk2, rk2) = randomized_spend_key(&ask); + for new_rk in [rk2, rk] { + let err = z.replace_ironwood_spend_rk(0, new_rk).unwrap_err(); + assert!(err.contains("spend-auth signature"), "unexpected: {err}"); + } + assert_eq!(z.v6_sig_digest().unwrap(), sighash, "digest unchanged"); + } } diff --git a/packages/wasm-utxo/src/inspect/psbt.rs b/packages/wasm-utxo/src/inspect/psbt.rs index e13a2ca3a66..f18fc2f2b26 100644 --- a/packages/wasm-utxo/src/inspect/psbt.rs +++ b/packages/wasm-utxo/src/inspect/psbt.rs @@ -664,7 +664,8 @@ fn transparent_signing_state(inputs: &[crate::bitcoin::psbt::Input]) -> &'static "half_signed" } -/// The `ironwood` node: every shielded output's value/recipient (one per action) and the bundle's +/// The `ironwood` node: every real shielded output's value/recipient, every real shielded spend's +/// value/nullifier (when the bundle spends notes), and the bundle's /// action-data (commitments, ciphertexts, flags, value balance, anchor) read from the PCZT stored /// in the proprietary map. Reports an error message inline rather than aborting the whole parse if /// the PCZT is malformed or missing (e.g. a v6 PSBT that had `add_ironwood_output`/ @@ -701,6 +702,31 @@ fn ironwood_shielded_state_to_node(zcash_psbt: &ZcashBitGoPsbt) -> Node { } } + // Only rendered for bundles that spend real notes, so shielding-only output is unchanged. + match zcash_psbt.ironwood_shielded_spends_info() { + Ok(spends) if spends.is_empty() => {} + Ok(spends) => { + let mut spends_node = Node::new("shielded_spends", Primitive::None); + for spend in spends { + let mut spend_node = Node::new("shielded_spend", Primitive::None); + spend_node.add_child(Node::new( + "action_index", + Primitive::U64(spend.action_index as u64), + )); + spend_node.add_child(Node::new("value", Primitive::U64(spend.value))); + spend_node.add_child(Node::new( + "nullifier", + Primitive::Buffer(spend.nullifier.to_vec()), + )); + spends_node.add_child(spend_node); + } + node.add_child(spends_node); + } + Err(e) => { + node.add_child(Node::new("shielded_spend_error", Primitive::String(e))); + } + } + match zcash_psbt.ironwood_action_data() { Ok(bundle) => { node.add_child(Node::new( diff --git a/packages/wasm-utxo/src/zcash/ironwood_build.rs b/packages/wasm-utxo/src/zcash/ironwood_build.rs index 1f8e4f0937f..2d1e08d6177 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_build.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_build.rs @@ -9,7 +9,7 @@ //! as an `orchard::pczt::Bundle` with no proof or signatures yet. All action data (value //! commitments, note commitments, ciphertexts) is fixed here, and it is exactly what the //! ZIP-244 shielded sighash commits to. Real spends carry only a placeholder `rk` and no -//! `alpha` — the signing service owns both. +//! `alpha` — the signing service owns both — and no FVK (it is used only during construction). //! - **IO Finalizer / Signer** ([`finalize_shield_io`]): derives the binding signing key (`bsk`) //! and signs the dummy spends over the now-fixed shielded sighash. //! - **Transaction Extractor** ([`combine`]): given the signed PCZT with the prover's `zkproof` @@ -173,6 +173,8 @@ pub enum IronwoodBuildError { /// already carries, so recomputing `out_ciphertext` from it would encrypt an `esk` inconsistent /// with the action's fixed `ephemeral_key`. NoteCommitmentMismatch, + /// A constructed output's `out_ciphertext` does not match the expected outgoing viewing key. + OutCiphertextMismatch, /// The bitgo/user key bytes are not a valid secp256k1 public/private key. BadKey(String), /// Local proof generation (`orchard-proving` feature) failed. @@ -235,6 +237,10 @@ impl core::fmt::Display for IronwoodBuildError { "ironwood-build: the note reconstructed from the output's recipient/value/rseed does \ not commit to the action's cmx; refusing to recompute out_ciphertext from it" ), + Self::OutCiphertextMismatch => write!( + f, + "ironwood-build: output out_ciphertext does not match the expected ovk" + ), Self::BadKey(e) => write!(f, "ironwood-build: invalid key: {e}"), Self::Prove(e) => write!(f, "ironwood-build: proof generation failed: {e}"), Self::BadWitnessPath => write!( @@ -291,9 +297,9 @@ pub struct IronwoodOutputSpec { /// /// Everything except the witness is the note's plaintext from the wallet's own note record: the /// FVK must own the note, the reconstructed note must commit to `cmx`, and the witness must -/// recompute to the bundle's anchor. The FVK is carried in the PCZT in plaintext — the prover -/// needs `nk` to re-derive the nullifier — and its `ak` doubles as the placeholder `rk` until the -/// signing service replaces it. +/// recompute to the bundle's anchor. The FVK is used only during construction (ownership check, +/// nullifier derivation, and the construction-time verifier) and is omitted from the serialized +/// PCZT; its public `ak` doubles as the placeholder `rk` until the signing service replaces it. #[derive(Clone, Copy, Debug)] pub struct IronwoodSpendSpec { /// Raw full viewing key, 96 bytes: `ak ‖ nk ‖ rivk` (orchard's `FullViewingKey::to_bytes` @@ -313,6 +319,7 @@ pub struct IronwoodSpendSpec { /// Merkle witness for `cmx` at the bundle's anchor. pub witness: IronwoodWitness, } + /// Constructor: build an Ironwood bundle as an orchard PCZT from real spends and/or requested /// outputs — the general Constructor for the shielding (outputs only), unshielding (spends only), /// and shielded-transfer flows. @@ -336,16 +343,15 @@ pub struct IronwoodSpendSpec { /// associate per-spend or per-output data with the right action must go through these mappings /// rather than assuming index `i`. /// -/// The returned PCZT carries no signatures or proof yet. Real spends carry the full note plaintext -/// (fvk/recipient/value/rho/rseed/witness) but only a placeholder `rk` (the spend's `ak`) and no -/// `alpha` — the signing service owns both; see [`scrub_spend_randomizers`]. Run -/// [`finalize_shield_io`] once the sighash is known, then hand the PCZT to the prover and -/// [`combine`]. +/// The returned PCZT carries no signatures or proof yet. Real spends carry the note plaintext +/// (recipient/value/rho/rseed/witness) but no FVK, only a placeholder `rk` (the spend's `ak`), and +/// no `alpha`; see [`scrub_spend_randomizers`]. Run [`finalize_shield_io`] once the sighash is +/// known, then hand the PCZT to the prover and[`combine`]. pub fn construct_spend_pczt_multi( spends: &[IronwoodSpendSpec], outputs: &[IronwoodOutputSpec], anchor: &AnchorBytes, - rng: R, + mut rng: R, ) -> Result<(PcztBundle, Vec, Vec), IronwoodBuildError> { if spends.is_empty() && outputs.is_empty() { return Err(IronwoodBuildError::EmptyBundle); @@ -409,7 +415,7 @@ pub fn construct_spend_pczt_multi( .map_err(|e| IronwoodBuildError::Output(e.to_string()))?; } let (bundle, meta) = builder - .build_for_pczt(rng) + .build_for_pczt(&mut rng) .map_err(|e| IronwoodBuildError::Builder(e.to_string()))?; let spend_indices = (0..spends.len()) .map(|i| { @@ -424,11 +430,15 @@ pub fn construct_spend_pczt_multi( }) .collect::, _>>()?; - // Record the spent note's cmx per real-spend action for reconciliation, then strip what the - // signing service owns: the builder bakes a random `alpha` and a randomized `rk` into every - // real spend at build time, but `alpha` is derived at signing — the persisted PCZT carries - // only the placeholder. Dummy spends are untouched (the IO Finalizer signs them from their - // stored alpha/dummy_sk). + // Validate FVK-dependent invariants while the constructor still has the transient FVKs. + verify_spend_pczt(&bundle)?; + validate_constructed_out_ciphertexts(&bundle, outputs, &output_indices, default_ovk, &mut rng)?; + + // Record each spent note's cmx for reconciliation, then strip what the signing service owns: + // the builder bakes a random `alpha` and a randomized `rk` into every real spend at build time, + // but `alpha` is derived at signing — the persisted PCZT carries only the placeholder. The FVK + // is stripped too: it is used only during construction. Dummy spends are untouched (the IO + // Finalizer signs them from their stored alpha/dummy_sk). let mut bundle = bundle; for (i, spec) in spends.iter().enumerate() { let idx = spend_indices[i]; @@ -445,6 +455,37 @@ pub fn construct_spend_pczt_multi( Ok((bundle, spend_indices, output_indices)) } +/// Check that every real output whose `ovk` is known at construction time (an explicit one, or the +/// spending FVK's default) got an `out_ciphertext` under exactly that `ovk`: with real spends the +/// signing round never re-encrypts it, so a mismatch here would ship unrecoverable outgoing data. +/// Encryption under a known `ovk` is deterministic, so `rng` is not actually consumed. +fn validate_constructed_out_ciphertexts( + bundle: &PcztBundle, + outputs: &[IronwoodOutputSpec], + output_indices: &[usize], + default_ovk: Option, + rng: &mut R, +) -> Result<(), IronwoodBuildError> { + for (output, &action_index) in outputs.iter().zip(output_indices) { + if output.amount == 0 { + continue; + } + let Some(ovk) = output.ovk.or(default_ovk) else { + continue; + }; + let expected = compute_out_ciphertext(bundle, action_index, ovk, rng)?; + if bundle.actions()[action_index] + .output() + .encrypted_note() + .out_ciphertext + != expected + { + return Err(IronwoodBuildError::OutCiphertextMismatch); + } + } + Ok(()) +} + /// Constructor: build a transparent → Ironwood shielding bundle (no real spends) as an orchard /// PCZT, for one or more outputs. A thin wrapper over [`construct_spend_pczt_multi`]; see it for /// the general (and multi-output) contract. @@ -516,41 +557,43 @@ fn parse_spend_spec( }) } -/// Strip the builder-generated spend authorizing data from every real spend, through the wire -/// form (the only place a built PCZT's `alpha`/`rk` are writable): `alpha` cleared, `rk` replaced -/// with the spend's own `ak` — `ak.randomize(0)`, a valid point so `Spend::parse` accepts the -/// placeholder. Dummy spends are untouched: their stored `alpha`/`dummy_sk` are what the IO -/// Finalizer signs with. +/// Strip builder-generated spend authorizing data from every real spend, through the wire form +/// (the only place a built PCZT's `alpha`/`rk` are writable): clear `alpha`, install the spend's +/// `ak` as a valid placeholder `rk`, and mark it for later signer replacement. Dummy spends are +/// untouched: their stored `alpha`/`dummy_sk` are what the IO Finalizer signs with. fn scrub_spend_randomizers( bundle: &PcztBundle, spend_indices: &[usize], spend_ak_bytes: &[[u8; 32]], ) -> Result { use super::ironwood_pczt::{ - clear_spend_alpha, deserialize_pczt, serialize_pczt, with_spend_rk, IronwoodPcztError, + deserialize_pczt, serialize_pczt, with_placeholder_spend_rk, with_spend_alpha, + with_spend_fvk_cleared, IronwoodPcztError, }; let map = |e: IronwoodPcztError| IronwoodBuildError::PcztWire(e.to_string()); let mut bytes = serialize_pczt(bundle).map_err(map)?; for (i, ak) in spend_ak_bytes.iter().enumerate() { let idx = spend_indices[i]; - bytes = clear_spend_alpha(&bytes, idx).map_err(map)?; - bytes = with_spend_rk(&bytes, idx, *ak).map_err(map)?; + bytes = with_spend_alpha(&bytes, idx, None).map_err(map)?; + bytes = with_placeholder_spend_rk(&bytes, idx, *ak).map_err(map)?; + // The caller-supplied FVK is constructor-only: it must never ride the + // serialized PCZT past this point. Dummy fvks (shielding builds) are + // untouched — `create_proof` requires an fvk on every spend. + bytes = with_spend_fvk_cleared(&bytes, idx).map_err(map)?; } deserialize_pczt(&bytes).map_err(map) } -/// Verifier: run the orchard PCZT checks that are meaningful for a constructed bundle, before it -/// is signed/proven. Each check runs only where its fields exist: +/// Verifier: run the orchard PCZT checks that are meaningful while their fields are present. +/// The Constructor calls this before serialization, while the transient FVK is available for +/// nullifier and value-commitment checks; serialized PCZTs intentionally omit that FVK. /// -/// - every real spend (its `fvk` present) — `verify_nullifier` re-derives the note and nullifier -/// from the stored plaintext and checks both against the wire `nullifier` and the FVK; -/// - every real output (its `recipient` present) — `verify_note_commitment`; -/// - actions with both sides real — `verify_cv_net`. +/// - spends with an FVK — re-derive and check the note nullifier; +/// - outputs with a recipient — verify the note commitment; +/// - actions with both sides' fields present — verify the value commitment. /// -/// `verify_rk` is deliberately skipped: it requires a stored `alpha`, which real spends do not -/// carry (the signing service owns it); the randomized `rk` is validated when the spend-auth -/// signature verifies against it. +/// `verify_rk` is deliberately skipped: it requires `alpha`, which real spends do not carry. pub fn verify_spend_pczt(bundle: &PcztBundle) -> Result<(), IronwoodBuildError> { bundle .verify_cross_address_restriction() @@ -1002,6 +1045,14 @@ pub fn compute_out_ciphertext( Ok(enc.encrypt_outgoing_plaintext(action.cv_net(), committed_cmx, rng)) } +/// An arbitrary canonical Pallas field element keyed off `seed` — a test stand-in for a leaf +/// commitment, sibling hash, or note `rho`. +#[cfg(test)] +pub(crate) fn test_field_bytes(seed: u64) -> [u8; 32] { + use ff::PrimeField; + pasta_curves::pallas::Base::from(seed).to_repr() +} + #[cfg(test)] mod tests { use super::*; @@ -1084,14 +1135,10 @@ mod tests { // ---- Witness builder ---- - /// An arbitrary canonical Pallas field element, keyed off `seed` — a stand-in for a leaf - /// commitment or sibling hash. [`build_ironwood_witness`] only cares that its inputs are - /// canonical field elements, not that they come from a real note-commitment tree, so a - /// synthesized value works just as well as one derived from a real note for these tests. - fn field_bytes(seed: u64) -> [u8; 32] { - use ff::PrimeField; - pasta_curves::pallas::Base::from(seed).to_repr() - } + // [`build_ironwood_witness`] only cares that its inputs are canonical field elements, not that + // they come from a real note-commitment tree, so synthesized `field_bytes` values work just as + // well as ones derived from a real note for these tests. + use super::test_field_bytes as field_bytes; /// A self-consistent `(cmx, position, auth_path, anchor)` fixture: `anchor` is computed via /// `orchard`'s own `MerklePath::root`, the same primitive [`build_ironwood_witness`] wraps. @@ -2332,13 +2379,14 @@ mod tests { let spend = bundle.actions()[0].spend(); let fvk = FullViewingKey::from_bytes(&spec.fvk).unwrap(); - // The persisted PCZT carries only the placeholder: no alpha, rk = ak (a zero randomizer). + // The in-memory Constructor uses the FVK, but the persisted PCZT carries no FVK or alpha. + assert!(spend.fvk().is_none()); assert!(spend.alpha().is_none()); assert_eq!( <[u8; 32]>::from(spend.rk()), <[u8; 32]>::try_from(&fvk.to_bytes()[0..32]).unwrap() ); - // Full note plaintext, exactly what the caller supplied. + // The note plaintext required by the proof flow remains available, excluding the FVK. assert_eq!(spend.value().as_ref().map(|v| v.inner()), Some(spec.value)); assert_eq!(spend.rho().as_ref().map(|r| r.to_bytes()), Some(spec.rho)); assert_eq!( @@ -2352,7 +2400,6 @@ mod tests { .map(|a| a.to_raw_address_bytes().to_vec()), Some(spec.recipient.to_vec()) ); - assert_eq!(spend.fvk().as_ref().map(|f| f.to_bytes()), Some(spec.fvk)); assert!(spend.witness().is_some()); assert!(spend.spend_auth_sig().is_none()); assert!(spend.dummy_sk().is_none()); @@ -2363,20 +2410,26 @@ mod tests { .map(|v| v.as_slice()), Some(&spec.cmx.to_vec()[..]) ); - // Spend-only action: the paired output side is the builder's random dummy note — a real - // note commitment (random recipient, zero value) no one can detect, decrypt, or spend, - // in contrast to the real spend which carries the wallet's note plaintext. + assert_eq!( + spend + .proprietary() + .get(super::super::ironwood_pczt::SPEND_RK_STATE_PROPRIETARY_KEY) + .map(|v| v.as_slice()), + Some(&[0u8][..]) + ); + // Spend-only action: the paired output side is a zero-valued dummy note. let output = bundle.actions()[0].output(); assert!(output.recipient().is_some()); assert_eq!(output.value().as_ref().map(|v| v.inner()), Some(0)); assert_ne!(output.cmx().to_bytes(), spec.cmx); - // The full plaintext round-trips the wire losslessly. let bytes = serialize_pczt(&bundle).unwrap(); - assert_eq!( - serialize_pczt(&deserialize_pczt(&bytes).unwrap()).unwrap(), - bytes - ); + assert!(!bytes + .windows(spec.fvk.len()) + .any(|window| window == &spec.fvk[..])); + let roundtrip = deserialize_pczt(&bytes).unwrap(); + assert!(roundtrip.actions()[0].spend().fvk().is_none()); + assert_eq!(serialize_pczt(&roundtrip).unwrap(), bytes); } #[test] @@ -2517,7 +2570,8 @@ mod tests { let (bundle, _, _) = construct_spend_pczt_multi(&[spec], &[], &anchor, OsRng).unwrap(); verify_spend_pczt(&bundle).unwrap(); - // One spend + one output: all three checks run (nullifier, note commitment, cv_net). + // The Constructor ran nullifier, note-commitment, and cv_net checks before serializing; the + // returned PCZT intentionally no longer carries the FVK needed to repeat spend checks. let (bundle, _, _) = construct_spend_pczt_multi( &[spec], &[IronwoodOutputSpec { diff --git a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs index 6a63a87bf03..591e80b9275 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs @@ -28,7 +28,10 @@ use orchard::value::Sign; use orchard::{ProtocolVersion, ValuePool}; /// Wire format version; bump on any layout change (deserialize rejects unknown versions). -pub const FORMAT_VERSION: u8 = 0x01; +pub const FORMAT_VERSION: u8 = 0x02; + +/// Per-spend metadata key recording whether a real spend's placeholder `rk` was replaced. +pub(crate) const SPEND_RK_STATE_PROPRIETARY_KEY: &str = "bitgo.rk_state"; /// Errors from Ironwood PCZT (de)serialization. #[derive(Debug, strum::IntoStaticStr)] @@ -111,7 +114,7 @@ struct SpendWire { value: Option, rho: Option<[u8; 32]>, rseed: Option<[u8; 32]>, - fvk: Option>, // 96 + fvk: Option>, // 96 — None for real spends; dummy spends carry their fabricating fvk witness: Option, alpha: Option<[u8; 32]>, dummy_sk: Option<[u8; 32]>, @@ -207,14 +210,30 @@ pub fn with_out_ciphertext( }) } -/// Replace one action spend's `rk` (spend validating key) in an already-serialized PCZT. -/// -/// Rejects an `rk` that is not a canonical RedPallas verification key; every other field of the -/// bundle/action is untouched. +/// Replace one action spend's `rk` (spend validating key) and mark it ready for sighashing. +/// Rejects an `rk` that is not a canonical RedPallas verification key. pub fn with_spend_rk( bytes: &[u8], action_index: usize, rk: [u8; 32], +) -> Result, IronwoodPcztError> { + patch_spend_rk(bytes, action_index, rk, false) +} + +/// Set the constructor placeholder `rk` and mark it as requiring signer replacement. +pub(crate) fn with_placeholder_spend_rk( + bytes: &[u8], + action_index: usize, + rk: [u8; 32], +) -> Result, IronwoodPcztError> { + patch_spend_rk(bytes, action_index, rk, true) +} + +fn patch_spend_rk( + bytes: &[u8], + action_index: usize, + rk: [u8; 32], + is_placeholder: bool, ) -> Result, IronwoodPcztError> { with_patched_wire(bytes, |wire| { let spend = &mut wire @@ -226,6 +245,55 @@ pub fn with_spend_rk( return Err(IronwoodPcztError::BadFieldEncoding("spend.rk")); } spend.rk = rk; + spend.proprietary.insert( + SPEND_RK_STATE_PROPRIETARY_KEY.to_string(), + vec![u8::from(!is_placeholder)], + ); + Ok(()) + }) +} + +/// Replace one action spend's plaintext `value` in an already-serialized PCZT. +/// +/// Test/defense tooling for the plaintext-integrity cross-check: a spend's plaintext value is +/// NOT bound by the proofs (only `cv_net`/`cm` are), so a value that disagrees with the bundle's +/// committed `value_sum` must be caught by `ironwood_bundle_info`'s cross-check instead of +/// silently flowing into fee accounting. The committed `value_sum` field is untouched. +pub fn with_spend_value( + bytes: &[u8], + action_index: usize, + value: u64, +) -> Result, IronwoodPcztError> { + with_patched_wire(bytes, |wire| { + wire.actions + .get_mut(action_index) + .ok_or(IronwoodPcztError::BadFieldEncoding("actions[action_index]"))? + .spend + .value = Some(value); + Ok(()) + }) +} + +/// Clear one action spend's `fvk` in an already-serialized PCZT. +/// +/// The Constructor role's real spends are built with the full viewing key held +/// transiently (the caller supplied it), so `bundle_to_wire` serializes it — +/// but the serialized PCZT the Constructor hands on must be keyless: the FVK +/// never leaves its holder (the client in the keyless flow, KMS in the keyed +/// flow). `scrub_spend_randomizers` clears it for every real spend, next to +/// the `alpha`/`rk` scrub. Dummy spends (shielding builds) keep their wire +/// `fvk`: it is the fabricating builder's random key, and `create_proof` +/// requires an fvk on every spend. +pub fn with_spend_fvk_cleared( + bytes: &[u8], + action_index: usize, +) -> Result, IronwoodPcztError> { + with_patched_wire(bytes, |wire| { + wire.actions + .get_mut(action_index) + .ok_or(IronwoodPcztError::BadFieldEncoding("actions[action_index]"))? + .spend + .fvk = None; Ok(()) }) } @@ -248,16 +316,21 @@ pub fn with_spend_auth_sig( }) } -/// Clear one action spend's `alpha` (spend authorizing randomizer) in an already-serialized PCZT. +/// Set or clear one action spend's `alpha` (spend authorizing randomizer) in an +/// already-serialized PCZT. /// -/// No-op when already absent; every other field of the bundle/action is untouched. -pub fn clear_spend_alpha(bytes: &[u8], action_index: usize) -> Result, IronwoodPcztError> { +/// `None` clears it; every other field of the bundle/action is untouched. +pub fn with_spend_alpha( + bytes: &[u8], + action_index: usize, + alpha: Option<[u8; 32]>, +) -> Result, IronwoodPcztError> { with_patched_wire(bytes, |wire| { wire.actions .get_mut(action_index) .ok_or(IronwoodPcztError::BadFieldEncoding("actions[action_index]"))? .spend - .alpha = None; + .alpha = alpha; Ok(()) }) } @@ -432,7 +505,6 @@ fn wire_to_spend(s: SpendWire, note_version: NoteVersion) -> Result(v, "spend.recipient")) .transpose()?; - let fvk = s.fvk.as_deref().map(|v| arr::<96>(v, "fvk")).transpose()?; let witness = s .witness .map(|w| -> Result<_, IronwoodPcztError> { @@ -443,6 +515,7 @@ fn wire_to_spend(s: SpendWire, note_version: NoteVersion) -> Result(v, "fvk")).transpose()?; PcztSpend::parse( s.nullifier, @@ -500,10 +573,13 @@ mod tests { use ff::Field; use orchard::builder::{Builder, BundleType}; use orchard::keys::{FullViewingKey, Scope, SpendAuthorizingKey, SpendingKey}; + use orchard::note::{Note, Rho}; use orchard::tree::Anchor; use orchard::value::NoteValue; use rand::rngs::OsRng; + use crate::zcash::ironwood_build::test_field_bytes as field_bytes; + /// Construct a shielding PCZT (one Ironwood output, dummy spend) via the orchard Constructor. fn sample_pczt() -> PcztBundle { let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); @@ -630,19 +706,38 @@ mod tests { } #[test] - fn with_spend_rk_replaces_the_rk() { + fn rk_patcher_tracks_placeholder_and_replaced_states() { let bytes = serialize_pczt(&sample_pczt()).unwrap(); let old_rk = <[u8; 32]>::from(deserialize_pczt(&bytes).unwrap().actions()[0].spend().rk()); + let placeholder = with_placeholder_spend_rk(&bytes, 0, old_rk).unwrap(); + let placeholder_bundle = deserialize_pczt(&placeholder).unwrap(); + assert_eq!( + placeholder_bundle.actions()[0] + .spend() + .proprietary() + .get(SPEND_RK_STATE_PROPRIETARY_KEY) + .map(|state| state.as_slice()), + Some(&[0u8][..]) + ); + let sk2 = Option::::from(SpendingKey::from_bytes([9u8; 32])).unwrap(); let new_rk = <[u8; 32]>::from(redpallas::VerificationKey::from( &SpendAuthorizingKey::from(&sk2).randomize(&pasta_curves::pallas::Scalar::ZERO), )); assert_ne!(old_rk, new_rk); - let patched = with_spend_rk(&bytes, 0, new_rk).unwrap(); + let patched = with_spend_rk(&placeholder, 0, new_rk).unwrap(); let bundle = deserialize_pczt(&patched).unwrap(); assert_eq!(<[u8; 32]>::from(bundle.actions()[0].spend().rk()), new_rk); + assert_eq!( + bundle.actions()[0] + .spend() + .proprietary() + .get(SPEND_RK_STATE_PROPRIETARY_KEY) + .map(|state| state.as_slice()), + Some(&[1u8][..]) + ); assert_eq!(serialize_pczt(&bundle).unwrap(), patched); assert_eq!(with_spend_rk(&patched, 0, new_rk).unwrap(), patched); } @@ -701,22 +796,253 @@ mod tests { } #[test] - fn clear_spend_alpha_clears_the_randomizer() { + fn with_spend_alpha_sets_and_clears_the_randomizer() { let bytes = serialize_pczt(&sample_pczt()).unwrap(); assert!(deserialize_pczt(&bytes).unwrap().actions()[0] .spend() .alpha() .is_some()); - let cleared = clear_spend_alpha(&bytes, 0).unwrap(); + + // Set an arbitrary canonical alpha, then clear it again. + let alpha = field_bytes(7); + let set = with_spend_alpha(&bytes, 0, Some(alpha)).unwrap(); + assert_eq!( + deserialize_pczt(&set).unwrap().actions()[0] + .spend() + .alpha() + .map(|a| a.to_repr()), + Some(alpha) + ); + + let cleared = with_spend_alpha(&set, 0, None).unwrap(); assert!(deserialize_pczt(&cleared).unwrap().actions()[0] .spend() .alpha() .is_none()); - // Idempotent, and the rest of the bundle round-trips byte-stable. - assert_eq!(clear_spend_alpha(&cleared, 0).unwrap(), cleared); + // Clearing the original bundle reproduces the byte-stable state, and the rest of the + // bundle round-trips byte-stable. assert_eq!( serialize_pczt(&deserialize_pczt(&cleared).unwrap()).unwrap(), cleared ); } + + /// A PCZT whose spend is fully populated — every optional `SpendWire` field + /// (`witness`, `fvk`, `alpha`, `rho`, `rseed`, `value`, `recipient`, `spend_auth_sig`) is + /// set at once, which no single stage of the real flow produces (the Constructor omits the + /// FVK and `alpha` from real spends), so the round-trip and negative tests below exercise + /// every optional field's encoding. + /// + /// The orchard structs are hand-assembled via `parse` rather than built with + /// `Builder::add_spend`, so every field can be set directly. Cryptographically valid encodings are harvested from [`sample_pczt`]'s + /// Builder output: the dummy spend's `nullifier`/`rk`/`cv_net` are real curve values, and + /// the real output note's fields (`recipient`/`value`/`rseed`/`cmx`) satisfy the + /// nullifier-is-rho coupling that + /// [`compute_out_ciphertext`](super::super::ironwood_build::compute_out_ciphertext) + /// relies on. + fn sample_spend_pczt() -> PcztBundle { + let base = sample_pczt(); + let base_bytes = serialize_pczt(&base).unwrap(); + let base_wire: BundleWire = postcard::from_bytes(&base_bytes[1..]).unwrap(); + let a = &base_wire.actions[0]; + + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + let fvk = FullViewingKey::from(&sk); + + // Recipient/value/rseed from the real output note; the paired dummy spend's + // nullifier serves both as the spent note's rho (rseed validates against it) and as + // the output note's rho (the coupling pinned by the test below). + let recipient: [u8; 43] = a.output.recipient.clone().unwrap().try_into().unwrap(); + let value = a.output.value.unwrap(); + let rseed = a.output.rseed.unwrap(); + let nullifier = a.spend.nullifier; + let rho = nullifier; + + // 32 canonical Merkle-hash encodings. `Spend::parse` only validates that each auth + // path entry decodes as a base-field element (`MerkleHashOrchard::from_bytes` is + // `pallas::Base::from_repr`), not tree structure, so small integers are valid + // stand-ins for sibling hashes (real witnesses are covered by the `ironwood_build` tests). + let auth_path: [[u8; 32]; 32] = core::array::from_fn(|i| { + let mut b = [0u8; 32]; + b[0] = i as u8; + b + }); + + // `alpha` is any canonical Pallas scalar; `[1u8; 32]` is 0x0101..01 < p. + let alpha = [1u8; 32]; + // `spend_auth_sig` is an opaque 64-byte redpallas signature at parse time. + let spend_auth_sig = [0xabu8; 64]; + + let spend = PcztSpend::parse( + nullifier, + a.spend.rk, + Some(spend_auth_sig), + Some(recipient), + Some(value), + Some(rho), + Some(rseed), + Some(fvk.to_bytes()), + Some((0u32, auth_path)), + Some(alpha), + None, // zip32_derivation: stays dropped (flat per-wallet key) + None, // dummy_sk: a real spend, not a dummy + BundleVersion::ironwood_v3().note_version(), + BTreeMap::new(), + ) + .unwrap(); + + let output = PcztOutput::parse( + Option::::from(Nullifier::from_bytes(&nullifier)).unwrap(), + a.output.cmx, + a.output.ephemeral_key, + a.output.enc_ciphertext.clone(), + a.output.out_ciphertext.clone(), + Some(recipient), + Some(value), + Some(rseed), + a.output.ock, + None, + None, + BundleVersion::ironwood_v3().note_version(), + BTreeMap::new(), + ) + .unwrap(); + + let action = PcztAction::parse(a.cv_net, spend, output, a.rcv).unwrap(); + + PcztBundle::parse( + vec![action], + base_wire.flags, + BundleVersion::ironwood_v3(), + (base_wire.value_sum_magnitude, base_wire.value_sum_negative), + base_wire.anchor, + None, + None, + ) + .unwrap() + } + + /// Round-trip a fully-populated real spend and assert byte-exact re-serialization. + #[test] + fn spend_fields_roundtrip_is_byte_stable() { + let bundle = sample_spend_pczt(); + let bytes = serialize_pczt(&bundle).unwrap(); + assert_eq!(bytes[0], FORMAT_VERSION); + + let wire: BundleWire = postcard::from_bytes(&bytes[1..]).unwrap(); + let s = &wire.actions[0].spend; + assert!(s.spend_auth_sig.is_some()); + assert!(s.recipient.is_some()); + assert!(s.value.is_some()); + assert!(s.rho.is_some()); + assert!(s.rseed.is_some()); + assert!(s.fvk.is_some()); + assert!(s.witness.is_some()); + assert!(s.alpha.is_some()); + assert!(s.dummy_sk.is_none(), "a real spend carries no dummy_sk"); + assert_eq!(s.witness.as_ref().unwrap().auth_path.len(), 32); + + let bundle2 = deserialize_pczt(&bytes).unwrap(); + let bytes2 = serialize_pczt(&bundle2).unwrap(); + assert_eq!( + bytes, bytes2, + "serialize∘deserialize is byte-stable for a populated spend" + ); + + // And a third pass, for good measure. + let bundle3 = deserialize_pczt(&bytes2).unwrap(); + assert_eq!(serialize_pczt(&bundle3).unwrap(), bytes); + } + + /// The spend-path negative tests: malformed field lengths in `wire_to_spend` must reject + /// with the exact error variant. + #[test] + fn spend_path_rejects_malformed_field_lengths() { + let mk_wire = || { + let bytes = serialize_pczt(&sample_spend_pczt()).unwrap(); + postcard::from_bytes::(&bytes[1..]).unwrap() + }; + let reencode = |wire: &BundleWire| { + let mut out = vec![FORMAT_VERSION]; + out.extend_from_slice(&postcard::to_stdvec(wire).unwrap()); + out + }; + + // `fvk` must be 96 bytes. + let mut wire = mk_wire(); + wire.actions[0].spend.fvk = Some(vec![0u8; 95]); + assert!(matches!( + deserialize_pczt(&reencode(&wire)), + Err(IronwoodPcztError::BadLength("fvk", 96, 95)) + )); + + // `spend_auth_sig` must be 64 bytes. + let mut wire = mk_wire(); + wire.actions[0].spend.spend_auth_sig = Some(vec![0u8; 63]); + assert!(matches!( + deserialize_pczt(&reencode(&wire)), + Err(IronwoodPcztError::BadLength("spend_auth_sig", 64, 63)) + )); + + // `spend.recipient` must be 43 bytes. + let mut wire = mk_wire(); + wire.actions[0].spend.recipient = Some(vec![0u8; 42]); + assert!(matches!( + deserialize_pczt(&reencode(&wire)), + Err(IronwoodPcztError::BadLength("spend.recipient", 43, 42)) + )); + + // `witness.auth_path` must have exactly 32 entries. + let mut wire = mk_wire(); + wire.actions[0] + .spend + .witness + .as_mut() + .unwrap() + .auth_path + .truncate(31); + assert!(matches!( + deserialize_pczt(&reencode(&wire)), + Err(IronwoodPcztError::BadFieldEncoding("witness.auth_path")) + )); + } + + /// Pins the nullifier-is-rho coupling: for a Builder-produced Ironwood + /// bundle, the output note's rho is the paired spend's nullifier, so the note reconstructs + /// from wire fields and re-derives the committed `cmx`. If this coupling ever broke, + /// `compute_out_ciphertext` would fail as a confusing `NoteCommitmentMismatch`. + #[test] + fn output_note_rho_is_paired_spend_nullifier() { + for bundle in [sample_pczt(), sample_spend_pczt()] { + let bundle = deserialize_pczt(&serialize_pczt(&bundle).unwrap()).unwrap(); + let action = bundle.actions().first().unwrap(); + let spend = action.spend(); + let output = action.output(); + + let nf = spend.nullifier().to_bytes(); + let rho = Option::::from(Rho::from_bytes(&nf)).expect("nullifier decodes as rho"); + let note = Option::::from(Note::from_parts( + *output.recipient().as_ref().unwrap(), + *output.value().as_ref().unwrap(), + rho, + *output.rseed().as_ref().unwrap(), + *output.note_version(), + )) + .unwrap(); + assert_eq!( + orchard::note::ExtractedNoteCommitment::from(note.commitment()).to_bytes(), + output.cmx().to_bytes() + ); + } + } + + /// The spend fields are `Option`s in the wire layout, so populating them is not a layout + /// change: a fully-populated spend serializes under the current `FORMAT_VERSION` and + /// deserializes cleanly. + #[test] + fn populated_spend_fields_use_the_current_format_version() { + let bytes = serialize_pczt(&sample_spend_pczt()).unwrap(); + assert_eq!(bytes[0], FORMAT_VERSION); + assert!(deserialize_pczt(&bytes).is_ok()); + } } diff --git a/packages/wasm-utxo/src/zcash/transaction.rs b/packages/wasm-utxo/src/zcash/transaction.rs index 4e827a76610..5f9f4f53bbc 100644 --- a/packages/wasm-utxo/src/zcash/transaction.rs +++ b/packages/wasm-utxo/src/zcash/transaction.rs @@ -240,3 +240,20 @@ pub fn encode_zcash_transaction_parts(parts: &ZcashTransactionParts) -> Result [u8; 32] { let inputs = &tx.transparent.input; - // Per ZIP-244, the sig transparent digest collapses to the txid one only when there are - // neither transparent inputs nor outputs (e.g. a fully-shielded tx). With outputs but no - // inputs (unshield: shielded spend -> transparent output), the full S.2 structure still - // applies, since hash_type/amounts/scripts/txin differ from the txid digest whenever - // outputs is non-empty. - if inputs.is_empty() && tx.transparent.output.is_empty() { + // ZIP-244 uses the transparent txid digest for shielded signatures whenever there are no + // transparent inputs, even if transparent outputs are present (the unshield case). + if inputs.is_empty() { return transparent_txid_digest(tx); } @@ -1048,10 +1045,9 @@ mod tests { } #[test] - fn shielded_sig_digest_unshield_case_differs_from_txid() { - // Unshield: transparent output present, no transparent inputs. The sig transparent - // digest must NOT collapse to the txid digest here, since the txid digest omits - // hash_type/amounts/scripts/txin while the sig digest includes them. + fn shielded_sig_digest_without_inputs_uses_txid_transparent_digest() { + // Unshield: transparent outputs are present, but the ZIP-244 shielded signature digest + // reuses the transparent txid digest because there are no transparent inputs. let (_, output) = { let sample = sample_transparent(true); (sample.input, sample.output) @@ -1070,7 +1066,7 @@ mod tests { sapling_value_balance: 0, ironwood_bundle: Some(sample_bundle()), }; - assert_ne!(compute_v6_sig_digest(&tx, &[], &[]), compute_v6_txid(&tx)); + assert_eq!(compute_v6_sig_digest(&tx, &[], &[]), compute_v6_txid(&tx)); } #[test] diff --git a/packages/wasm-utxo/test/fixtures/zcash/ironwood_pczt_wire_golden.hex b/packages/wasm-utxo/test/fixtures/zcash/ironwood_pczt_wire_golden.hex new file mode 100644 index 00000000000..b2574887869 --- /dev/null +++ b/packages/wasm-utxo/test/fixtures/zcash/ironwood_pczt_wire_golden.hex @@ -0,0 +1 @@ +02070102904e004471f96dda8cbcf521e37b93069ac110c44f6e7e8bb0187effab3ee12727371100000101fa0f036daffeec642f3cae00b956a47bb6f06cf064fbadcff981611b10ce2001fe469674efe118be17a44ba7b64848e14a4b8b2444e21a73cdf3beb6b384933367f05c5293408554dc2bc7ee3019c12f903661c67a2bd60309b6fb663b3548236ebb833c1d2f8433080abceabe47906097f90678d603f577d0486c9111737b0700012b4559029c0b5dbf941c5ad181a5fe8f45b34630f29d0c8dd8dc1cc3573386f416cb324133156d723df5e62d0180c2d72f016400000000000000000000000000000000000000000000000000000000000000012a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a000105200100000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000050000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000700000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000009000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000b000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000d000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000f0000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001100000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000013000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000150000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000001700000000000000000000000000000000000000000000000000000000000000180000000000000000000000000000000000000000000000000000000000000019000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000001b000000000000000000000000000000000000000000000000000000000000001c000000000000000000000000000000000000000000000000000000000000001d000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000001f0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000020e626974676f2e726b5f7374617465010003636d782005bf6975af55c73bdc20a0f464f899a36397a67581b592b6c27226e7b9ccd633d749f7e430df72e9626d4e629573890b93e6dd609986292497d9327e41c2eb16dbbb287c1a8999c54b5b78df92eba32beb90c1418ebe8011883e684d42b4ba20c404ae3cf498c2fd92c12cf410bbabef65775a0151974c971cdeb0b40216f1dd79518a764aca3ed57d4bf7f7c610d30b880502832b1aad6437b568d8d3b3357d9ad597c26b3a61e68903984dc67364a4683c28ce331118a46106ae0d6770cb3289c313c2128226d688a2fc7b5aac0db7c61fe0ca27e11b3d2b5fa568f175d6e2a6ed7053b580880cfed34e6f5f47e4fcb87b590cbaee5b26f88af32eba80ed7fe78c4961c8eff9bc8ea3f29b690a299b1f7ed63012a7f2abca6febea079d876b86641c3298f670bb90798a1c3b17e864892e225a20fec078ad51fd66ee9aeb20c84473d776629913ed0182667c68607a30034c93f5f3d83fdd11230a34c9591845a6972c3b839c5dd7cc83dad2493573ce4f784ab2b5b4ccc82491d69fb1507d0c02ea223e7ec8fa0ce54d19decdb4bc5904b10b4918875a0744d2a4c60c0a2af391039fb758da306a76002b6eac963904b0e5b81499cc582a8707d3f03069b58b5674edc315e62672762b2c88a329406ec1dc7b0644f4b8ec7fd106fd0d7976c44d6f040fe2092d0d60abb0876de783b83c27d961abc02d11ba15bb2c0ad23894fafa76ed72a63902796698db20828215d94ffe307b44c26a6e71cda35a488d249394b995c696490accd95d3ed284f20b5d32d2c1baa89e137fad8cadb247bc8d603866e24e5c6d8873ecf3f537cbd4479b5902f26538628412adc356596497936e6433b9ab56031ab67464879b3eb6ce154921c3b7385248d31c413ac0a3f3219a5892e32ac97b5b0c9aa7c6d1de77a0b87e565e808e4a919ca73c86d708525af054c9762350393196dd7a4178fedf45221a4a198ef5e7e20b01da65c73d5e3d18d8ec4a25984547208018b8cc75d149cc3bb50c04471554a676b79423cd2132f8769ba1265c24216d6216cea802a4819bb9b190c5e6012b4559029c0b5dbf941c5ad181a5fe8f45b34630f29d0c8dd8dc1cc3573386f416cb324133156d723df5e62d01f0f3d62f01e6f167080c4e12dd5bfca5fdcedb48b17adf3ba87d67300bda1f5f3901a73a8a000000