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 954a03ca43e..34619ba5ce7 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 @@ -795,6 +795,13 @@ impl BitGoPsbt { ..Default::default() }; + // Rule 0 (plan §3): the Zcash arm rejects a transparent input on a PSBT that already + // spends shielded notes. A stored PCZT with only dummy spends — the shielding flow — + // is fine. + if let BitGoPsbt::Zcash(zcash, _) = self { + zcash.reject_transparent_input_with_spend_bundle()?; + } + crate::psbt_ops::insert_input(self.psbt_mut(), index, tx_in, psbt_input) } @@ -894,6 +901,12 @@ impl BitGoPsbt { value: u64, options: ReplayProtectionOptions, ) -> Result { + // Rule 0 (plan §3): the Zcash arm rejects a transparent input on a PSBT that already + // spends shielded notes (see `add_input_at_index`). + if let BitGoPsbt::Zcash(zcash, _) = self { + zcash.reject_transparent_input_with_spend_bundle()?; + } + let network = self.network(); Self::add_replay_protection_input_to_psbt( self.psbt_mut(), @@ -1157,6 +1170,12 @@ impl BitGoPsbt { script_id: ScriptId, options: WalletInputOptions, ) -> Result { + // Rule 0 (plan §3): the Zcash arm rejects a transparent input on a PSBT that already + // spends shielded notes (see `add_input_at_index`). + if let BitGoPsbt::Zcash(zcash, _) = self { + zcash.reject_transparent_input_with_spend_bundle()?; + } + let network = self.network(); Self::add_wallet_input_to_psbt( self.psbt_mut(), diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/error.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/error.rs new file mode 100644 index 00000000000..c56495c17ad --- /dev/null +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/error.rs @@ -0,0 +1,77 @@ +//! Typed errors for the Ironwood PSBT workflow (plan §6.4). +//! +//! Every new Ironwood method on [`super::ZcashBitGoPsbt`] returns +//! [`IronwoodPsbtError`], whose variant name is the JS `err.code` +//! (`"IronwoodPsbtError.MixedInputKinds"`, …) via [`crate::error::WasmUtxoError`]. Wrapped +//! lower-layer errors surface their own codes instead: `Build`/`Verify`/`Pczt` re-raise the +//! constructor, key-free-verifier and wire errors with their established +//! `IronwoodBuildError.*` / `IronwoodVerifyError.*` / `IronwoodPcztError.*` codes, so callers +//! key diagnostics on the layer that failed. + +/// An Ironwood PSBT workflow error. +#[derive(Debug, strum::IntoStaticStr)] +pub enum IronwoodPsbtError { + /// Rule 0 (plan §3): a v6 PSBT spends either transparent inputs or shielded notes, + /// never both. [`super::ZcashBitGoPsbt::add_shielded_spends`] rejects a PSBT that + /// already carries transparent inputs. + MixedInputKinds, + /// Only one Ironwood Constructor call is supported per PSBT — calling it again would + /// silently overwrite the first batch, whose value the transparent side is funding. + AlreadyAdded, + /// A caller-supplied Unified Address is not a valid Unified Address, has no Orchard + /// receiver, or its Orchard receiver is not the output's `recipient`. + BadUnifiedAddress(String), + /// The Ironwood Constructor rejected the request; the wrapped + /// [`IronwoodBuildError`](crate::zcash::ironwood_build::IronwoodBuildError) carries the + /// reason (and its own `IronwoodBuildError.*` code). + Build(crate::zcash::ironwood_build::IronwoodBuildError), + /// The Ironwood PCZT wire round-trip failed inside a PSBT operation; the wrapped + /// [`IronwoodPcztError`](crate::zcash::ironwood_pczt::IronwoodPcztError) carries the + /// reason. + Pczt(crate::zcash::ironwood_pczt::IronwoodPcztError), +} + +impl std::fmt::Display for IronwoodPsbtError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + IronwoodPsbtError::MixedInputKinds => write!( + f, + "a v6 PSBT spends either transparent inputs or shielded notes, never both; \ + build the shielded spends in a PSBT without transparent inputs" + ), + IronwoodPsbtError::AlreadyAdded => write!( + f, + "an Ironwood PCZT is already present; only one call is supported" + ), + IronwoodPsbtError::BadUnifiedAddress(e) => write!(f, "invalid unified address: {e}"), + IronwoodPsbtError::Build(e) => write!(f, "{e}"), + IronwoodPsbtError::Pczt(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for IronwoodPsbtError {} + +impl From for IronwoodPsbtError { + fn from(error: crate::zcash::ironwood_build::IronwoodBuildError) -> Self { + IronwoodPsbtError::Build(error) + } +} + +impl From for IronwoodPsbtError { + fn from(error: crate::zcash::ironwood_pczt::IronwoodPcztError) -> Self { + IronwoodPsbtError::Pczt(error) + } +} + +/// Wrapped lower-layer errors surface their own code (`IronwoodBuildError.OvkNotAllowed`, …), +/// not the wrapping variant's name; the PSBT-level variants use `IronwoodPsbtError.*`. +impl crate::error::WasmErrorCode for IronwoodPsbtError { + fn code(&self) -> String { + match self { + IronwoodPsbtError::Build(error) => crate::error::WasmErrorCode::code(error), + IronwoodPsbtError::Pczt(error) => crate::error::WasmErrorCode::code(error), + other => format!("IronwoodPsbtError.{}", <&'static str>::from(other)), + } + } +} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs index 05813bbaa08..5ef9bd7515b 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs @@ -40,6 +40,10 @@ pub struct IronwoodOutputRequest { /// (to check note ownership and derive the nullifier) and omitted from the serialized PCZT; its /// `ak` is used as the placeholder `rk` until the signer delivers the randomized one /// (see [`ZcashBitGoPsbt::replace_ironwood_spend_rk`]). +/// +/// Oracle-only: production spends go through +/// [`crate::zcash::ironwood_construct::IronwoodKeylessSpendSpec`] (plan PR 5). +#[cfg(any(test, feature = "orchard-proving"))] #[derive(Clone)] pub struct IronwoodSpendRequest { /// Raw full viewing key, 96 bytes: `ak ‖ nk ‖ rivk` (orchard's `FullViewingKey::to_bytes` @@ -151,7 +155,7 @@ impl ZcashBitGoPsbt { if requests.is_empty() { return Err("at least one Ironwood output is required".to_string()); } - let action_map = self.add_ironwood_actions(&[], requests, anchor, rng)?; + let action_map = self.add_ironwood_output_actions(requests, anchor, rng)?; Ok(action_map.output_action_indices().to_vec()) } @@ -183,6 +187,82 @@ impl ZcashBitGoPsbt { Ok(output_indices[0]) } + /// The outputs-only Constructor + store path behind [`Self::add_ironwood_outputs`]: build + /// the shielded outputs as a single keyless orchard PCZT (the shielding flow — no real + /// spends, no FVK) and store it with the per-output Unified Addresses. + fn add_ironwood_output_actions( + &mut self, + outputs: &[IronwoodOutputRequest], + anchor: &crate::zcash::ironwood_build::AnchorBytes, + rng: R, + ) -> Result { + use crate::zcash::ironwood_build; + + 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(), + ); + } + for req in outputs { + if let Some(ua) = &req.unified_address { + self.check_unified_address_matches_recipient(ua, &req.recipient)?; + } + } + 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, action_map) = + ironwood_build::construct_shield_pczt_multi(&output_specs, anchor, rng) + .map_err(|e| e.to_string())?; + self.store_ironwood_pczt_and_unified_addresses(pczt, action_map, outputs) + .map_err(|e| e.to_string()) + } + + /// The shared Constructor tail: serialize the constructed PCZT, store it, and install the + /// per-output Unified Addresses keyed by action index (clearing any stale ones first — + /// see [`super::propkv::clear_ironwood_unified_addresses`]). + pub(crate) fn store_ironwood_pczt_and_unified_addresses( + &mut self, + pczt: orchard::pczt::Bundle, + action_map: crate::zcash::ironwood_build::IronwoodActionMap, + outputs: &[IronwoodOutputRequest], + ) -> Result + { + use crate::zcash::ironwood_pczt; + + // Everything fallible happens before the first write, so a failure leaves the PSBT + // untouched. + let bytes = ironwood_pczt::serialize_pczt(&pczt)?; + let unified_addresses = outputs + .iter() + .enumerate() + .filter_map(|(i, req)| req.unified_address.as_ref().map(|ua| (i, ua))) + .map(|(i, ua)| { + action_map + .output_action_index(i) + .map(|action_index| (action_index, ua)) + .ok_or(crate::zcash::ironwood_build::IronwoodBuildError::ActionIndexOutOfRange) + }) + .collect::, _>>()?; + + 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 (action_index, ua) in unified_addresses { + super::propkv::set_ironwood_unified_address(&mut self.psbt, action_index, ua); + } + Ok(action_map) + } + /// 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 @@ -200,6 +280,11 @@ impl ZcashBitGoPsbt { /// equal action order. /// /// Real spends leave the placeholder `rk` (= the spend's `ak`) in the stored PCZT. + /// + /// Oracle-only (plan PR 5): production spends go through + /// [`Self::add_shielded_spends`], the keyless Constructor. This keyed path needs the FVK + /// on the PSBT side and is compiled only under `test` / `orchard-proving`. + #[cfg(any(test, feature = "orchard-proving"))] pub fn add_ironwood_actions( &mut self, spends: &[IronwoodSpendRequest], @@ -207,7 +292,7 @@ impl ZcashBitGoPsbt { anchor: &crate::zcash::ironwood_build::AnchorBytes, rng: R, ) -> Result { - use crate::zcash::{ironwood_build, ironwood_pczt}; + use crate::zcash::ironwood_build; if super::propkv::get_ironwood_pczt(&self.psbt).is_some() { return Err( @@ -257,27 +342,13 @@ impl ZcashBitGoPsbt { let (pczt, action_map) = 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 (i, req) in outputs.iter().enumerate() { - if let Some(ua) = &req.unified_address { - let action_index = action_map - .output_action_index(i) - .ok_or_else(|| format!("output {i}: no action index"))?; - super::propkv::set_ironwood_unified_address(&mut self.psbt, action_index, ua); - } - } - Ok(action_map) + self.store_ironwood_pczt_and_unified_addresses(pczt, action_map, outputs) + .map_err(|e| e.to_string()) } /// 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( + pub(crate) fn check_unified_address_matches_recipient( &self, ua: &str, recipient: &crate::zcash::ironwood_build::OrchardAddressBytes, @@ -301,6 +372,9 @@ impl ZcashBitGoPsbt { /// 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. + /// + /// Oracle-only: see [`Self::add_ironwood_actions`]. + #[cfg(any(test, feature = "orchard-proving"))] pub fn add_ironwood_spends( &mut self, requests: &[IronwoodSpendRequest], diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood_spend.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood_spend.rs new file mode 100644 index 00000000000..e9e018885f3 --- /dev/null +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood_spend.rs @@ -0,0 +1,121 @@ +//! The Ironwood **spend** PSBT workflow (plan §4/§6.2): the keyless Constructor entry point +//! ([`ZcashBitGoPsbt::add_shielded_spends`]) and the one-input-kind rule (plan rule 0). +//! +//! The build service (IMS) selects notes and calls [`ZcashBitGoPsbt::add_shielded_spends`] +//! with the notes' plaintext, their witnesses, the KMS-derived nullifiers, the requested +//! outputs and the wallet's spend-validating key `ak` — and no viewing key. The constructed +//! PCZT is stored in the PSBT together with `ak` (proprietary key `0x08`), which the key-free +//! verifier (plan §5) reads for the `Rk` check, and the per-output Unified Addresses. +//! +//! Rule 0 (plan §3): a v6 PSBT spends **either** transparent inputs (transparent v6 or +//! shielding) **or** shielded notes (deshield / shielded→shielded), never both. The shielded +//! side enforces it here ([`ZcashBitGoPsbt::add_shielded_spends`] rejects transparent +//! inputs); the transparent side enforces it in +//! [`ZcashBitGoPsbt::reject_transparent_input_with_spend_bundle`], which the generic input +//! adders call for their Zcash arms, and in `decode_v6` at deserialize time. + +use super::error::IronwoodPsbtError; +use super::{IronwoodOutputRequest, ZcashBitGoPsbt}; + +impl ZcashBitGoPsbt { + /// Constructor role: build the shielded spends (and optionally shielded outputs) as a + /// single **keyless** orchard PCZT and store it in the PSBT — the deshield and + /// shielded→shielded Constructor for a build service that holds no viewing keys (plan §4). + /// + /// Each entry of `spends` carries the spent note's plaintext (`recipient`/`value`/`rho`/ + /// `rseed`/`cmx`), its Merkle witness, and the nullifier (from the note store, or from + /// KMS's `generate-nullifiers`). Each entry of `outputs` is a requested shielded output + /// (its `ovk` must be `None`: KMS owns encryption in the spend flow). The bundle has + /// `max(spends, outputs)` actions; the actions are shuffled, so the returned + /// [`IronwoodActionMap`](crate::zcash::ironwood_build::IronwoodActionMap) gives, for each + /// `spends[i]`/`outputs[i]`, the action index it landed at. + /// + /// Every real spend carries the wallet's `ak` as the placeholder `rk` and no `alpha`; the + /// signer installs `alpha` and the matching `rk = ak.randomize(alpha)` before any sighash + /// is computed (plan rule 2). Every output's `out_ciphertext` is a random placeholder — + /// KMS replaces it under its FVK `ovk` (plan §2 step 3). No FVK ever enters the stored + /// PCZT. + /// + /// Rules enforced here (plan §3): + /// - **rule 0** — rejected when the PSBT already carries transparent inputs + /// ([`IronwoodPsbtError::MixedInputKinds`]); + /// - **one call per PSBT** — a second call would silently overwrite the first batch + /// ([`IronwoodPsbtError::AlreadyAdded`]). + /// + /// Also stored: the caller's `ak` (proprietary key `0x08`) — the key-free verifier's `Rk` + /// check reads it — and each output's Unified Address, keyed by its action index. + pub fn add_shielded_spends( + &mut self, + spends: &[crate::zcash::ironwood_construct::IronwoodKeylessSpendSpec], + outputs: &[IronwoodOutputRequest], + anchor: &crate::zcash::ironwood_build::AnchorBytes, + ak: &[u8; 32], + rng: R, + ) -> Result { + use crate::zcash::{ironwood_build, ironwood_construct}; + + // Rule 0: a spend PSBT has no transparent inputs — the shielded side funds itself. + if !self.psbt.inputs.is_empty() { + return Err(IronwoodPsbtError::MixedInputKinds); + } + // One call per PSBT: a second batch would overwrite the first one's value. + if super::propkv::get_ironwood_pczt(&self.psbt).is_some() { + return Err(IronwoodPsbtError::AlreadyAdded); + } + for req in outputs { + if let Some(ua) = &req.unified_address { + self.check_unified_address_matches_recipient(ua, &req.recipient) + .map_err(IronwoodPsbtError::BadUnifiedAddress)?; + } + } + + 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, action_map) = ironwood_construct::construct_spend_pczt_keyless( + spends, + &output_specs, + anchor, + ak, + rng, + )?; + let action_map = + self.store_ironwood_pczt_and_unified_addresses(pczt, action_map, outputs)?; + // The wallet's spend-validating key, for the key-free verifier's `Rk` check (plan §5). + super::propkv::set_ironwood_spend_validating_key(&mut self.psbt, ak); + Ok(action_map) + } + + /// Rule 0 on the transparent side: reject adding a transparent input to a PSBT whose + /// stored Ironwood bundle already spends real (non-dummy) notes. + /// + /// A stored PCZT with only dummy spends — the **shielding** flow — is fine: transparent + /// inputs funding shielded outputs is exactly what that flow builds. Called by the + /// generic input adders' Zcash arms ([`super::super::BitGoPsbt::add_input_at_index`], + /// `add_wallet_input_at_index`, `add_replay_protection_input_at_index`). + pub(crate) fn reject_transparent_input_with_spend_bundle(&self) -> Result<(), String> { + let Some(bytes) = super::propkv::get_ironwood_pczt(&self.psbt) else { + return Ok(()); + }; + let pczt = crate::zcash::ironwood_pczt::deserialize_pczt(&bytes).map_err(|e| { + format!( + "cannot check the one-input-kind rule: the stored Ironwood PCZT is invalid: {e}" + ) + })?; + if Self::bundle_has_real_spends(&pczt) { + return Err( + "cannot add a transparent input to a v6 PSBT that already spends shielded \ + notes (one input kind per transaction); build the shielded spend in a PSBT \ + without transparent inputs" + .to_string(), + ); + } + Ok(()) + } +} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/mod.rs index b607f45b88b..649354d7cbf 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/mod.rs @@ -12,14 +12,17 @@ pub use crate::zcash::transaction::{ decode_zcash_transaction_meta, ZcashTransactionMeta, ZCASH_SAPLING_VERSION_GROUP_ID, }; +pub(crate) mod error; pub(crate) mod ironwood; +pub(crate) mod ironwood_spend; pub(crate) mod parse; pub(crate) mod propkv; pub(crate) mod v6; -pub use ironwood::{ - IronwoodBundleInfo, IronwoodOutputRequest, IronwoodSpendInfo, IronwoodSpendRequest, -}; +pub use error::IronwoodPsbtError; +#[cfg(any(test, feature = "orchard-proving"))] +pub use ironwood::IronwoodSpendRequest; +pub use ironwood::{IronwoodBundleInfo, IronwoodOutputRequest, IronwoodSpendInfo}; pub use v6::VerifyV6SignatureError; use super::{psbt_wallet_input, BitGoPsbt, DeserializeError}; diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/propkv.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/propkv.rs index cdb7044b48d..0cafbadcee3 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/propkv.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/propkv.rs @@ -293,9 +293,8 @@ pub fn clear_ironwood_unified_addresses(psbt: &mut miniscript::bitcoin::psbt::Ps } /// Store the wallet's spend-validating key (`ak`, 32 bytes) for the stored Ironwood bundle. -/// Written when the bundle is installed (plan PR 5); the keyless verifier reads it for the -/// `Rk` check. -#[allow(dead_code)] // written by plan PR 5's add_shielded_spends +/// Written when the bundle is installed (plan PR 5's `add_shielded_spends`); the keyless +/// verifier reads it for the `Rk` check. pub fn set_ironwood_spend_validating_key( psbt: &mut miniscript::bitcoin::psbt::Psbt, ak: &[u8; 32], diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs new file mode 100644 index 00000000000..beb5d55ad64 --- /dev/null +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs @@ -0,0 +1,749 @@ +//! Tests for the keyless Ironwood spend flow (plan PR 5): `add_shielded_spends`, the +//! one-input-kind rule (rule 0), and the deshield / shielded→shielded lifecycles the build +//! service drives. Native-only (orchard + zebra). + +use super::*; +use crate::bitcoin::hashes::Hash; +use crate::bitcoin::Txid; +use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::WalletInputOptions; +use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt; +use crate::fixed_script_wallet::script_id::ScriptId; +use crate::networks::Network; +use crate::zcash::ironwood_build::{compute_out_ciphertext, IronwoodActionMap}; +use crate::zcash::ironwood_construct::test_util::keyless_fixture; +use crate::zcash::ironwood_pczt::with_out_ciphertext; +use crate::zcash::NetworkUpgrade; +use ff::{Field, PrimeField}; +use orchard::keys::{FullViewingKey, Scope, SpendAuthorizingKey, SpendingKey}; +use orchard::primitives::redpallas::{SpendAuth, VerificationKey}; +use orchard::Proof; +use rand::rngs::OsRng; + +fn test_spending_key() -> SpendingKey { + Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap() +} + +/// A shielded output request back to the wallet itself, at the given diversifier and amount. +fn wallet_output(fvk: &FullViewingKey, diversifier: u32, amount: u64) -> IronwoodOutputRequest { + IronwoodOutputRequest { + recipient: fvk + .address_at(diversifier, Scope::External) + .to_raw_address_bytes(), + amount, + ovk: None, + memo: [0u8; 512], + unified_address: None, + } +} + +/// A deshield PSBT: one real note (100M zatoshi) spent with no shielded outputs, paying a +/// transparent output of 99_999_000 — a 1_000-zatoshi fee, no transparent inputs. +fn keyless_deshield_psbt( + seed: &str, +) -> ( + ZcashBitGoPsbt, + IronwoodActionMap, + [u8; 32], + FullViewingKey, + SpendAuthorizingKey, +) { + let (specs, anchor, fvk, ak) = keyless_fixture(); + 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"); + }; + z.add_transparent_output( + miniscript::bitcoin::ScriptBuf::from(vec![0x76u8, 0xa9, 0x14, 0x88, 0xac]), + 99_999_000, + None, + ) + .unwrap(); + let map = z + .add_shielded_spends(&specs[..1], &[], &anchor, &ak, OsRng) + .unwrap(); + ( + z, + map, + ak, + fvk, + SpendAuthorizingKey::from(&test_spending_key()), + ) +} + +/// A shielded→shielded transfer: two real notes (100M + 80M) spent into two shielded outputs +/// (90M each, back to the wallet's own addresses) — value balance 0, no transparent side. +fn keyless_transfer_psbt( + seed: &str, +) -> ( + ZcashBitGoPsbt, + IronwoodActionMap, + [u8; 32], + FullViewingKey, + SpendAuthorizingKey, +) { + let (specs, anchor, fvk, ak) = keyless_fixture(); + 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 outputs = [ + wallet_output(&fvk, 100, 90_000_000), + wallet_output(&fvk, 101, 90_000_000), + ]; + let map = z + .add_shielded_spends(&specs, &outputs, &anchor, &ak, OsRng) + .unwrap(); + ( + z, + map, + ak, + fvk, + SpendAuthorizingKey::from(&test_spending_key()), + ) +} + +/// KMS's out-ciphertext round (plan §2 step 3): re-encrypt every requested output's +/// `out_ciphertext` under the FVK's external `ovk` and splice it into the stored PCZT. +fn kms_out_ciphertexts(z: &mut ZcashBitGoPsbt, map: &IronwoodActionMap, fvk: &FullViewingKey) { + let ovk = *fvk.to_ovk(Scope::External).as_ref(); + let pczt = z.ironwood_pczt().unwrap(); + let mut bytes = z.raw_ironwood_pczt_bytes().unwrap(); + for &action_index in map.output_action_indices() { + let expected = compute_out_ciphertext(&pczt, action_index, ovk, &mut OsRng).unwrap(); + bytes = with_out_ciphertext(&bytes, action_index, expected).unwrap(); + } + crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::set_ironwood_pczt(&mut z.psbt, bytes); +} + +/// The FROST stand-in round: install `(alpha, rk = ak.randomize(alpha))` on every real spend +/// and return the `(action_index, alpha)` pairs the local prover needs. +/// +/// PR 7's `add_spend_randomizers` batch method does this atomically; until it lands, the +/// alpha goes through the wire patcher and the matching `rk` through the existing +/// `replace_ironwood_spend_rk` — the same two writes, in the same order. +fn install_randomizers( + z: &mut ZcashBitGoPsbt, + map: &IronwoodActionMap, + ask: &SpendAuthorizingKey, +) -> Vec<(usize, [u8; 32])> { + use crate::zcash::ironwood_pczt::with_spend_alpha; + + let mut pairs = Vec::new(); + for &action_index in map.spend_action_indices() { + let alpha = pasta_curves::pallas::Scalar::random(&mut OsRng); + let rk = <[u8; 32]>::from(VerificationKey::::from(&ask.randomize(&alpha))); + // Alpha first, so the stored ak already justifies the rk the verifier checks. + let bytes = z.raw_ironwood_pczt_bytes().unwrap(); + let bytes = with_spend_alpha(&bytes, action_index, Some(alpha.to_repr())).unwrap(); + crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::set_ironwood_pczt( + &mut z.psbt, + bytes, + ); + z.replace_ironwood_spend_rk(action_index, rk).unwrap(); + pairs.push((action_index, alpha.to_repr())); + } + pairs +} + +/// The FROST stand-in signatures: RedPallas over the PSBT's ZIP-244 sig digest. +fn install_spend_signatures( + z: &mut ZcashBitGoPsbt, + pairs: &[(usize, [u8; 32])], + ask: &SpendAuthorizingKey, +) { + let sighash = z.v6_sig_digest().unwrap(); + for (action_index, alpha_repr) in pairs { + let alpha = pasta_curves::pallas::Scalar::from_repr(*alpha_repr).unwrap(); + let sig = ask.randomize(&alpha).sign(OsRng, &sighash); + z.apply_ironwood_spend_signature(*action_index, <[u8; 64]>::from(&sig)) + .unwrap(); + } +} + +/// The full keyless deshield lifecycle (plan §2, no transparent inputs): build → FROST +/// randomizers → signatures → combine. Each step survives a serialize/deserialize round-trip, +/// and zebra independently decodes the result. +#[test] +fn keyless_deshield_lifecycle_round_trips() { + let seed = "ironwood_v6_keyless_deshield"; + let (mut z, map, _ak, fvk, ask) = keyless_deshield_psbt(seed); + + // The stored key is the wallet's spend-validating key (the verifier's `Rk` input). + assert_eq!( + crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::get_ironwood_spend_validating_key( + &z.psbt + ), + Some(fvk.to_bytes()[..32].try_into().unwrap()), + ); + + // The accounting view: one real spend, no shielded outputs, +100M value balance. + 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.value_balance, 100_000_000); + assert_eq!(info.fee, 1_000); + + // Round-trip before randomizing: the placeholder state survives the PSBT wire. + let bytes = z.serialize_v6(); + z = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap(); + + let pairs = install_randomizers(&mut z, &map, &ask); + let bytes = z.serialize_v6(); + z = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap(); + + install_spend_signatures(&mut z, &pairs, &ask); + let bytes = z.serialize_v6(); + let z = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap(); + + // No shielded outputs ⇒ no KMS out-ciphertext round for this shape. + let raw = z + .combine_ironwood_proof(vec![0u8; Proof::expected_proof_size(1)], OsRng) + .unwrap(); + let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap(); + let bundle = tx.ironwood_bundle.as_ref().unwrap(); + assert_eq!(bundle.actions.len(), 1); + assert_eq!(bundle.value_balance, 100_000_000, "a deshield pays out"); + + use zebra_chain::serialization::ZcashDeserialize; + use zebra_chain::transaction::Transaction as ZebraTx; + let zebra = ZebraTx::zcash_deserialize(&raw[..]).expect("zebra decodes the deshield"); + assert_eq!(zebra.version(), 6); + assert_eq!(zebra.ironwood_actions().count(), 1); + assert!( + zebra.has_transparent_outputs(), + "the transparent payout is on the wire" + ); +} + +/// The shielded→shielded lifecycle: two spends into two outputs, with the KMS out-ciphertext +/// round — the patched ciphertexts are exactly what the FVK's `ovk` recovers, and they +/// survive to the broadcast transaction. +#[test] +fn keyless_shielded_transfer_lifecycle_round_trips() { + use orchard::note_encryption::IronwoodDomain; + use zcash_note_encryption::try_output_recovery_with_ovk; + + let seed = "ironwood_v6_keyless_transfer"; + let (mut z, map, _ak, fvk, ask) = keyless_transfer_psbt(seed); + + assert_eq!(z.ironwood_bundle_info().unwrap().value_balance, 0); + + // KMS round: every output's out_ciphertext becomes recoverable under the FVK's ovk. + kms_out_ciphertexts(&mut z, &map, &fvk); + let expected = z.ironwood_action_data().unwrap(); + + let pairs = install_randomizers(&mut z, &map, &ask); + install_spend_signatures(&mut z, &pairs, &ask); + + // Round-trip: the patched ciphertexts and signatures survive the PSBT wire. + let bytes = z.serialize_v6(); + let z = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap(); + + // Every action's outgoing note is recoverable under the spending FVK's external ovk. + let ovk = *fvk.to_ovk(Scope::External).as_ref(); + let pczt = z.ironwood_pczt().unwrap(); + for (i, action) in pczt.actions().iter().enumerate() { + let domain = IronwoodDomain::for_pczt_action(action); + let (note, _, _) = try_output_recovery_with_ovk( + &domain, + &orchard::keys::OutgoingViewingKey::from(ovk), + action, + action.cv_net(), + &expected.actions[i].out_ciphertext, + ) + .unwrap_or_else(|| panic!("action {i} recoverable under the FVK ovk")); + assert_eq!(note.value().inner(), 90_000_000); + } + + let raw = z + .combine_ironwood_proof(vec![0u8; Proof::expected_proof_size(2)], OsRng) + .unwrap(); + let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap(); + let bundle = tx.ironwood_bundle.as_ref().unwrap(); + assert_eq!(bundle.actions.len(), 2); + assert_eq!(bundle.value_balance, 0); + for (i, action) in bundle.actions.iter().enumerate() { + assert_eq!( + action.out_ciphertext.to_vec(), + expected.actions[i].out_ciphertext.to_vec(), + "action {i}'s KMS out_ciphertext survives to the broadcast transaction" + ); + } + + use zebra_chain::serialization::ZcashDeserialize; + use zebra_chain::transaction::Transaction as ZebraTx; + let zebra = ZebraTx::zcash_deserialize(&raw[..]).expect("zebra decodes the transfer"); + assert_eq!(zebra.ironwood_actions().count(), 2); +} + +/// A 1→2 shape: one real note (100M) into two shielded outputs (40M + 50M) plus a +/// 9_999_000 transparent payout — the surplus output rides a Builder-fabricated dummy spend. +fn keyless_surplus_psbt( + seed: &str, +) -> ( + ZcashBitGoPsbt, + IronwoodActionMap, + [u8; 32], + FullViewingKey, + SpendAuthorizingKey, +) { + let (specs, anchor, fvk, ak) = keyless_fixture(); + 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"); + }; + z.add_transparent_output( + miniscript::bitcoin::ScriptBuf::from(vec![0x76u8, 0xa9, 0x14, 0x88, 0xac]), + 9_999_000, + None, + ) + .unwrap(); + let outputs = [ + wallet_output(&fvk, 100, 40_000_000), + wallet_output(&fvk, 101, 50_000_000), + ]; + let map = z + .add_shielded_spends(&specs[..1], &outputs, &anchor, &ak, OsRng) + .unwrap(); + ( + z, + map, + ak, + fvk, + SpendAuthorizingKey::from(&test_spending_key()), + ) +} + +/// A 1→2 shape: the surplus output rides a Builder-fabricated dummy spend, which the IO +/// Finalizer signs at combine time — the keyless Constructor's reuse of orchard's dummy +/// fabrication works end to end. +#[test] +fn keyless_surplus_output_lifecycle_round_trips() { + let seed = "ironwood_v6_keyless_surplus"; + let (mut z, map, _ak, fvk, ask) = keyless_surplus_psbt(seed); + assert_eq!(z.ironwood_bundle_info().unwrap().value_balance, 10_000_000); + + kms_out_ciphertexts(&mut z, &map, &fvk); + let pairs = install_randomizers(&mut z, &map, &ask); + install_spend_signatures(&mut z, &pairs, &ask); + + let raw = z + .combine_ironwood_proof(vec![0u8; Proof::expected_proof_size(2)], OsRng) + .unwrap(); + let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap(); + assert_eq!(tx.ironwood_bundle.as_ref().unwrap().actions.len(), 2); + + use zebra_chain::serialization::ZcashDeserialize; + use zebra_chain::transaction::Transaction as ZebraTx; + ZebraTx::zcash_deserialize(&raw[..]).expect("zebra decodes the surplus-output deshield"); +} + +/// The Zcash parse surfaces the keyless bundle's spends (verified, with the stored `ak` +/// backing the `Rk` check) and reports the signing state the workflow is in. +#[test] +fn keyless_parse_reports_verified_spends() { + let seed = "ironwood_v6_keyless_parse"; + let (mut z, map, _ak, _fvk, ask) = keyless_deshield_psbt(seed); + install_randomizers(&mut z, &map, &ask); + + let wallet_keys = root_wallet_keys(seed); + let psbt = BitGoPsbt::Zcash(z.clone(), Network::ZcashTestnet); + let parsed = + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_transaction_with_wallet_keys( + &psbt, + &wallet_keys, + &crate::fixed_script_wallet::ReplayProtection::new(vec![]), + &[], + ) + .unwrap(); + let crate::fixed_script_wallet::bitgo_psbt::zcash::parse::ZcashParsedInputs::Shielded(spends) = + &parsed.inputs + else { + panic!("expected the shielded input kind"); + }; + assert_eq!(spends.len(), 1); + assert_eq!(spends[0].action_index, map.spend_action_index(0).unwrap()); + assert_eq!(spends[0].value, 100_000_000); + assert!(spends[0].rk_replaced, "the randomizer was installed"); + assert!(!spends[0].signed, "no signature yet"); +} + +/// Rule 0 (plan §3), shielded side: `add_shielded_spends` rejects a PSBT that already carries +/// transparent inputs, with the typed error code. +#[test] +fn add_shielded_spends_rejects_transparent_inputs() { + let seed = "ironwood_v6_keyless_rule0_shielded"; + let (specs, anchor, _fvk, ak) = keyless_fixture(); + 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([0x44u8; 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"); + }; + let error = z + .add_shielded_spends(&specs[..1], &[], &anchor, &ak, OsRng) + .unwrap_err(); + assert_eq!( + crate::error::WasmErrorCode::code(&error), + "IronwoodPsbtError.MixedInputKinds" + ); +} + +/// Rule 0, transparent side: the generic input adders reject a PSBT whose stored bundle +/// already spends real notes. A PSBT with only a shielded *output* (the shielding flow) +/// still accepts transparent inputs — see the shielding tests in [`super::ironwood`]. +#[test] +fn transparent_adders_reject_after_shielded_spends() { + let seed = "ironwood_v6_keyless_rule0_transparent"; + let wallet_keys = root_wallet_keys(seed); + let (z, _map, _ak, _fvk, _ask) = keyless_deshield_psbt(seed); + let mut psbt = BitGoPsbt::Zcash(z, Network::ZcashTestnet); + let error = psbt + .add_wallet_input( + Txid::from_byte_array([0x46u8; 32]), + 0, + 200_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap_err(); + assert!( + error.contains("cannot add a transparent input"), + "unexpected error: {error}" + ); +} + +/// Rule 0 at deserialize: a PSBT mixing transparent inputs with real spends (built through +/// the oracle path, which predates the rule) is rejected by `deserialize_v6`. +#[test] +fn deserialize_rejects_mixed_input_kinds() { + let seed = "ironwood_v6_keyless_rule0_deserialize"; + let (specs, anchor, fvk, _ak) = keyless_fixture(); + 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([0x45u8; 32]), + 0, + 300_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + let BitGoPsbt::Zcash(mut z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + // The oracle path (test/orchard-proving only) still builds mixed bundles, so the + // deserialize-side rule has something to catch. + let req = IronwoodSpendRequest { + fvk: fvk.to_bytes(), + recipient: specs[0].recipient, + value: specs[0].value, + rho: specs[0].rho, + rseed: specs[0].rseed, + cmx: specs[0].cmx, + witness: specs[0].witness, + }; + z.add_ironwood_actions(&[req], &[], &anchor, OsRng).unwrap(); + + let bytes = z.serialize_v6(); + let error = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap_err(); + assert!( + error + .to_string() + .contains("mixes transparent inputs with shielded spends"), + "unexpected error: {error}" + ); +} + +/// Only one shielded-spend call per PSBT: a second call is a typed error, not a silent +/// overwrite of the first bundle. +#[test] +fn second_add_shielded_spends_is_rejected() { + let seed = "ironwood_v6_keyless_twice"; + let (specs, anchor, _fvk, ak) = keyless_fixture(); + 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"); + }; + z.add_shielded_spends(&specs[..1], &[], &anchor, &ak, OsRng) + .unwrap(); + let error = z + .add_shielded_spends(&specs[..1], &[], &anchor, &ak, OsRng) + .unwrap_err(); + assert_eq!( + crate::error::WasmErrorCode::code(&error), + "IronwoodPsbtError.AlreadyAdded" + ); +} + +/// An empty v6 PSBT for the PSBT-level negative tests. +fn empty_v6_psbt(seed: &str) -> ZcashBitGoPsbt { + 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(z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + z +} + +/// Each output's Unified Address is stored under the action index the output landed at after +/// the shuffle, and survives a serialize/deserialize round-trip. +#[test] +fn add_shielded_spends_stores_unified_addresses_by_action_index() { + let seed = "ironwood_v6_keyless_ua"; + let (specs, anchor, fvk, ak) = keyless_fixture(); + let mut z = empty_v6_psbt(seed); + let mut outputs = [ + wallet_output(&fvk, 100, 40_000_000), + wallet_output(&fvk, 101, 50_000_000), + ]; + let uas: Vec = outputs + .iter() + .map(|o| crate::zcash::unified_address::encode_orchard_receiver(&o.recipient, "tzec")) + .collect::>() + .unwrap(); + for (output, ua) in outputs.iter_mut().zip(&uas) { + output.unified_address = Some(ua.clone()); + } + let map = z + .add_shielded_spends(&specs, &outputs, &anchor, &ak, OsRng) + .unwrap(); + + let z = ZcashBitGoPsbt::deserialize_v6(&z.serialize_v6(), Network::ZcashTestnet).unwrap(); + let pczt = z.ironwood_pczt().unwrap(); + for (j, ua) in uas.iter().enumerate() { + let action_index = map.output_action_index(j).unwrap(); + assert_eq!( + crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::get_ironwood_unified_address( + &z.psbt, + action_index + ), + Some(ua.clone()), + "output {j}'s UA is keyed by its post-shuffle action index" + ); + // …and that action really carries output j's recipient. + assert_eq!( + pczt.actions()[action_index] + .output() + .recipient() + .map(|a| a.to_raw_address_bytes()), + Some(outputs[j].recipient) + ); + } +} + +/// A rejected request leaves the PSBT untouched: a mismatched UA is a typed +/// `BadUnifiedAddress`, and nothing (PCZT, `ak`, UAs) is written. +#[test] +fn add_shielded_spends_rejects_mismatched_unified_address_atomically() { + let seed = "ironwood_v6_keyless_ua_mismatch"; + let (specs, anchor, fvk, ak) = keyless_fixture(); + let mut z = empty_v6_psbt(seed); + let before = z.serialize_v6(); + let mut output = wallet_output(&fvk, 100, 40_000_000); + output.unified_address = Some( + crate::zcash::unified_address::encode_orchard_receiver( + &fvk.address_at(999u32, Scope::External) + .to_raw_address_bytes(), + "tzec", + ) + .unwrap(), + ); + let error = z + .add_shielded_spends(&specs[..1], &[output], &anchor, &ak, OsRng) + .unwrap_err(); + assert_eq!( + crate::error::WasmErrorCode::code(&error), + "IronwoodPsbtError.BadUnifiedAddress" + ); + assert_eq!( + z.serialize_v6(), + before, + "a rejected call must not touch the PSBT" + ); +} + +/// Constructor errors wrapped by `IronwoodPsbtError` surface the Constructor's own code +/// (`IronwoodBuildError.*`), and leave the PSBT untouched. +#[test] +fn add_shielded_spends_surfaces_constructor_error_codes() { + let seed = "ironwood_v6_keyless_codes"; + let (specs, anchor, fvk, ak) = keyless_fixture(); + let mut z = empty_v6_psbt(seed); + let before = z.serialize_v6(); + + let mut with_ovk = wallet_output(&fvk, 100, 40_000_000); + with_ovk.ovk = Some([3u8; 32]); + let error = z + .add_shielded_spends(&specs[..1], &[with_ovk], &anchor, &ak, OsRng) + .unwrap_err(); + assert_eq!( + crate::error::WasmErrorCode::code(&error), + "IronwoodBuildError.OvkNotAllowed" + ); + + let error = z + .add_shielded_spends(&[], &[], &anchor, &ak, OsRng) + .unwrap_err(); + assert_eq!( + crate::error::WasmErrorCode::code(&error), + "IronwoodBuildError.NoSpends" + ); + assert_eq!( + z.serialize_v6(), + before, + "a rejected call must not touch the PSBT" + ); +} + +/// The full local lifecycle under `orchard-proving`, for each PSBT shape (1→0 deshield, +/// 1→2 with a Builder dummy spend, 2→2 transfer): the keyless-constructed bundle proves (with +/// the FVK installed transiently by the local prover), the proof verifies against orchard's +/// own verifying key, and zebra decodes the transaction. +#[cfg(feature = "orchard-proving")] +#[test] +fn keyless_spends_prove_and_verify_locally() { + type Build = fn( + &str, + ) -> ( + ZcashBitGoPsbt, + IronwoodActionMap, + [u8; 32], + FullViewingKey, + SpendAuthorizingKey, + ); + let shapes: [(&str, Build, usize); 3] = [ + ( + "ironwood_v6_keyless_local_proof_deshield", + keyless_deshield_psbt, + 1, + ), + ( + "ironwood_v6_keyless_local_proof_surplus", + keyless_surplus_psbt, + 2, + ), + ( + "ironwood_v6_keyless_local_proof_transfer", + keyless_transfer_psbt, + 2, + ), + ]; + for (seed, build, n_actions) in shapes { + let (mut z, map, _ak, fvk, ask) = build(seed); + kms_out_ciphertexts(&mut z, &map, &fvk); + let pairs = install_randomizers(&mut z, &map, &ask); + install_spend_signatures(&mut z, &pairs, &ask); + prove_and_verify_locally(z, &pairs, &fvk, n_actions); + } +} + +#[cfg(feature = "orchard-proving")] +fn prove_and_verify_locally( + z: ZcashBitGoPsbt, + pairs: &[(usize, [u8; 32])], + fvk: &FullViewingKey, + n_actions: usize, +) { + use nonempty::NonEmpty; + use orchard::bundle::{Authorized, Bundle as OrchardBundle, BundleVersion}; + use orchard::circuit::{OrchardCircuitVersion, VerifyingKey}; + use orchard::note::{ExtractedNoteCommitment, Nullifier, TransmittedNoteCiphertext}; + use orchard::primitives::redpallas::{Binding, Signature}; + use orchard::value::ValueCommitment; + use orchard::Action as OrchardAction; + + let raw = z + .combine_ironwood_proof_locally_with_alphas(pairs, Some(&fvk.to_bytes()), OsRng) + .unwrap(); + let tx = crate::zcash::v6::decode_v6_transaction(&raw).unwrap(); + let bundle = tx.ironwood_bundle.as_ref().unwrap(); + assert_eq!(bundle.actions.len(), n_actions); + assert_eq!(bundle.proof.len(), Proof::expected_proof_size(n_actions)); + + use zebra_chain::serialization::ZcashDeserialize; + use zebra_chain::transaction::Transaction as ZebraTx; + let zebra = ZebraTx::zcash_deserialize(&raw[..]).expect("zebra decodes the tx"); + assert_eq!(zebra.ironwood_actions().count(), n_actions); + // Reconstruct the fully-authorized orchard bundle from the on-wire action data and ask + // orchard itself to verify the proof against a freshly-built verifying key. + let actions = bundle + .actions + .iter() + .zip(&bundle.spend_auth_sigs) + .map(|(a, sig)| { + let cv_net = Option::from(ValueCommitment::from_bytes(&a.cv)).expect("valid cv_net"); + let rk = VerificationKey::::try_from(a.rk).expect("valid rk"); + let cmx = Option::from(ExtractedNoteCommitment::from_bytes(&a.cmx)).expect("valid cmx"); + let nf = Option::from(Nullifier::from_bytes(&a.nullifier)).expect("valid nf"); + let encrypted_note = TransmittedNoteCiphertext { + epk_bytes: a.ephemeral_key, + enc_ciphertext: a.enc_ciphertext, + out_ciphertext: a.out_ciphertext, + }; + OrchardAction::from_parts( + nf, + rk, + cmx, + encrypted_note, + cv_net, + Signature::::from(*sig), + ) + .expect("valid action") + }) + .collect::>(); + let actions = NonEmpty::from_vec(actions).expect("at least one action"); + let authorization = Authorized::from_parts( + Proof::new(bundle.proof.clone()), + Signature::::from(bundle.binding_sig), + ); + let orchard_bundle = OrchardBundle::::try_from_parts( + actions, + BundleVersion::ironwood_v3().default_flags(), + bundle.value_balance, + Option::from(orchard::tree::Anchor::from_bytes(bundle.anchor)).expect("valid anchor"), + authorization, + BundleVersion::ironwood_v3(), + ) + .expect("valid authorized bundle"); + let vk = VerifyingKey::build(OrchardCircuitVersion::PostNu6_3); + orchard_bundle + .verify_proof(&vk) + .expect("the keyless bundle's locally-produced proof verifies"); +} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/mod.rs index 969087d5491..2af81191400 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/mod.rs @@ -3,6 +3,7 @@ //! - [`v4`]: the v4/Sapling PSBT surface. //! - [`v6`]: transparent v6 (Ironwood) behavior (sighash, signatures, wire format). //! - [`ironwood`]: shielded (PCZT) behavior and ZIP-317 accounting. Native-only. +//! - [`ironwood_spend`]: the keyless spend flow and the one-input-kind rule. Native-only. use super::*; @@ -15,4 +16,6 @@ use common::*; #[cfg(not(target_arch = "wasm32"))] mod ironwood; #[cfg(not(target_arch = "wasm32"))] +mod ironwood_spend; +#[cfg(not(target_arch = "wasm32"))] mod v6; diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs index be2ab459018..cc2212cdfd2 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs @@ -807,6 +807,27 @@ impl ZcashBitGoPsbt { "v6 PSBT is missing its Ironwood PCZT after extraction".to_string(), )); } + // Rule 0 (plan §3): a v6 PSBT carries either transparent inputs or shielded spends, + // never both. The shielded side of the rule lives in + // `add_shielded_spends` (transparent-input check) and the generic input adders' + // Zcash arms; this closes the deserialize hole a PSBT could be edited through. + if !psbt.inputs.is_empty() { + if let Some(pczt_bytes) = super::propkv::get_ironwood_pczt(&psbt) { + let pczt = + crate::zcash::ironwood_pczt::deserialize_pczt(&pczt_bytes).map_err(|e| { + super::DeserializeError::Network(format!( + "v6 PSBT's Ironwood PCZT is invalid: {e}" + )) + })?; + if Self::bundle_has_real_spends(&pczt) { + return Err(super::DeserializeError::Network( + "v6 PSBT mixes transparent inputs with shielded spends (one input \ + kind per transaction)" + .to_string(), + )); + } + } + } Ok(ZcashBitGoPsbt { psbt, network, diff --git a/packages/wasm-utxo/src/zcash/ironwood_build.rs b/packages/wasm-utxo/src/zcash/ironwood_build.rs index cbc65629498..0f064e8254e 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_build.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_build.rs @@ -33,8 +33,12 @@ use rand::{CryptoRng, RngCore}; use orchard::builder::{Builder, BundleType}; use orchard::bundle::BundleVersion; -use orchard::keys::{FullViewingKey, OutgoingViewingKey, Scope}; -use orchard::note::{ExtractedNoteCommitment, Note, NoteVersion, RandomSeed, Rho}; +#[cfg(any(test, feature = "orchard-proving"))] +use orchard::keys::Scope; +use orchard::keys::{FullViewingKey, OutgoingViewingKey}; +use orchard::note::ExtractedNoteCommitment; +#[cfg(any(test, feature = "orchard-proving"))] +use orchard::note::{Note, NoteVersion, RandomSeed, Rho}; use orchard::pczt::Bundle as PcztBundle; use orchard::tree::{Anchor, MerkleHashOrchard, MerklePath}; use orchard::value::NoteValue; @@ -239,6 +243,34 @@ pub enum IronwoodBuildError { /// the Constructor's placeholder (the spend's `ak`). Marking that `rk` as replaced would let /// a linkable `rk` past the [`PlaceholderRkNotReplaced`](Self::PlaceholderRkNotReplaced) gate. UnrandomizedRk, + /// The keyless spend constructor was called without any real spend (plan §4 invariant 6): + /// an output-only bundle is a *shielding* build — use [`construct_shield_pczt_multi`] + /// for it. + NoSpends, + /// An output passed to the keyless spend constructor carried an explicit `ovk` (plan §4 + /// invariant 7): in the spend flow KMS owns encryption, so the PSBT side may not choose + /// one. The placeholder `out_ciphertext` the constructor installs is replaced by KMS's + /// `out_ciphertext` later. + OvkNotAllowed { + /// The offending output's position in the `outputs` slice. + output_index: usize, + }, + /// The spend-validating key bytes are not a canonical, non-identity RedPallas + /// verification key (plan §4 invariant 5): the placeholder `rk` must be a key the + /// signer's `ak.randomize(alpha)` can replace. + BadSpendValidatingKey, + /// A keyless spend's caller-supplied nullifier is not a canonical Pallas base-field + /// encoding. The nullifier is installed verbatim and keys the paired output note's `rho`. + BadNullifier { + /// The offending spend's position in the `spends` slice. + spend_index: usize, + }, + /// A note or output value, or the spend/output total, exceeds Zcash `MAX_MONEY` + /// (21 000 000 ZEC): no such value can appear in a valid transaction. + ValueOutOfRange, + /// The keyless spend constructor's own output failed the key-free verifier (plan §4 step + /// 4) — an internal inconsistency; the wrapped error names the failing check. + SelfCheck(IronwoodVerifyError), } /// Which Verifier check failed — its name is the error code callers key their diagnostics on @@ -456,6 +488,34 @@ impl core::fmt::Display for IronwoodBuildError { "ironwood-build: the spend randomizer must randomize rk (alpha is zero or rk is \ the placeholder ak)" ), + Self::NoSpends => write!( + f, + "ironwood-build: a spend bundle needs at least one real spend; outputs-only \ + bundles are shielding builds" + ), + Self::OvkNotAllowed { output_index } => write!( + f, + "ironwood-build: output {output_index} carries an explicit ovk; the keyless \ + spend constructor leaves encryption to the KMS" + ), + Self::BadSpendValidatingKey => write!( + f, + "ironwood-build: the spend-validating key is not a canonical RedPallas \ + verification key" + ), + Self::BadNullifier { spend_index } => write!( + f, + "ironwood-build: spend {spend_index}'s nullifier is not a canonical field \ + element" + ), + Self::ValueOutOfRange => write!( + f, + "ironwood-build: a value or the spend/output total exceeds MAX_MONEY" + ), + Self::SelfCheck(e) => write!( + f, + "ironwood-build: the constructed bundle failed its key-free self-check: {e}" + ), } } } @@ -484,6 +544,11 @@ pub struct IronwoodOutputSpec { /// 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 signer replaces it. +/// +/// The keyed (FVK-carrying) spend spec — the **test oracle** for the keyless constructor +/// (`ironwood_construct::construct_spend_pczt_keyless`). Production builds never see the FVK: +/// the build service is keyless, and the shipped WASM must not carry this path. +#[cfg(any(test, feature = "orchard-proving"))] #[derive(Clone, Copy, Debug)] pub struct IronwoodSpendSpec { /// Raw full viewing key, 96 bytes: `ak ‖ nk ‖ rivk` (orchard's `FullViewingKey::to_bytes` @@ -536,6 +601,20 @@ impl IronwoodActionMap { } } +impl IronwoodActionMap { + /// Assemble a map from already-shuffled action indices — the keyless spend + /// constructor's shuffle (plan §4) produces them directly, without a builder. + pub(crate) fn from_parts( + spend_action_indices: Vec, + output_action_indices: Vec, + ) -> Self { + Self { + spend_action_indices, + output_action_indices, + } + } +} + /// 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. @@ -562,7 +641,12 @@ impl IronwoodActionMap { /// 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`]. +/// known, then hand the PCZT to the prover and [`combine`]. +/// +/// This is the oracle path: keyed construction through the orchard `Builder`, available only +/// under `test` / `orchard-proving` (plan PR 5). The production deshield/shielded→shielded +/// Constructor is [`crate::zcash::ironwood_construct::construct_spend_pczt_keyless`]. +#[cfg(any(test, feature = "orchard-proving"))] pub fn construct_spend_pczt_multi( spends: &[IronwoodSpendSpec], outputs: &[IronwoodOutputSpec], @@ -718,17 +802,70 @@ fn validate_constructed_out_ciphertexts( } /// 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. +/// PCZT, for one or more outputs. Each output rides an orchard-`Builder`-fabricated dummy spend +/// (`BundleType::UNPADDED`, so exactly one action per output); no key is needed. +/// +/// Also the dummy-spend fabricator the keyless spend constructor +/// ([`crate::zcash::ironwood_construct::construct_spend_pczt_keyless`]) reuses for surplus +/// outputs: it moves the fabricated actions into its own bundle through the wire form. pub fn construct_shield_pczt_multi( outputs: &[IronwoodOutputSpec], anchor: &AnchorBytes, - rng: R, + mut rng: R, ) -> Result<(PcztBundle, IronwoodActionMap), IronwoodBuildError> { - construct_spend_pczt_multi(&[], outputs, anchor, rng) + if outputs.is_empty() { + return Err(IronwoodBuildError::EmptyBundle); + } + let anchor_root = + Option::from(Anchor::from_bytes(*anchor)).ok_or(IronwoodBuildError::BadAnchor)?; + let bundle_version = BundleVersion::ironwood_v3(); + let mut builder = Builder::new( + BundleType::UNPADDED, + bundle_version, + bundle_version.default_flags(), + anchor_root, + ) + .map_err(|e| IronwoodBuildError::Builder(e.to_string()))?; + for (output_index, spec) in outputs.iter().enumerate() { + if spec.amount == 0 { + return Err(IronwoodBuildError::ZeroValueOutput { output_index }); + } + let recipient = Option::from(Address::from_raw_address_bytes(&spec.recipient)) + .ok_or(IronwoodBuildError::BadRecipient)?; + builder + .add_output( + spec.ovk.map(OutgoingViewingKey::from), + recipient, + NoteValue::from_raw(spec.amount), + spec.memo, + ) + .map_err(|e| IronwoodBuildError::Output(e.to_string()))?; + } + let (bundle, meta) = builder + .build_for_pczt(&mut rng) + .map_err(|e| IronwoodBuildError::Builder(e.to_string()))?; + let action_map = IronwoodActionMap::from_parts( + Vec::new(), + (0..outputs.len()) + .map(|i| { + meta.output_action_index(i) + .ok_or(IronwoodBuildError::ActionIndexOutOfRange) + }) + .collect::, _>>()?, + ); + verify_spend_pczt(&bundle)?; + validate_constructed_out_ciphertexts( + &bundle, + outputs, + action_map.output_action_indices(), + None, + &mut rng, + )?; + Ok((bundle, action_map)) } /// The parsed-and-validated values one real spend contributes to the builder. +#[cfg(any(test, feature = "orchard-proving"))] struct ParsedSpend { fvk: FullViewingKey, note: Note, @@ -745,6 +882,7 @@ struct ParsedSpend { /// ([`IronwoodBuildError::InvalidNote`]) and must commit to exactly `spec.cmx` /// ([`IronwoodBuildError::NoteCommitmentMismatch`]); /// - the witness must recompute to the bundle's anchor (the `build_ironwood_witness` errors). +#[cfg(any(test, feature = "orchard-proving"))] fn parse_spend_spec( spec: &IronwoodSpendSpec, anchor: &AnchorBytes, @@ -790,6 +928,7 @@ fn parse_spend_spec( /// (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. +#[cfg(any(test, feature = "orchard-proving"))] fn scrub_spend_randomizers( bundle: &PcztBundle, spend_indices: &[usize], diff --git a/packages/wasm-utxo/src/zcash/ironwood_construct.rs b/packages/wasm-utxo/src/zcash/ironwood_construct.rs new file mode 100644 index 00000000000..91e02a2e420 --- /dev/null +++ b/packages/wasm-utxo/src/zcash/ironwood_construct.rs @@ -0,0 +1,1126 @@ +//! The **keyless** Ironwood spend Constructor (plan §4): build a deshield or +//! shielded→shielded bundle from real spends and requested outputs without any viewing key. +//! +//! The keyed constructor ([`construct_spend_pczt_multi`]) needs each spend's FVK — the orchard +//! [`Builder`](orchard::builder::Builder) derives the nullifier and checks note ownership with +//! it — but the build service holds no keys. This module assembles the same actions field by +//! field instead: +//! +//! - each real spend carries its **note plaintext** (`recipient`/`value`/`rho`/`rseed`/`cmx`), a +//! **witness** and a caller-supplied **nullifier** (from the note store, or from KMS); +//! - each real output carries its recipient, value and memo; its note (and therefore +//! `cmx`/`enc_ciphertext`) is fabricated here, keyed to the paired spend's nullifier; +//! - every action's `rcv`/`cv_net` pair is sampled here, and the bundle's committed +//! `value_sum` is `Σ(v_spend − v_out)`; +//! - `rk` is the wallet's spend-validating key `ak` (the Constructor's placeholder), with no +//! `alpha` — the FROST signer replaces both ([`set_spend_auth_randomizer`]); +//! - no FVK ever enters the constructed bundle: real spends carry `fvk = None`, so the wire +//! form stays keyless by construction; +//! - each output's `out_ciphertext` is a random **placeholder** (encrypted under `ovk = ⊥`): +//! KMS re-encrypts it under its FVK `ovk` and splices the real one in via +//! [`with_out_ciphertext`](super::ironwood_pczt::with_out_ciphertext) (plan §2 step 3). +//! +//! Surplus outputs (more outputs than spends) are fabricated as **dummy-spend** actions by an +//! outputs-only [`Builder`] — reusing orchard's own dummy-spend fabrication (its `dummy_sk`, +//! fabricating FVK, `alpha`/`rk` and dummy witness) — and moved into the combined bundle +//! through the wire form. Surplus spends (more spends than outputs) are paired with **dummy +//! outputs**: value-0 notes to a fresh random address, the shape the orchard builder itself +//! fabricates for an unpaired spend. +//! +//! After assembly the bundle is shuffled (action order leaks nothing about the request order) +//! and run through the key-free verifier ([`verify_bundle_without_keys`]) as a self-check: the +//! Constructor's own output must satisfy every check the client will later run on it. +//! +//! [`construct_spend_pczt_multi`]: super::ironwood_build::construct_spend_pczt_multi +//! [`set_spend_auth_randomizer`]: super::ironwood_build::set_spend_auth_randomizer +//! [`verify_bundle_without_keys`]: super::ironwood_verify::verify_bundle_without_keys + +use rand::seq::SliceRandom; +use rand::{CryptoRng, RngCore}; + +use orchard::bundle::BundleVersion; +use orchard::keys::{FullViewingKey, Scope, SpendingKey}; +use orchard::note::{ExtractedNoteCommitment, Note, NoteVersion, Nullifier, RandomSeed, Rho}; +use orchard::pczt::Bundle as PcztBundle; +use orchard::primitives::redpallas::{SpendAuth, VerificationKey}; +use orchard::tree::Anchor; +use orchard::value::{NoteValue, ValueCommitTrapdoor, ValueCommitment}; +use orchard::Address; + +use super::ironwood_build::{ + build_ironwood_witness, construct_shield_pczt_multi, AnchorBytes, IronwoodActionMap, + IronwoodBuildError, IronwoodOutputSpec, IronwoodWitness, MemoBytes, OrchardAddressBytes, + CMX_PROPRIETARY_KEY, MEMO_SIZE, +}; +use super::ironwood_pczt::{ActionWire, OutputWire, SpendWire, WitnessWire}; +use super::ironwood_verify::{verify_bundle_without_keys, KeylessVerifyContext}; + +/// Zcash `MAX_MONEY`: 21 000 000 ZEC in zatoshi. No note, output or bundle total can exceed it. +const MAX_MONEY: u64 = 21_000_000 * 100_000_000; + +/// One Ironwood note to spend, as passed to [`construct_spend_pczt_keyless`]. +/// +/// Everything here is the note's plaintext from the wallet's own note record (or a KMS +/// nullifier response): the reconstructed note must commit to `cmx`, and the witness must +/// recompute to the bundle's anchor. The **nullifier is not verified here** — that needs the +/// FVK, which the build service does not hold. A wrong nullifier only produces an invalid +/// transaction: the proof fails, and the KMS worker (which re-derives every nullifier) rejects +/// the bundle before proving. +#[derive(Clone, Copy, Debug)] +pub struct IronwoodKeylessSpendSpec { + /// The spent note's recipient: 43-byte raw Orchard/Ironwood address. + pub recipient: 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: IronwoodWitness, + /// The nullifier this spend reveals, from the note store or from KMS. Installed verbatim: + /// a wrong value fails the proof, never misdirects value. + pub nullifier: [u8; 32], +} + +/// Where a constructed action's request-side data came from, for the [`IronwoodActionMap`]. +/// +/// An action pairs one spend with one output, so a paired action carries both origins; a +/// surplus action carries only the side it was fabricated for (a surplus real spend gets a +/// dummy output, a dummy spend carries a surplus real output). +#[derive(Clone, Copy)] +struct ActionOrigin { + spend: Option, + output: Option, +} + +/// Constructor: build a keyless Ironwood spend bundle as an orchard PCZT from real spends +/// and/or requested outputs — the deshield and shielded→shielded Constructor for a build +/// service that holds no viewing keys (plan §4). +/// +/// The bundle has `max(spends.len(), outputs.len())` actions (the Ironwood V3 default flags +/// enable cross-address transfers, so a spend and an output share an action): where one side +/// runs out, the missing half is a protocol dummy — a value-0 output to a fresh random address +/// for surplus spends, an orchard-`Builder`-fabricated dummy spend for surplus outputs. At +/// least one real spend is required: output-only bundles are *shielding* builds +/// ([`construct_shield_pczt_multi`]). +/// +/// `ak` is the wallet's spend-validating key. It becomes every real spend's placeholder `rk` +/// (replaced by the signer's `ak.randomize(alpha)` later) and is the only key material the +/// constructed bundle carries — never an FVK. +/// +/// Every output must have `ovk: None` and a positive amount: KMS owns encryption in the spend +/// flow, and a zero-valued output is how a bundle marks fabricated dummies. +/// +/// The returned [`IronwoodActionMap`] gives, for each `spends[i]`/`outputs[i]`, the action +/// index it landed at in the bundle — the actions are shuffled, so request order does not +/// equal action order. +/// +/// [`construct_shield_pczt_multi`]: super::ironwood_build::construct_shield_pczt_multi +pub fn construct_spend_pczt_keyless( + spends: &[IronwoodKeylessSpendSpec], + outputs: &[IronwoodOutputSpec], + anchor: &AnchorBytes, + ak: &[u8; 32], + mut rng: R, +) -> Result<(PcztBundle, IronwoodActionMap), IronwoodBuildError> { + // Invariant 6: at least one real spend. Output-only bundles are shielding builds. + if spends.is_empty() { + return Err(IronwoodBuildError::NoSpends); + } + // Invariant 5: `ak` must be a canonical, non-identity spend-auth key — it becomes every + // real spend's placeholder `rk`, so a non-canonical one would only fail later, inside the + // wire parse, with a less obvious error. The identity encodes as all zeros; it + // `randomize`s to a key anyone can sign for, so it is rejected up front (as the key-free + // verifier's `Rk` check does). + if *ak == [0u8; 32] { + return Err(IronwoodBuildError::BadSpendValidatingKey); + } + VerificationKey::::try_from(*ak) + .map_err(|_| IronwoodBuildError::BadSpendValidatingKey)?; + Option::::from(Anchor::from_bytes(*anchor)).ok_or(IronwoodBuildError::BadAnchor)?; + + // Validate every caller input before anything is built, so a bad request fails + // atomically instead of halfway through construction. + let note_version = BundleVersion::ironwood_v3().note_version(); + for (spend_index, spec) in spends.iter().enumerate() { + validate_keyless_spend_note(spec, spend_index, anchor, note_version)?; + } + let mut recipients = Vec::with_capacity(outputs.len()); + for (output_index, spec) in outputs.iter().enumerate() { + // Invariant 7: KMS owns encryption in the spend flow — no caller-chosen `ovk`. + if spec.ovk.is_some() { + return Err(IronwoodBuildError::OvkNotAllowed { output_index }); + } + // A zero-valued output is how a bundle marks fabricated dummies (invariant 4). + if spec.amount == 0 { + return Err(IronwoodBuildError::ZeroValueOutput { output_index }); + } + if spec.amount > MAX_MONEY { + return Err(IronwoodBuildError::ValueOutOfRange); + } + recipients.push( + Option::from(Address::from_raw_address_bytes(&spec.recipient)) + .ok_or(IronwoodBuildError::BadRecipient)?, + ); + } + // Each value is ≤ MAX_MONEY, so these sums cannot overflow a u64; a total above + // MAX_MONEY can never appear in a valid transaction. + let spend_total: u64 = spends.iter().map(|s| s.value).sum(); + let output_total: u64 = outputs.iter().map(|o| o.amount).sum(); + if spend_total > MAX_MONEY || output_total > MAX_MONEY { + return Err(IronwoodBuildError::ValueOutOfRange); + } + + // Surplus outputs (more outputs than spends) are fabricated as dummy-spend actions by an + // outputs-only Builder — orchard's own dummy-spend fabrication (`dummy_sk`, fabricating + // FVK, alpha/rk, dummy witness), which cannot be reproduced from public APIs. Each + // surplus action carries the request index of the output it was fabricated for. + let surplus_start = spends.len().min(outputs.len()); + let surplus = &outputs[surplus_start..]; + let surplus_actions = builder_dummy_spend_actions(surplus, surplus_start, anchor, &mut rng)?; + + let mut actions = Vec::with_capacity(spends.len().max(outputs.len())); + for (i, spend_spec) in spends.iter().enumerate() { + // Per action: sample the value-commitment trapdoor, derive `cv_net` from the action's + // net value, then build both halves against it — the output's placeholder + // `out_ciphertext` commits `cv_net`, mirroring the orchard builder. + let output_value = if i < outputs.len() { + outputs[i].amount + } else { + 0 + }; + let rcv = sample_rcv(&mut rng); + let v_net = NoteValue::from_raw(spend_spec.value) - NoteValue::from_raw(output_value); + let cv_net = ValueCommitment::derive(v_net, rcv.clone()); + + // The output note keys to the paired spend's nullifier — the request-supplied one. + let paired_nullifier = spend_spec.nullifier; + let (output, has_real_output) = if i < outputs.len() { + ( + real_output_wire( + &outputs[i], + recipients[i], + paired_nullifier, + &cv_net, + note_version, + &mut rng, + )?, + true, + ) + } else { + // A dummy output on a surplus real spend: value 0, a fresh random address, a + // ZIP-302 no-memo — the shape the orchard builder fabricates for an unpaired + // spend. + let (recipient, memo) = dummy_output(&mut rng); + ( + dummy_output_wire( + recipient, + memo, + paired_nullifier, + &cv_net, + note_version, + &mut rng, + )?, + false, + ) + }; + actions.push(( + ActionWire { + cv_net: cv_net.to_bytes(), + rcv: Some(rcv.to_bytes()), + spend: real_spend_wire(spend_spec, ak), + output, + }, + ActionOrigin { + spend: Some(i), + output: has_real_output.then_some(i), + }, + )); + } + actions.extend(surplus_actions); + + // Shuffle so the action order leaks nothing about the request order, then record where + // each requested spend/output landed. + let (wires, action_map) = shuffle_actions(actions, spends.len(), outputs.len(), &mut rng); + + // The bundle's committed value balance: Σ(v_spend − v_out) over the real sides (dummies + // carry value 0, so they contribute nothing), as (magnitude, is_negative). Both totals + // are ≤ MAX_MONEY (checked above), well inside orchard's ±2^63−1 value-sum range. + let value_sum = if spend_total >= output_total { + (spend_total - output_total, false) + } else { + (output_total - spend_total, true) + }; + + let bundle = assemble_bundle(wires, value_sum, anchor)?; + + // Self-check (plan §4 step 4): the Constructor's own output must pass every key-free + // check the client will later run on it — note commitments, value commitments, the + // value sum, the witnesses, the placeholder-rk state, and no leaked viewing keys. + let report = verify_bundle_without_keys( + &bundle, + &KeylessVerifyContext { + stored_ak: Some(*ak), + ..Default::default() + }, + ) + .map_err(IronwoodBuildError::SelfCheck)?; + debug_assert_eq!( + report.spends.len(), + spends.len(), + "every requested spend is reported as a real spend" + ); + debug_assert_eq!( + report.outputs.len(), + outputs.len(), + "every requested output is reported as a real output" + ); + + Ok((bundle, action_map)) +} + +/// Validate one keyless spend's note data (plan §4 invariants 1–2): the reconstructed note +/// must be internally valid, must commit to exactly `cmx`, and its witness must recompute to +/// the bundle's anchor. The nullifier's *value* is deliberately NOT checked — that needs the +/// FVK — only that it is a canonical encoding. +fn validate_keyless_spend_note( + spec: &IronwoodKeylessSpendSpec, + spend_index: usize, + anchor: &AnchorBytes, + note_version: NoteVersion, +) -> Result<(), IronwoodBuildError> { + if spec.value > MAX_MONEY { + return Err(IronwoodBuildError::ValueOutOfRange); + } + // Only the encoding is checkable without the FVK; it must parse, because it keys the + // paired output note's `rho` and is installed verbatim on the wire. + Option::::from(Nullifier::from_bytes(&spec.nullifier)) + .ok_or(IronwoodBuildError::BadNullifier { spend_index })?; + let recipient = Option::from(Address::from_raw_address_bytes(&spec.recipient)) + .ok_or(IronwoodBuildError::BadRecipient)?; + let rho = Option::from(Rho::from_bytes(&spec.rho)).ok_or(IronwoodBuildError::InvalidNote)?; + let rseed = Option::from(RandomSeed::from_bytes(spec.rseed, &rho)) + .ok_or(IronwoodBuildError::InvalidNote)?; + let note: Note = Option::from(Note::from_parts( + recipient, + NoteValue::from_raw(spec.value), + rho, + rseed, + note_version, + )) + .ok_or(IronwoodBuildError::InvalidNote)?; + if ExtractedNoteCommitment::from(note.commitment()).to_bytes() != spec.cmx { + return Err(IronwoodBuildError::NoteCommitmentMismatch); + } + build_ironwood_witness( + &spec.cmx, + spec.witness.position, + &spec.witness.auth_path, + anchor, + )?; + Ok(()) +} + +/// The wire spend half of one real spend: note plaintext, the witness, the wallet `ak` as the +/// placeholder `rk`, no `alpha`, no FVK, and the note's `cmx` recorded for reconciliation. +fn real_spend_wire(spec: &IronwoodKeylessSpendSpec, ak: &[u8; 32]) -> SpendWire { + SpendWire { + nullifier: spec.nullifier, + rk: *ak, + spend_auth_sig: None, + recipient: Some(spec.recipient.to_vec()), + value: Some(spec.value), + rho: Some(spec.rho), + rseed: Some(spec.rseed), + // The FVK never enters the PCZT: the Constructor is keyless by design. + fvk: None, + witness: Some(WitnessWire { + position: spec.witness.position, + auth_path: spec.witness.auth_path.to_vec(), + }), + alpha: None, + dummy_sk: None, + proprietary: [(CMX_PROPRIETARY_KEY.to_string(), spec.cmx.to_vec())].into(), + } +} + +/// Rejection-sample an output note's `rseed` for `rho` (plan §4): a random 32-byte seed is +/// only usable when `RandomSeed::from_bytes` accepts it. +fn sample_rseed(rng: &mut R, rho: &Rho) -> RandomSeed { + loop { + let mut bytes = [0u8; 32]; + rng.fill_bytes(&mut bytes); + if let Some(rseed) = RandomSeed::from_bytes(bytes, rho).into_option() { + return rseed; + } + } +} + +/// Rejection-sample the action's value-commitment trapdoor (plan §4): a random 32-byte +/// scalar is only usable when it is a canonical field-element encoding. +fn sample_rcv(rng: &mut R) -> ValueCommitTrapdoor { + loop { + let mut bytes = [0u8; 32]; + rng.fill_bytes(&mut bytes); + if let Some(rcv) = ValueCommitTrapdoor::from_bytes(bytes).into_option() { + return rcv; + } + } +} + +/// Build the output note for `rho` (the paired spend's nullifier) with a fresh +/// rejection-sampled `rseed`, returning the note and its extracted commitment. +/// +/// `Note::from_parts` also rejects (with negligible probability) an `rseed` whose note +/// commitment is degenerate; that is resampled too, as orchard's own `Note::new` does, so a +/// valid request never fails here. +fn build_output_note( + recipient: Address, + value: NoteValue, + rho: Rho, + note_version: NoteVersion, + rng: &mut R, +) -> (Note, ExtractedNoteCommitment) { + loop { + let rseed = sample_rseed(rng, &rho); + if let Some(note) = + Note::from_parts(recipient, value, rho, rseed, note_version).into_option() + { + let cmx = ExtractedNoteCommitment::from(note.commitment()); + return (note, cmx); + } + } +} + +/// The wire output half of one real output, keyed to the paired spend's nullifier. +fn real_output_wire( + spec: &IronwoodOutputSpec, + recipient: Address, + paired_nullifier: [u8; 32], + cv_net: &ValueCommitment, + note_version: NoteVersion, + rng: &mut R, +) -> Result { + output_wire_from_note( + recipient, + NoteValue::from_raw(spec.amount), + &spec.memo, + paired_nullifier, + cv_net, + note_version, + rng, + ) +} + +/// Encrypt an output note: the real `enc_ciphertext` (recoverable by whoever holds the +/// recipient's `ivk`) and the ephemeral key it rode in on, plus a placeholder `out_ciphertext` +/// under `ovk = ⊥` — random bytes, replaced by KMS's real one later (plan §2 step 3). +fn encrypt_output( + note: Note, + memo: &MemoBytes, + cv_net: &ValueCommitment, + cmx: &ExtractedNoteCommitment, + rng: &mut R, +) -> ([u8; 32], [u8; 580], [u8; 80]) { + use orchard::note_encryption::IronwoodDomain; + use zcash_note_encryption::NoteEncryption; + + let enc = NoteEncryption::::new(None, note, *memo); + let enc_ciphertext = enc.encrypt_note_plaintext(); + let out_ciphertext = enc.encrypt_outgoing_plaintext(cv_net, cmx, rng); + ( + ::epk_bytes(enc.epk()).0, + enc_ciphertext, + out_ciphertext, + ) +} + +/// The wire output half of one dummy output: value 0, a fresh random address, a ZIP-302 +/// no-memo — the shape the orchard builder fabricates for an unpaired real spend. +fn dummy_output_wire( + recipient: OrchardAddressBytes, + memo: MemoBytes, + paired_nullifier: [u8; 32], + cv_net: &ValueCommitment, + note_version: NoteVersion, + rng: &mut R, +) -> Result { + let recipient = Option::from(Address::from_raw_address_bytes(&recipient)) + .ok_or(IronwoodBuildError::BadRecipient)?; + output_wire_from_note( + recipient, + NoteValue::ZERO, + &memo, + paired_nullifier, + cv_net, + note_version, + rng, + ) +} + +/// Shared body of [`real_output_wire`]/[`dummy_output_wire`]: build the note against `rho` +/// (the paired spend's nullifier), commit it, encrypt it, and return the wire output. +#[allow(clippy::too_many_arguments)] +fn output_wire_from_note( + recipient: Address, + value: NoteValue, + memo: &MemoBytes, + paired_nullifier: [u8; 32], + cv_net: &ValueCommitment, + note_version: NoteVersion, + rng: &mut R, +) -> Result { + let rho = + Option::from(Rho::from_bytes(&paired_nullifier)).ok_or(IronwoodBuildError::InvalidNote)?; + let (note, cmx) = build_output_note(recipient, value, rho, note_version, rng); + let (ephemeral_key, enc_ciphertext, out_ciphertext) = + encrypt_output(note, memo, cv_net, &cmx, rng); + Ok(OutputWire { + cmx: cmx.to_bytes(), + ephemeral_key, + enc_ciphertext: enc_ciphertext.to_vec(), + out_ciphertext: out_ciphertext.to_vec(), + recipient: Some(recipient.to_raw_address_bytes().to_vec()), + value: Some(value.inner()), + rseed: Some(*note.rseed().as_bytes()), + ock: None, + user_address: None, + proprietary: Default::default(), + }) +} + +/// A fresh random address (a random `SpendingKey`'s external address at diversifier 0) and the +/// ZIP-302 no-memo field — the dummy output's recipient and memo. +fn dummy_output(rng: &mut R) -> (OrchardAddressBytes, MemoBytes) { + let mut memo = [0u8; MEMO_SIZE]; + memo[0] = 0xf6; // ZIP-302 "no memo" + loop { + let mut bytes = [0u8; 32]; + rng.fill_bytes(&mut bytes); + if let Some(sk) = Option::::from(SpendingKey::from_bytes(bytes)) { + let fvk = FullViewingKey::from(&sk); + return ( + fvk.address_at(0u32, Scope::External).to_raw_address_bytes(), + memo, + ); + } + } +} + +/// Fabricate the dummy-spend actions for surplus outputs with an outputs-only orchard +/// [`Builder`](orchard::builder::Builder) — the existing shielding path, which needs no key — +/// and return their wire actions paired with the surplus output's request-relative index. +/// +/// This reuses orchard's own dummy-spend fabrication (a fabricated zero-value note with its +/// `dummy_sk`, a fabricating FVK, a builder-chosen `alpha`/`rk`, and a dummy Merkle witness): +/// the IO Finalizer signs those dummies from their stored `alpha`/`dummy_sk` at +/// [`finalize_shield_io`](super::ironwood_build::finalize_shield_io) time. +fn builder_dummy_spend_actions( + surplus_outputs: &[IronwoodOutputSpec], + // The request-relative index of `surplus_outputs[0]` — origins are request-relative so + // the shuffled map lines up with the caller's `outputs` slice. + output_offset: usize, + anchor: &AnchorBytes, + mut rng: R, +) -> Result, IronwoodBuildError> { + if surplus_outputs.is_empty() { + return Ok(Vec::new()); + } + let (surplus_bundle, surplus_map) = + construct_shield_pczt_multi(surplus_outputs, anchor, &mut rng)?; + let wires = super::ironwood_pczt::bundle_actions_to_wire(&surplus_bundle); + let output_indices = surplus_map.output_action_indices(); + Ok(wires + .into_iter() + .enumerate() + .map(|(action_index, wire)| { + // Which surplus output this action was fabricated for. + let surplus_output = output_indices + .iter() + .position(|&index| index == action_index) + .map(|k| output_offset + k); + ( + wire, + ActionOrigin { + spend: None, + output: surplus_output, + }, + ) + }) + .collect()) +} + +/// Shuffle the constructed actions so their order leaks nothing about the request order, and +/// record where each requested spend/output landed. +fn shuffle_actions( + actions: Vec<(ActionWire, ActionOrigin)>, + spend_count: usize, + output_count: usize, + rng: &mut R, +) -> (Vec, IronwoodActionMap) { + let mut shuffled = actions; + shuffled.shuffle(rng); + let mut spend_action_indices = vec![usize::MAX; spend_count]; + let mut output_action_indices = vec![usize::MAX; output_count]; + for (position, (_, origin)) in shuffled.iter().enumerate() { + if let Some(i) = origin.spend { + spend_action_indices[i] = position; + } + if let Some(j) = origin.output { + output_action_indices[j] = position; + } + } + ( + shuffled.into_iter().map(|(wire, _)| wire).collect(), + IronwoodActionMap::from_parts(spend_action_indices, output_action_indices), + ) +} + +/// Assemble the wire actions into an Ironwood V3 bundle with the given committed `value_sum` +/// — the same parse a serialized PCZT takes on the way back in, including the +/// duplicate-nullifier guard. +fn assemble_bundle( + actions: Vec, + value_sum: (u64, bool), + anchor: &AnchorBytes, +) -> Result { + let bundle_version = BundleVersion::ironwood_v3(); + let flags = bundle_version + .default_flags() + .to_byte(bundle_version) + .ok_or_else(|| { + IronwoodBuildError::Builder( + "the Ironwood V3 default flags have no byte encoding".to_string(), + ) + })?; + let wire = super::ironwood_pczt::BundleWire { + flags, + value_pool: 1, // Ironwood + protocol_version: 2, // V3 + value_sum_magnitude: value_sum.0, + value_sum_negative: value_sum.1, + anchor: *anchor, + zkproof: None, + bsk: None, + actions, + }; + super::ironwood_pczt::bundle_from_wire(wire).map_err(|e| match e { + // Invariant 3: surface the wire parse's duplicate-nullifier guard as the typed + // Constructor error (the index is the post-shuffle action index). + super::ironwood_pczt::IronwoodPcztError::DuplicateNullifier { action_index } => { + IronwoodBuildError::DuplicateNullifier { action_index } + } + other => IronwoodBuildError::PcztWire(other.to_string()), + }) +} + +/// Shared keyless-spend test fixture (also used by the PSBT-level spend tests). +#[cfg(test)] +pub(crate) mod test_util { + use super::*; + use crate::zcash::ironwood_build::test_field_bytes as field_bytes; + use crate::zcash::ironwood_build::test_util::{real_two_leaf_tree, RealLeaf}; + + /// Two real notes (100M and 80M zatoshi) of one wallet under a real two-leaf tree, as the + /// keyless Constructor receives them (nullifiers as the note store / KMS would report + /// them), plus the anchor, the wallet's FVK (the test oracle's key holder, standing in for + /// KMS) and its spend-validating key `ak`. + pub(crate) fn keyless_fixture() -> ( + Vec, + AnchorBytes, + FullViewingKey, + [u8; 32], + ) { + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + let fvk = FullViewingKey::from(&sk); + let (anchor, leaves) = real_two_leaf_tree( + &fvk, + [ + (100_000_000u64, field_bytes(200), [52u8; 32]), + (80_000_000u64, field_bytes(201), [53u8; 32]), + ], + ); + let ak: [u8; 32] = fvk.to_bytes()[..32].try_into().unwrap(); + let spec = |leaf: &RealLeaf| { + let address = + Option::
::from(Address::from_raw_address_bytes(&leaf.recipient)).unwrap(); + let rho = Option::::from(Rho::from_bytes(&leaf.rho)).unwrap(); + let rseed = + Option::::from(RandomSeed::from_bytes(leaf.rseed, &rho)).unwrap(); + let note = Option::::from(Note::from_parts( + address, + NoteValue::from_raw(leaf.value), + rho, + rseed, + BundleVersion::ironwood_v3().note_version(), + )) + .unwrap(); + IronwoodKeylessSpendSpec { + recipient: leaf.recipient, + value: leaf.value, + rho: leaf.rho, + rseed: leaf.rseed, + cmx: leaf.cmx, + witness: leaf.witness, + nullifier: note.nullifier(&fvk).to_bytes(), + } + }; + (vec![spec(&leaves[0]), spec(&leaves[1])], anchor, fvk, ak) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::zcash::ironwood_build::compute_out_ciphertext; + use crate::zcash::ironwood_build::test_field_bytes as field_bytes; + use crate::zcash::ironwood_pczt::{deserialize_pczt, serialize_pczt, with_out_ciphertext}; + use ff::{Field, PrimeField}; + use orchard::keys::SpendAuthorizingKey; + use rand::rngs::OsRng; + + use super::test_util::keyless_fixture as fixture; + + /// A requested output back to the wallet itself, at the given diversifier and amount. + fn wallet_output(fvk: &FullViewingKey, diversifier: u32, amount: u64) -> IronwoodOutputSpec { + IronwoodOutputSpec { + recipient: fvk + .address_at(diversifier, Scope::External) + .to_raw_address_bytes(), + amount, + ovk: None, + memo: [0u8; 512], + } + } + + /// Patch every requested output's `out_ciphertext` to the real encryption under `ovk` — + /// what KMS does in the production flow (plan §2 step 3). + fn patch_out_ciphertexts( + bundle: &PcztBundle, + output_indices: &[usize], + ovk: [u8; 32], + ) -> PcztBundle { + let mut bytes = serialize_pczt(bundle).unwrap(); + for &idx in output_indices { + let expected = compute_out_ciphertext(bundle, idx, ovk, &mut OsRng).unwrap(); + bytes = with_out_ciphertext(&bytes, idx, expected).unwrap(); + } + deserialize_pczt(&bytes).unwrap() + } + + /// The oracle round trip (plan PR 5's tests): a bundle built by the keyless Constructor + /// behaves exactly like one built by the keyed oracle — KMS's `out_ciphertext`s splice in, + /// the FROST randomizers install, the keyed verifier and the key-free verifier both pass, + /// and the FROST stand-in signatures verify. + fn oracle_round_trip(spends: usize, outputs: usize) -> (PcztBundle, IronwoodActionMap) { + let (specs, anchor, fvk, ak) = fixture(); + let specs = &specs[..spends]; + let outs: Vec = (0..outputs) + .map(|j| wallet_output(&fvk, 100 + j as u32, 90_000_000)) + .collect(); + let (bundle, map) = + construct_spend_pczt_keyless(specs, &outs, &anchor, &ak, OsRng).unwrap(); + + // Shape: UNPADDED means exactly max(S, O) actions. + assert_eq!(bundle.actions().len(), spends.max(outputs)); + + // KMS's out_ciphertext round: every requested output gets its real ciphertext. + let ovk = *fvk.to_ovk(Scope::External).as_ref(); + let bundle = patch_out_ciphertexts(&bundle, map.output_action_indices(), ovk); + + // FROST round: install a real randomizer per real spend. + let ask = SpendAuthorizingKey::from( + &Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(), + ); + let mut randomizers = Vec::new(); + let mut bundle = bundle; + for action_index in map.spend_action_indices() { + let alpha = pasta_curves::pallas::Scalar::random(&mut OsRng); + let rk = <[u8; 32]>::from(VerificationKey::::from(&ask.randomize(&alpha))); + crate::zcash::ironwood_build::set_spend_auth_randomizer( + &mut bundle, + *action_index, + &alpha.to_repr(), + &rk, + ) + .unwrap(); + randomizers.push((*action_index, alpha.to_repr(), rk)); + } + + // The keyed oracle verifier passes (the FVK holder's view)… + crate::zcash::ironwood_build::verify_ironwood_spends(&bundle, &fvk.to_bytes()).unwrap(); + + // …and the key-free verifier passes with the caller's own expectations. + let report = verify_bundle_without_keys( + &bundle, + &KeylessVerifyContext { + stored_ak: Some(ak), + expected_ak: Some(ak), + expected_randomizers: randomizers.clone(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(report.spends.len(), spends); + assert_eq!(report.outputs.len(), outputs); + + // Sign with the FROST stand-in key; the signatures verify against the installed rks. + let sighash = [9u8; 32]; + for ((action_index, alpha_repr, _), _) in randomizers.iter().zip(0..) { + let alpha = pasta_curves::pallas::Scalar::from_repr(*alpha_repr).unwrap(); + let sig = ask.randomize(&alpha).sign(OsRng, &sighash); + crate::zcash::ironwood_build::apply_spend_signature( + &mut bundle, + *action_index, + sighash, + &<[u8; 64]>::from(&sig), + ) + .unwrap(); + } + verify_bundle_without_keys( + &bundle, + &KeylessVerifyContext { + stored_ak: Some(ak), + sighash: Some(sighash), + ..Default::default() + }, + ) + .unwrap(); + (bundle, map) + } + + #[test] + fn oracle_round_trip_one_spend_no_outputs() { + oracle_round_trip(1, 0); + } + + #[test] + fn oracle_round_trip_one_to_one() { + oracle_round_trip(1, 1); + } + + #[test] + fn oracle_round_trip_two_spends_one_output() { + oracle_round_trip(2, 1); + } + + #[test] + fn oracle_round_trip_one_spend_two_outputs() { + oracle_round_trip(1, 2); + } + + #[test] + fn oracle_round_trip_two_to_two() { + oracle_round_trip(2, 2); + } + + /// Shape checks: the action map points at the right actions after the shuffle; surplus + /// real spends carry value-0 dummy outputs; `value_sum` is positive for a deshield. + #[test] + fn action_map_and_dummy_outputs_follow_the_shapes() { + let (specs, anchor, fvk, ak) = fixture(); + + // 2 spends → 1 output: the surplus spend carries a dummy output (value 0). + let (bundle, map) = construct_spend_pczt_keyless( + &specs, + &[wallet_output(&fvk, 100, 90_000_000)], + &anchor, + &ak, + OsRng, + ) + .unwrap(); + assert_eq!(bundle.actions().len(), 2); + let real_outputs: Vec<_> = map + .output_action_indices() + .iter() + .map(|&idx| { + let output = bundle.actions()[idx].output(); + ( + output.value().map(|v| v.inner()), + output.recipient().map(|a| a.to_raw_address_bytes()), + ) + }) + .collect(); + assert_eq!( + real_outputs, + vec![( + Some(90_000_000), + Some( + fvk.address_at(100u32, Scope::External) + .to_raw_address_bytes() + ) + )] + ); + // The other action's output is the dummy: value 0. + let dummy_index = (0..2) + .find(|&idx| !map.output_action_indices().contains(&idx)) + .unwrap(); + assert_eq!( + bundle.actions()[dummy_index] + .output() + .value() + .map(|v| v.inner()), + Some(0) + ); + // Two 100M/80M spends pay out 90M: valueBalance is +90M (positive: a deshield). + let report = verify_bundle_without_keys( + &bundle, + &KeylessVerifyContext { + stored_ak: Some(ak), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(report.value_balance, 90_000_000); + + // 1 spend → 2 outputs: the surplus output rides a dummy spend. + let (specs, anchor, fvk, ak) = fixture(); + let (bundle, map) = construct_spend_pczt_keyless( + &specs[..1], + &[ + wallet_output(&fvk, 100, 40_000_000), + wallet_output(&fvk, 101, 50_000_000), + ], + &anchor, + &ak, + OsRng, + ) + .unwrap(); + assert_eq!(bundle.actions().len(), 2); + for j in 0..2 { + let idx = map.output_action_index(j).unwrap(); + let action = &bundle.actions()[idx]; + // Output 0 pairs the real spend; surplus output 1 rides a dummy spend. + assert_eq!( + action.spend().dummy_sk().is_some(), + j == 1, + "output {j}: unexpected spend kind" + ); + assert_eq!( + action.output().value().map(|v| v.inner()), + Some(40_000_000 + 10_000_000 * j as u64) + ); + } + let report = verify_bundle_without_keys( + &bundle, + &KeylessVerifyContext { + stored_ak: Some(ak), + ..Default::default() + }, + ) + .unwrap(); + // 100M spent − 90M outputs = +10M (positive: a deshield pays out). + assert_eq!(report.value_balance, 10_000_000); + } + + /// Negative tests, one per constructor invariant (plan §4). + #[test] + fn rejects_bad_requests() { + let (specs, anchor, fvk, ak) = fixture(); + + // Invariant 6: at least one spend. + assert!(matches!( + construct_spend_pczt_keyless(&[], &[], &anchor, &ak, OsRng), + Err(IronwoodBuildError::NoSpends) + )); + + // Invariant 5: `ak` must be a canonical verification key (the identity fails). + assert!(matches!( + construct_spend_pczt_keyless(&specs, &[], &anchor, &[0u8; 32], OsRng), + Err(IronwoodBuildError::BadSpendValidatingKey) + )); + + // Invariant 7: no caller-chosen ovk. + let mut bad_ovk = wallet_output(&fvk, 100, 90_000_000); + bad_ovk.ovk = Some([3u8; 32]); + assert!(matches!( + construct_spend_pczt_keyless(&specs, &[bad_ovk], &anchor, &ak, OsRng), + Err(IronwoodBuildError::OvkNotAllowed { output_index: 0 }) + )); + + // Invariant 4: zero-valued outputs are the dummy marker. + assert!(matches!( + construct_spend_pczt_keyless( + &specs, + &[wallet_output(&fvk, 100, 0)], + &anchor, + &ak, + OsRng + ), + Err(IronwoodBuildError::ZeroValueOutput { output_index: 0 }) + )); + + // Invariant 1: the note must commit to its recorded cmx. + let mut bad_cmx = specs[0]; + bad_cmx.cmx = field_bytes(999); + assert!(matches!( + construct_spend_pczt_keyless(&[bad_cmx], &[], &anchor, &ak, OsRng), + Err(IronwoodBuildError::NoteCommitmentMismatch) + )); + + // Invariant 1: the note plaintext must be internally valid (non-canonical rho). + let mut bad_rho = specs[0]; + bad_rho.rho = [0xffu8; 32]; + assert!(matches!( + construct_spend_pczt_keyless(&[bad_rho], &[], &anchor, &ak, OsRng), + Err(IronwoodBuildError::InvalidNote) + )); + + // Invariant 2: the witness must recompute to the anchor. + assert!(matches!( + construct_spend_pczt_keyless( + &specs, + &[], + &crate::zcash::ironwood_build::test_util::real_two_leaf_tree( + &fvk, + [ + (1u64, field_bytes(1), [1u8; 32]), + (2u64, field_bytes(2), [2u8; 32]) + ] + ) + .0, + &ak, + OsRng + ), + Err(IronwoodBuildError::WitnessAnchorMismatch) + )); + + // Invariant 3: duplicate nullifiers are rejected, as the typed Constructor error. + let mut dup = specs[1]; + dup.nullifier = specs[0].nullifier; + assert!(matches!( + construct_spend_pczt_keyless(&[specs[0], dup], &[], &anchor, &ak, OsRng), + Err(IronwoodBuildError::DuplicateNullifier { .. }) + )); + + // Invariant 5: a non-canonical (non-point) `ak` encoding. + assert!(matches!( + construct_spend_pczt_keyless(&specs, &[], &anchor, &[0xffu8; 32], OsRng), + Err(IronwoodBuildError::BadSpendValidatingKey) + )); + + // A non-canonical anchor encoding. + assert!(matches!( + construct_spend_pczt_keyless(&specs, &[], &[0xffu8; 32], &ak, OsRng), + Err(IronwoodBuildError::BadAnchor) + )); + + // A non-canonical nullifier encoding names the offending spend. + let mut bad_nf = specs[1]; + bad_nf.nullifier = [0xffu8; 32]; + assert!(matches!( + construct_spend_pczt_keyless(&[specs[0], bad_nf], &[], &anchor, &ak, OsRng), + Err(IronwoodBuildError::BadNullifier { spend_index: 1 }) + )); + + // Values above MAX_MONEY: an output amount, and a total of in-range outputs. + assert!(matches!( + construct_spend_pczt_keyless( + &specs, + &[wallet_output(&fvk, 100, MAX_MONEY + 1)], + &anchor, + &ak, + OsRng + ), + Err(IronwoodBuildError::ValueOutOfRange) + )); + assert!(matches!( + construct_spend_pczt_keyless( + &specs, + &[ + wallet_output(&fvk, 100, MAX_MONEY), + wallet_output(&fvk, 101, 1) + ], + &anchor, + &ak, + OsRng + ), + Err(IronwoodBuildError::ValueOutOfRange) + )); + } + + /// No FVK reaches the constructed PCZT (plan PR 5 review focus): every real spend's `fvk` + /// is absent, and the wallet FVK's secret-ish half (`nk ‖ rivk`) appears nowhere in the + /// serialized bytes — only `ak`, the public placeholder `rk`, does. + #[test] + fn constructed_pczt_carries_no_wallet_fvk() { + let (specs, anchor, fvk, ak) = fixture(); + for (spends, outputs) in [(1usize, 0usize), (2, 1), (1, 2), (2, 2)] { + let outs: Vec = (0..outputs) + .map(|j| wallet_output(&fvk, 100 + j as u32, 30_000_000)) + .collect(); + let (bundle, map) = + construct_spend_pczt_keyless(&specs[..spends], &outs, &anchor, &ak, OsRng).unwrap(); + for &idx in map.spend_action_indices() { + let spend = bundle.actions()[idx].spend(); + assert!(spend.fvk().is_none(), "real spend {idx} carries an fvk"); + assert!(spend.zip32_derivation().is_none()); + assert!( + spend.alpha().is_none(), + "no alpha before the signer installs one" + ); + assert_eq!(<[u8; 32]>::from(spend.rk()), ak, "placeholder rk is ak"); + } + let bytes = serialize_pczt(&bundle).unwrap(); + let fvk_bytes = fvk.to_bytes(); + let nk_rivk = &fvk_bytes[32..]; + assert!( + !bytes.windows(nk_rivk.len()).any(|w| w == nk_rivk), + "{spends}→{outputs}: the wallet FVK's nk‖rivk leaked into the PCZT" + ); + for half in [&fvk_bytes[32..64], &fvk_bytes[64..96]] { + assert!( + !bytes.windows(32).any(|w| w == half), + "{spends}→{outputs}: a wallet FVK component leaked into the PCZT" + ); + } + } + } + + /// Randomness: two builds of the same request produce different action orders, `rcv`s + /// and `rseed`s — linkability hinges on the Constructor randomizing everything it can. + #[test] + fn two_builds_randomize_order_rcvs_and_rseeds() { + let (specs, anchor, fvk, ak) = fixture(); + let outs = [ + wallet_output(&fvk, 100, 30_000_000), + wallet_output(&fvk, 101, 30_000_000), + ]; + let build = || { + let (bundle, map) = + construct_spend_pczt_keyless(&specs, &outs, &anchor, &ak, OsRng).unwrap(); + (serialize_pczt(&bundle).unwrap(), map) + }; + // The action ORDER differs: the maps disagree on where spend 0 landed. 2! orders are + // possible (a collision has probability 1/2 per pair), so retry a few times. + let mut order_differs = false; + let mut first: Option<(Vec, IronwoodActionMap)> = None; + for _ in 0..8 { + let (bytes, map) = build(); + if let Some((_, ref map_a)) = first { + if (0..2).any(|i| map_a.spend_action_index(i) != map.spend_action_index(i)) + || (0..2).any(|i| map_a.output_action_index(i) != map.output_action_index(i)) + { + order_differs = true; + } + } else { + first = Some((bytes, map)); + } + } + assert!( + order_differs, + "the shuffle must reorder actions across builds" + ); + // The rseeds differ regardless of order: compare the ciphertexts of two builds. + let (a, _) = build(); + let (b, _) = build(); + assert_ne!( + a, b, + "two builds of the same request must differ byte-for-byte" + ); + } +} diff --git a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs index f517c2c742c..6a140cd5073 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs @@ -97,60 +97,61 @@ crate::impl_wasm_error_code!(IronwoodPcztError); // ---- Mirror structs (serde ⇄ postcard) ---- #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] -struct BundleWire { - flags: u8, - value_pool: u8, // 0 = Orchard, 1 = Ironwood - protocol_version: u8, // 0 = InsecureV1, 1 = V2, 2 = V3 - value_sum_magnitude: u64, - value_sum_negative: bool, - anchor: [u8; 32], - zkproof: Option>, - bsk: Option<[u8; 32]>, - actions: Vec, +pub(crate) struct BundleWire { + pub(crate) flags: u8, + pub(crate) value_pool: u8, // 0 = Orchard, 1 = Ironwood + pub(crate) protocol_version: u8, // 0 = InsecureV1, 1 = V2, 2 = V3 + pub(crate) value_sum_magnitude: u64, + pub(crate) value_sum_negative: bool, + pub(crate) anchor: [u8; 32], + pub(crate) zkproof: Option>, + pub(crate) bsk: Option<[u8; 32]>, + pub(crate) actions: Vec, } #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] -struct ActionWire { - cv_net: [u8; 32], - rcv: Option<[u8; 32]>, - spend: SpendWire, - output: OutputWire, +pub(crate) struct ActionWire { + pub(crate) cv_net: [u8; 32], + pub(crate) rcv: Option<[u8; 32]>, + pub(crate) spend: SpendWire, + pub(crate) output: OutputWire, } #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] -struct SpendWire { - nullifier: [u8; 32], - rk: [u8; 32], - spend_auth_sig: Option>, // 64 - recipient: Option>, // 43 - value: Option, - rho: Option<[u8; 32]>, - rseed: Option<[u8; 32]>, - fvk: Option>, // 96 — None for real spends; dummy spends carry their fabricating fvk - witness: Option, - alpha: Option<[u8; 32]>, - dummy_sk: Option<[u8; 32]>, - proprietary: BTreeMap>, +pub(crate) struct SpendWire { + pub(crate) nullifier: [u8; 32], + pub(crate) rk: [u8; 32], + pub(crate) spend_auth_sig: Option>, // 64 + pub(crate) recipient: Option>, // 43 + pub(crate) value: Option, + pub(crate) rho: Option<[u8; 32]>, + pub(crate) rseed: Option<[u8; 32]>, + /// 96 bytes — `None` for real spends; dummy spends carry their fabricating fvk + pub(crate) fvk: Option>, + pub(crate) witness: Option, + pub(crate) alpha: Option<[u8; 32]>, + pub(crate) dummy_sk: Option<[u8; 32]>, + pub(crate) proprietary: BTreeMap>, } #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] -struct WitnessWire { - position: u32, - auth_path: Vec<[u8; 32]>, +pub(crate) struct WitnessWire { + pub(crate) position: u32, + pub(crate) auth_path: Vec<[u8; 32]>, } #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] -struct OutputWire { - cmx: [u8; 32], - ephemeral_key: [u8; 32], - enc_ciphertext: Vec, // 580 - out_ciphertext: Vec, // 80 - recipient: Option>, - value: Option, - rseed: Option<[u8; 32]>, - ock: Option<[u8; 32]>, - user_address: Option, - proprietary: BTreeMap>, +pub(crate) struct OutputWire { + pub(crate) cmx: [u8; 32], + pub(crate) ephemeral_key: [u8; 32], + pub(crate) enc_ciphertext: Vec, // 580 + pub(crate) out_ciphertext: Vec, // 80 + pub(crate) recipient: Option>, + pub(crate) value: Option, + pub(crate) rseed: Option<[u8; 32]>, + pub(crate) ock: Option<[u8; 32]>, + pub(crate) user_address: Option, + pub(crate) proprietary: BTreeMap>, } // ---- Public API ---- @@ -233,6 +234,10 @@ pub fn with_spend_rk( } /// Set the constructor placeholder `rk` and mark it as requiring signer replacement. +/// +/// Oracle-only: the keyed Constructor's scrub is the only caller, and it exists only under +/// `test` / `orchard-proving` (plan PR 7 deletes this marker entirely). +#[cfg(any(test, feature = "orchard-proving"))] pub(crate) fn with_placeholder_spend_rk( bytes: &[u8], action_index: usize, @@ -774,6 +779,22 @@ fn wire_to_output( .map_err(|e| IronwoodPcztError::Parse(format!("{e:?}"))) } +/// Assemble a bundle from wire-form actions — the keyless spend constructor's assembly +/// path (plan §4): it builds its own actions plus the dummy-spend actions an outputs-only +/// [`Builder`](orchard::builder::Builder) fabricated, then re-parses the whole set through +/// the same validation a serialized PCZT takes ([`wire_to_bundle`], including the +/// duplicate-nullifier guard). +pub(crate) fn bundle_from_wire(wire: BundleWire) -> Result { + wire_to_bundle(wire) +} + +/// The wire-form actions of an already-constructed bundle — how the keyless spend +/// constructor moves an outputs-only builder bundle's dummy-spend actions into the combined +/// bundle without rebuilding them. +pub(crate) fn bundle_actions_to_wire(bundle: &PcztBundle) -> Vec { + bundle_to_wire(bundle).actions +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/wasm-utxo/src/zcash/mod.rs b/packages/wasm-utxo/src/zcash/mod.rs index 4f42c268c09..3b9f2593908 100644 --- a/packages/wasm-utxo/src/zcash/mod.rs +++ b/packages/wasm-utxo/src/zcash/mod.rs @@ -13,6 +13,9 @@ pub mod address; pub mod blake2b; /// orchard PCZT ↔ v6 IronwoodBundle bridge (Constructor / Signer / Extractor roles). pub mod ironwood_build; +/// The key-free Ironwood spend Constructor (plan §4): builds deshield and +/// shielded→shielded bundles without any viewing key. +pub mod ironwood_construct; /// Serialization of the orchard PCZT Ironwood bundle (proof-service payload / PSBT carry). pub mod ironwood_pczt; /// The key-free verifier for Ironwood PCZT bundles (plan §5).