diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs index 5d15c58d4be..528647fdf01 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs @@ -916,8 +916,8 @@ impl ZcashBitGoPsbt { if requests.is_empty() { return Err("at least one Ironwood output is required".to_string()); } - let (_, output_indices) = self.add_ironwood_actions(&[], requests, anchor, rng)?; - Ok(output_indices) + let action_map = self.add_ironwood_actions(&[], requests, anchor, rng)?; + Ok(action_map.output_action_indices().to_vec()) } /// Constructor role: build a single shielded output as an orchard PCZT and store it in the @@ -934,7 +934,7 @@ impl ZcashBitGoPsbt { unified_address: Option<&str>, rng: R, ) -> Result { - let action_indices = self.add_ironwood_outputs( + let output_indices = self.add_ironwood_outputs( &[IronwoodOutputRequest { recipient: *recipient, amount, @@ -945,7 +945,7 @@ impl ZcashBitGoPsbt { anchor, rng, )?; - Ok(action_indices[0]) + Ok(output_indices[0]) } /// Constructor role: build an orchard PCZT from real Ironwood spends and/or requested shielded @@ -958,8 +958,9 @@ impl ZcashBitGoPsbt { /// first batch (whose value the transparent side would still be funding). Multiple spends and /// outputs in one transaction must all be passed in this single call. /// - /// The returned index vectors give, for each `spends[i]`/`outputs[i]`, the action index it - /// landed at in the bundle — the orchard builder reorders actions (see + /// The returned [`IronwoodActionMap`](crate::zcash::ironwood_build::IronwoodActionMap) gives, + /// for each `spends[i]`/`outputs[i]`, the action index it landed at in the bundle — the + /// orchard builder reorders actions (see /// [`crate::zcash::ironwood_build::construct_spend_pczt_multi`]), so request order does not /// equal action order. /// @@ -970,7 +971,7 @@ impl ZcashBitGoPsbt { outputs: &[IronwoodOutputRequest], anchor: &crate::zcash::ironwood_build::AnchorBytes, rng: R, - ) -> Result<(Vec, Vec), String> { + ) -> Result { use crate::zcash::{ironwood_build, ironwood_pczt}; if super::propkv::get_ironwood_pczt(&self.psbt).is_some() { @@ -1018,7 +1019,7 @@ impl ZcashBitGoPsbt { memo: req.memo, }) .collect(); - let (pczt, spend_indices, output_indices) = + 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())?; @@ -1028,12 +1029,15 @@ impl ZcashBitGoPsbt { // PCZT key, not these) before writing the new batch's, so a since-gone action index's // stale UA can never be silently misattributed to a different recipient in the new bundle. super::propkv::clear_ironwood_unified_addresses(&mut self.psbt); - for (req, &action_index) in outputs.iter().zip(output_indices.iter()) { + 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((spend_indices, output_indices)) + Ok(action_map) } /// Reject a caller-supplied Unified Address whose Orchard receiver is not exactly `recipient`: @@ -1071,8 +1075,8 @@ impl ZcashBitGoPsbt { if requests.is_empty() { return Err("at least one Ironwood spend is required".to_string()); } - let (spend_indices, _) = self.add_ironwood_actions(requests, &[], anchor, rng)?; - Ok(spend_indices) + let action_map = self.add_ironwood_actions(requests, &[], anchor, rng)?; + Ok(action_map.spend_action_indices().to_vec()) } /// Add a plain transparent output. Just `script`/`value` (`unified_address: None`) is exactly @@ -2247,17 +2251,21 @@ impl ZcashBitGoPsbt { self, rng: R, ) -> Result, String> { - self.combine_ironwood_proof_locally_with_alphas(&[], rng) + self.combine_ironwood_proof_locally_with_alphas(&[], None, rng) } /// Spend-bundle variant of [`Self::combine_ironwood_proof_locally`]: the Prover role derives /// `rk` inside the circuit from `ak` + `alpha`, so proving a bundle with real spends requires - /// each spend's `alpha` — delivered here as `(action_index, alpha)` pairs. The alphas are - /// patched into the in-memory PCZT only and never reach the PSBT or the produced transaction. + /// each spend's `alpha` — delivered here as `(action_index, alpha)` pairs — and the spending + /// FVK, which the Constructor scrubbed from the stored PCZT ("the FVK never leaves its + /// holder": the local prover *is* the holder, so it passes its own FVK here; the KMS keyed + /// prover supplies the equivalent out-of-band). Both are patched into the in-memory PCZT + /// only and never reach the PSBT or the produced transaction. #[cfg(feature = "orchard-proving")] pub fn combine_ironwood_proof_locally_with_alphas( self, alphas: &[(usize, [u8; 32])], + spending_fvk: Option<&crate::zcash::ironwood_build::FvkBytes>, mut rng: R, ) -> Result, String> { use crate::zcash::{ironwood_build, ironwood_pczt}; @@ -2281,6 +2289,12 @@ impl ZcashBitGoPsbt { bytes = ironwood_pczt::with_spend_alpha(&bytes, *action_index, Some(*alpha)) .map_err(|e| e.to_string())?; } + if let Some(fvk) = spending_fvk { + // The prover needs the FVK on every real spend to derive `ak`/`nk`; dummy spends + // keep their fabricating FVK (the IO Finalizer signs them with it). + bytes = ironwood_pczt::with_spend_fvk_on_real_spends(&bytes, *fvk) + .map_err(|e| e.to_string())?; + } let mut pczt = ironwood_pczt::deserialize_pczt(&bytes).map_err(|e| e.to_string())?; ironwood_build::finalize_shield_io(&mut pczt, sighash, &mut rng) @@ -5649,6 +5663,242 @@ mod ironwood_v6_tests { ); } + /// Fixture bundle of [`build_signed_multi_spend_psbt`], factored out for clippy's + /// `type_complexity`. + type SignedMultiSpendFixture = ( + ZcashBitGoPsbt, + [(usize, [u8; 32]); 2], + [[u8; 32]; 2], + crate::zcash::ironwood_build::FvkBytes, + ); + + /// B3/B5 shared fixture: a 2-spend/2-output Ironwood bundle built against a real 2-leaf + /// Orchard tree, with both placeholder rks replaced (FROST stand-in: `rk = ak.randomize + /// (alpha)`), the transparent side signed, and both spend-auth signatures applied. Returns + /// the PSBT, the `(action_index, alpha)` pairs the local prover needs, each action's + /// randomized `rk`, and the spending FVK. + fn build_signed_multi_spend_psbt(seed: &str) -> SignedMultiSpendFixture { + use crate::zcash::ironwood_build::test_util::{real_two_leaf_tree, RealLeaf}; + use ff::PrimeField; + + let secp = Secp256k1::new(); + let wallet_keys = root_wallet_keys(seed); + let mut psbt = BitGoPsbt::new_zcash_v6_at_height( + Network::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu6_3.testnet_activation_height(), + None, + None, + ) + .unwrap(); + psbt.add_wallet_input( + Txid::from_byte_array([0x77u8; 32]), + 0, + 300_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + // Accounting: 300M transparent in + 180M shielded in (the two spends); 279_999_000 + // transparent out + 200M shielded out (the two payments) ⇒ exactly 1_000 zatoshi fee + // (value_balance is -20M: the transparent side funds 20M into the shielded pool). + psbt.add_wallet_output(0, 1, 279_999_000, &wallet_keys) + .unwrap(); + let BitGoPsbt::Zcash(mut z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + + 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 req = |leaf: &RealLeaf| IronwoodSpendRequest { + fvk: fvk.to_bytes(), + recipient: leaf.recipient, + value: leaf.value, + rho: leaf.rho, + rseed: leaf.rseed, + cmx: leaf.cmx, + witness: leaf.witness, + }; + let spends = [req(&leaves[0]), req(&leaves[1])]; + // Shielded self-payments back to this wallet's two addresses. + let outputs = [ + IronwoodOutputRequest { + recipient: spends[0].recipient, + amount: 100_000_000, + ovk: None, + memo: [0u8; 512], + unified_address: None, + }, + IronwoodOutputRequest { + recipient: spends[1].recipient, + amount: 100_000_000, + ovk: None, + memo: [0u8; 512], + unified_address: None, + }, + ]; + let action_map = z + .add_ironwood_actions(&spends, &outputs, &anchor, OsRng) + .unwrap(); + let ask = SpendAuthorizingKey::from(&sk); + let mut pairs = [(0usize, [0u8; 32]); 2]; + let mut rks = [[0u8; 32]; 2]; + for (i, action_index) in action_map.spend_action_indices().iter().enumerate() { + let alpha = pasta_curves::pallas::Scalar::random(&mut OsRng); + let rsk = ask.randomize(&alpha); + let rk = <[u8; 32]>::from(VerificationKey::::from(&rsk)); + z.replace_ironwood_spend_rk(*action_index, rk).unwrap(); + pairs[i] = (*action_index, alpha.to_repr()); + rks[i] = rk; + } + + // The transparent side: real-spend bundles skip the client-ovk round, so either key + // may open; sign user-then-bitgo like the shielding flow. + z.sign_ironwood_v6(&test_wallet_xpriv(seed, 0), &wallet_keys, &secp) + .unwrap(); + z.sign_ironwood_v6(&test_wallet_xpriv(seed, 2), &wallet_keys, &secp) + .unwrap(); + + let sighash = z.v6_sig_digest().unwrap(); + for (i, action_index) in action_map.spend_action_indices().iter().enumerate() { + let alpha = pasta_curves::pallas::Scalar::from_repr(pairs[i].1).unwrap(); + let rsk = ask.randomize(&alpha); + let sig = rsk.sign(OsRng, &sighash); + z.apply_ironwood_spend_signature(*action_index, <[u8; 64]>::from(&sig)) + .unwrap(); + } + (z, pairs, rks, fvk.to_bytes()) + } + + /// B3: a 2-spend/2-output bundle signs and combines end-to-end with a proof sized for its + /// action count, and zebra independently decodes the result. + #[test] + fn multi_spend_multi_output_round_trips() { + let (z, pairs, rks, _fvk) = + build_signed_multi_spend_psbt("ironwood_v6_multi_spend_multi_out"); + 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.proof.len(), Proof::expected_proof_size(2)); + assert_eq!(bundle.spend_auth_sigs.len(), 2); + + 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.version(), 6); + assert_eq!(zebra.ironwood_actions().count(), 2); + // Each action carries exactly the randomized rk installed for its request — the map's + // action routing survives the builder's reordering all the way to the wire. + for (i, (action_index, _alpha)) in pairs.iter().enumerate() { + assert_eq!( + bundle.actions[*action_index].rk, rks[i], + "action {action_index} must carry spend {i}'s randomized rk" + ); + } + } + + /// B3: the Transaction Extractor rejects a proof sized for the wrong action count. + #[test] + fn combine_rejects_a_proof_sized_for_a_different_action_count() { + let (z, _pairs, _rks, _fvk) = build_signed_multi_spend_psbt("ironwood_v6_wrong_size_proof"); + let err = z + .combine_ironwood_proof(vec![0u8; Proof::expected_proof_size(1)], OsRng) + .unwrap_err(); + assert!( + err.contains("non-canonical length"), + "expected a proof-size rejection, got: {err}" + ); + } + + /// PR-4 end-to-end under the proving feature: construct (placeholder rk) -> install real + /// alphas/rks -> sign -> finalize IO -> prove LOCALLY -> extract -> verify the proof with + /// orchard's own verifier — with the spending FVK supplied by the local prover (the holder). + #[cfg(feature = "orchard-proving")] + #[test] + fn multi_spend_multi_output_proves_and_verifies_locally() { + 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, SpendAuth, VerificationKey}; + use orchard::value::ValueCommitment; + use orchard::{Action as OrchardAction, Proof}; + + let (z, pairs, _rks, fvk_bytes) = + build_signed_multi_spend_psbt("ironwood_v6_multi_local_proof"); + let raw = z + .combine_ironwood_proof_locally_with_alphas(&pairs, Some(&fvk_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(), 2); + assert_eq!(bundle.proof.len(), Proof::expected_proof_size(2)); + + 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(), 2); + + // 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("locally-produced 2-action proof verifies"); + } + #[cfg(feature = "orchard-proving")] #[test] fn combine_ironwood_proof_locally_rejects_a_transaction_that_does_not_conserve_value() { @@ -5714,8 +5964,8 @@ mod ironwood_v6_tests { ) .unwrap(); let (req, anchor, ask) = test_spend_request(); - let (spend_indices, _out) = z.add_ironwood_actions(&[req], &[], &anchor, OsRng).unwrap(); - assert_eq!(spend_indices, vec![0]); + let action_map = z.add_ironwood_actions(&[req], &[], &anchor, OsRng).unwrap(); + assert_eq!(action_map.spend_action_indices(), &[0]); // rk before any transparent signature (the replace guard enforces the ordering). let mut rng = OsRng; diff --git a/packages/wasm-utxo/src/zcash/ironwood_build.rs b/packages/wasm-utxo/src/zcash/ironwood_build.rs index 2d1e08d6177..bda257c8c92 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_build.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_build.rs @@ -197,6 +197,13 @@ pub enum IronwoodBuildError { /// The bundle's spends use more than one full viewing key; one wallet (one FVK) per bundle, /// because the output `ovk` defaults to the spending FVK's own. MixedSpendingFvks, + /// Two of the bundle's spends derive the same nullifier. Orchard does not check this, and + /// consensus rejects a transaction with a repeated nullifier, so the duplicate must be caught + /// at construction instead of dying on-chain. + DuplicateNullifier { + /// The later action whose nullifier repeats an earlier spend's. + action_index: usize, + }, /// The orchard PCZT Updater role rejected an update (e.g. an action index out of range). Updater(String), /// The PCZT wire round-trip (serialize/patch/deserialize) failed. @@ -265,6 +272,11 @@ impl core::fmt::Display for IronwoodBuildError { "ironwood-build: cap does not have enough detail to compute a sibling root" ), Self::BadFvk => write!(f, "ironwood-build: invalid full viewing key bytes"), + Self::DuplicateNullifier { action_index } => write!( + f, + "ironwood-build: duplicate nullifier across spend actions (action {action_index} \ + repeats an earlier spend's nullifier)" + ), Self::MixedSpendingFvks => write!( f, "ironwood-build: all spends in one bundle must share one full viewing key" @@ -320,6 +332,38 @@ pub struct IronwoodSpendSpec { pub witness: IronwoodWitness, } +/// Maps each caller-supplied spend/output position to the action index it landed at in the +/// constructed bundle. The builder reorders actions (see `orchard::builder::BundleMetadata`), so +/// callers that associate per-spend or per-output data with an action must go through this +/// mapping rather than assuming index `i`. +#[derive(Clone, Debug)] +pub struct IronwoodActionMap { + spend_action_indices: Vec, + output_action_indices: Vec, +} + +impl IronwoodActionMap { + /// The action index `spends[i]` landed at. + pub fn spend_action_index(&self, i: usize) -> Option { + self.spend_action_indices.get(i).copied() + } + + /// The action index `outputs[i]` landed at. + pub fn output_action_index(&self, i: usize) -> Option { + self.output_action_indices.get(i).copied() + } + + /// The action index of every requested spend, in request order. + pub fn spend_action_indices(&self) -> &[usize] { + &self.spend_action_indices + } + + /// The action index of every requested output, in request order. + pub fn output_action_indices(&self) -> &[usize] { + &self.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. @@ -338,10 +382,10 @@ pub struct IronwoodSpendSpec { /// `rng` must be a CSPRNG — it seeds the builder's internal randomness. /// /// Actions are randomized (reordered) relative to the `spends`/`outputs` order (see -/// `orchard::builder::BundleMetadata`), so the returned index vectors give, for each +/// `orchard::builder::BundleMetadata`), so the returned [`IronwoodActionMap`] gives, for each /// `spends[i]`/`outputs[i]`, the action index it landed at in the bundle — callers that need to -/// associate per-spend or per-output data with the right action must go through these mappings -/// rather than assuming index `i`. +/// associate per-spend or per-output data with the right action must go through the map rather +/// than assuming index `i`. /// /// 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 @@ -352,7 +396,7 @@ pub fn construct_spend_pczt_multi( outputs: &[IronwoodOutputSpec], anchor: &AnchorBytes, mut rng: R, -) -> Result<(PcztBundle, Vec, Vec), IronwoodBuildError> { +) -> Result<(PcztBundle, IronwoodActionMap), IronwoodBuildError> { if spends.is_empty() && outputs.is_empty() { return Err(IronwoodBuildError::EmptyBundle); } @@ -417,22 +461,30 @@ pub fn construct_spend_pczt_multi( let (bundle, meta) = builder .build_for_pczt(&mut rng) .map_err(|e| IronwoodBuildError::Builder(e.to_string()))?; - let spend_indices = (0..spends.len()) - .map(|i| { - meta.spend_action_index(i) - .ok_or(IronwoodBuildError::ActionIndexOutOfRange) - }) - .collect::, _>>()?; - let output_indices = (0..outputs.len()) - .map(|i| { - meta.output_action_index(i) - .ok_or(IronwoodBuildError::ActionIndexOutOfRange) - }) - .collect::, _>>()?; + let action_map = IronwoodActionMap { + spend_action_indices: (0..spends.len()) + .map(|i| { + meta.spend_action_index(i) + .ok_or(IronwoodBuildError::ActionIndexOutOfRange) + }) + .collect::, _>>()?, + output_action_indices: (0..outputs.len()) + .map(|i| { + meta.output_action_index(i) + .ok_or(IronwoodBuildError::ActionIndexOutOfRange) + }) + .collect::, _>>()?, + }; // Validate FVK-dependent invariants while the constructor still has the transient FVKs. verify_spend_pczt(&bundle)?; - validate_constructed_out_ciphertexts(&bundle, outputs, &output_indices, default_ovk, &mut rng)?; + validate_constructed_out_ciphertexts( + &bundle, + outputs, + action_map.output_action_indices(), + default_ovk, + &mut rng, + )?; // Record each spent note's cmx for reconciliation, then strip what the signing service owns: // the builder bakes a random `alpha` and a randomized `rk` into every real spend at build time, @@ -441,7 +493,10 @@ pub fn construct_spend_pczt_multi( // Finalizer signs them from their stored alpha/dummy_sk). let mut bundle = bundle; for (i, spec) in spends.iter().enumerate() { - let idx = spend_indices[i]; + // The map covers every requested spend by construction (it was built from the same len). + let idx = action_map + .spend_action_index(i) + .expect("map covers every requested spend"); bundle .update_with(|mut u| { u.update_action_with(idx, |mut a| { @@ -451,8 +506,9 @@ pub fn construct_spend_pczt_multi( }) .map_err(|e| IronwoodBuildError::Updater(e.to_string()))?; } - let bundle = scrub_spend_randomizers(&bundle, &spend_indices, &spend_ak_bytes)?; - Ok((bundle, spend_indices, output_indices)) + let bundle = + scrub_spend_randomizers(&bundle, action_map.spend_action_indices(), &spend_ak_bytes)?; + Ok((bundle, action_map)) } /// Check that every real output whose `ovk` is known at construction time (an explicit one, or the @@ -493,10 +549,8 @@ pub fn construct_shield_pczt_multi( outputs: &[IronwoodOutputSpec], anchor: &AnchorBytes, rng: R, -) -> Result<(PcztBundle, Vec), IronwoodBuildError> { - let (bundle, _spend_indices, action_indices) = - construct_spend_pczt_multi(&[], outputs, anchor, rng)?; - Ok((bundle, action_indices)) +) -> Result<(PcztBundle, IronwoodActionMap), IronwoodBuildError> { + construct_spend_pczt_multi(&[], outputs, anchor, rng) } /// The parsed-and-validated values one real spend contributes to the builder. @@ -591,13 +645,18 @@ fn scrub_spend_randomizers( /// /// - spends with an FVK — re-derive and check the note nullifier; /// - outputs with a recipient — verify the note commitment; -/// - actions with both sides' fields present — verify the value commitment. +/// - actions with both sides' fields present — verify the value commitment; +/// - every spend's nullifier is unique across the bundle (orchard does not check this; consensus +/// rejects a transaction with a repeated nullifier). /// /// `verify_rk` is deliberately skipped: it requires `alpha`, which real spends do not carry. pub fn verify_spend_pczt(bundle: &PcztBundle) -> Result<(), IronwoodBuildError> { bundle .verify_cross_address_restriction() .map_err(|e| IronwoodBuildError::Verify(e.to_string()))?; + if let Some(action_index) = super::ironwood_pczt::duplicate_nullifier_index(bundle) { + return Err(IronwoodBuildError::DuplicateNullifier { action_index }); + } for (i, action) in bundle.actions().iter().enumerate() { let spend = action.spend(); let output = action.output(); @@ -631,7 +690,7 @@ pub fn construct_shield_pczt( memo: &MemoBytes, rng: R, ) -> Result { - let (bundle, _action_indices) = construct_shield_pczt_multi( + let (bundle, _) = construct_shield_pczt_multi( &[IronwoodOutputSpec { recipient: *recipient, amount, @@ -2372,10 +2431,14 @@ mod tests { #[test] fn construct_spend_pczt_builds_a_real_spend_bundle() { let (spec, anchor) = test_spend_spec(); - let (bundle, spend_indices, output_indices) = + let (bundle, action_map) = construct_spend_pczt_multi(&[spec], &[], &anchor, OsRng).unwrap(); - assert_eq!(spend_indices, vec![0], "one spend ⇒ one action"); - assert!(output_indices.is_empty()); + assert_eq!( + action_map.spend_action_indices(), + &[0], + "one spend ⇒ one action" + ); + assert!(action_map.output_action_indices().is_empty()); let spend = bundle.actions()[0].spend(); let fvk = FullViewingKey::from_bytes(&spec.fvk).unwrap(); @@ -2435,7 +2498,7 @@ mod tests { #[test] fn construct_spend_pczt_defaults_output_ovk_to_the_spending_fvk() { let (spec, anchor) = test_spend_spec(); - let (bundle, _spend_indices, output_indices) = construct_spend_pczt_multi( + let (bundle, action_map) = construct_spend_pczt_multi( &[spec], &[IronwoodOutputSpec { recipient: test_recipient(), @@ -2447,7 +2510,7 @@ mod tests { OsRng, ) .unwrap(); - let out_idx = output_indices[0]; + let out_idx = action_map.output_action_index(0).unwrap(); let output = bundle.actions()[out_idx].output(); assert!(output.recipient().is_some(), "real output, not a dummy"); @@ -2549,7 +2612,7 @@ mod tests { )); // Shielding (no spends) still builds keyless with ovk None. - let (_, _, out_idx) = construct_spend_pczt_multi( + let (_, action_map) = construct_spend_pczt_multi( &[], &[IronwoodOutputSpec { recipient: test_recipient(), @@ -2561,18 +2624,18 @@ mod tests { OsRng, ) .unwrap(); - assert_eq!(out_idx.len(), 1); + assert_eq!(action_map.output_action_indices().len(), 1); } #[test] fn verify_spend_pczt_accepts_constructed_bundles() { let (spec, anchor) = test_spend_spec(); - let (bundle, _, _) = construct_spend_pczt_multi(&[spec], &[], &anchor, OsRng).unwrap(); + let (bundle, _) = construct_spend_pczt_multi(&[spec], &[], &anchor, OsRng).unwrap(); verify_spend_pczt(&bundle).unwrap(); // The Constructor ran nullifier, note-commitment, and cv_net checks before serializing; the // returned PCZT intentionally no longer carries the FVK needed to repeat spend checks. - let (bundle, _, _) = construct_spend_pczt_multi( + let (bundle, _) = construct_spend_pczt_multi( &[spec], &[IronwoodOutputSpec { recipient: test_recipient(), @@ -2597,7 +2660,7 @@ mod tests { #[test] fn verify_spend_pczt_rejects_a_tampered_output_note() { let (spec, anchor) = test_spend_spec(); - let (bundle, _, output_indices) = construct_spend_pczt_multi( + let (bundle, action_map) = construct_spend_pczt_multi( &[spec], &[IronwoodOutputSpec { recipient: test_recipient(), @@ -2610,11 +2673,249 @@ mod tests { ) .unwrap(); let bytes = serialize_pczt(&bundle).unwrap(); - let tampered = with_output_rseed_for_test(&bytes, output_indices[0], [9u8; 32]).unwrap(); + let tampered = with_output_rseed_for_test( + &bytes, + action_map.output_action_index(0).unwrap(), + [9u8; 32], + ) + .unwrap(); let msg = match verify_spend_pczt(&deserialize_pczt(&tampered).unwrap()) { Err(IronwoodBuildError::Verify(msg)) => msg, other => panic!("expected a Verify error, got {other:?}"), }; assert!(msg.contains("cmx"), "got {msg:?}"); } + + /// Gap 13: a spend validated against a **real** note-commitment tree — real commitment + /// leaves folded with `MerkleHashOrchard::combine` and real empty-leaf subtrees — not the + /// self-consistent synthetic fixtures (arbitrary field bytes keyed off their own anchor). + #[test] + fn construct_witnesses_against_a_real_tree() { + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + let fvk = FullViewingKey::from(&sk); + let (anchor, leaves) = super::test_util::real_two_leaf_tree( + &fvk, + [ + (100_000_000u64, field_bytes(200), [52u8; 32]), + (80_000_000u64, field_bytes(201), [53u8; 32]), + ], + ); + let spec = |leaf: &super::test_util::RealLeaf| IronwoodSpendSpec { + fvk: fvk.to_bytes(), + recipient: leaf.recipient, + value: leaf.value, + rho: leaf.rho, + rseed: leaf.rseed, + cmx: leaf.cmx, + witness: leaf.witness, + }; + + // Positive: both leaves' witnesses recompute to the shared real root. + let specs = [spec(&leaves[0]), spec(&leaves[1])]; + let (bundle, map) = construct_spend_pczt_multi(&specs, &[], &anchor, OsRng).unwrap(); + assert_eq!(bundle.actions().len(), 2); + assert_eq!(map.spend_action_indices().len(), 2); + + // Negative: a witness recomputed for the WRONG leaf does not fold to the anchor — a + // real tree distinguishes its leaves, so a spend cannot ride another note's witness. + let wrong = IronwoodSpendSpec { + witness: leaves[1].witness, + ..spec(&leaves[0]) + }; + let err = construct_spend_pczt_multi(&[wrong], &[], &anchor, OsRng).unwrap_err(); + assert!( + matches!(err, IronwoodBuildError::WitnessAnchorMismatch), + "unexpected: {err:?}" + ); + } + + /// Invariant 5: two spends deriving the same nullifier are rejected at construction — + /// orchard does not check this, and consensus rejects a transaction with a repeated + /// nullifier. + #[test] + fn construct_rejects_duplicate_nullifiers() { + // The nullifier derives from (nk, rho) alone, so the SAME note spent twice in one + // bundle collides regardless of anything else. + let (spec, anchor) = test_spend_spec(); + let err = construct_spend_pczt_multi(&[spec, spec], &[], &anchor, OsRng).unwrap_err(); + assert!( + matches!( + err, + IronwoodBuildError::DuplicateNullifier { action_index: 1 } + ), + "unexpected: {err:?}" + ); + } + + /// B5: the builder reorders actions, so the returned map — not the request index — must + /// route per-spend/per-output data to the action that actually carries it. + #[test] + fn action_map_pins_the_reordered_action_positions() { + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + let fvk = FullViewingKey::from(&sk); + let (anchor, leaves) = super::test_util::real_two_leaf_tree( + &fvk, + [ + (100_000_000u64, field_bytes(200), [52u8; 32]), + (80_000_000u64, field_bytes(201), [53u8; 32]), + ], + ); + let spec = |leaf: &super::test_util::RealLeaf| IronwoodSpendSpec { + fvk: fvk.to_bytes(), + recipient: leaf.recipient, + value: leaf.value, + rho: leaf.rho, + rseed: leaf.rseed, + cmx: leaf.cmx, + witness: leaf.witness, + }; + let specs = [spec(&leaves[0]), spec(&leaves[1])]; + let outputs = [ + IronwoodOutputSpec { + recipient: fvk + .address_at(10u32, Scope::External) + .to_raw_address_bytes(), + amount: 30_000_000, + ovk: None, + memo: [0u8; 512], + }, + IronwoodOutputSpec { + recipient: fvk + .address_at(11u32, Scope::External) + .to_raw_address_bytes(), + amount: 20_000_000, + ovk: None, + memo: [0u8; 512], + }, + ]; + let (bundle, map) = construct_spend_pczt_multi(&specs, &outputs, &anchor, OsRng).unwrap(); + assert_eq!(bundle.actions().len(), 2, "UNPADDED 2+2 => 2 actions"); + + for (i, s) in specs.iter().enumerate() { + let idx = map.spend_action_index(i).expect("map covers every spend"); + let recorded = bundle.actions()[idx] + .spend() + .proprietary() + .get(CMX_PROPRIETARY_KEY) + .expect("the Constructor recorded the spend's cmx") + .clone(); + assert_eq!( + recorded.as_slice(), + s.cmx, + "spend {i} maps to the action carrying its cmx" + ); + } + for (i, o) in outputs.iter().enumerate() { + let idx = map.output_action_index(i).expect("map covers every output"); + let recipient = bundle.actions()[idx] + .output() + .recipient() + .expect("real output carries its recipient") + .to_raw_address_bytes(); + assert_eq!(recipient, o.recipient, "output {i} maps to its own action"); + } + } +} + +/// Test-only fixtures shared across modules (the `zcash_psbt` tests build multi-spend flows on +/// the same tree). Not compiled outside `cargo test`. +#[cfg(test)] +pub(crate) mod test_util { + use super::*; + + /// One leaf of the fixture tree: everything needed to spend the note, plus the witness that + /// proves it against the shared root. + pub(crate) struct RealLeaf { + pub recipient: OrchardAddressBytes, + pub value: u64, + pub rho: [u8; 32], + pub rseed: [u8; 32], + pub cmx: [u8; 32], + pub witness: IronwoodWitness, + } + + /// Build a real, internally consistent 2-leaf Orchard note-commitment tree over two notes + /// owned by `fvk` — `(value, rho, rseed)` per leaf, recipients `fvk.address_at(i)`. Leaves + /// are real commitments, the level-1 parent folds them with `MerkleHashOrchard::combine`, + /// and every empty subtree is the real `empty_leaf` hash, so each leaf's witness recomputes + /// to the shared root exactly as it would against a live tree. + pub(crate) fn real_two_leaf_tree( + fvk: &FullViewingKey, + notes: [(u64, [u8; 32], [u8; 32]); 2], + ) -> (AnchorBytes, [RealLeaf; 2]) { + let note_version = BundleVersion::ironwood_v3().note_version(); + let mut cmxs = [[0u8; 32]; 2]; + let mut parts = Vec::with_capacity(2); + for (i, (value, rho_bytes, rseed_bytes)) in notes.iter().enumerate() { + let recipient = fvk + .address_at(i as u32, Scope::External) + .to_raw_address_bytes(); + let address = + Option::
::from(Address::from_raw_address_bytes(&recipient)).unwrap(); + let rho = Option::::from(Rho::from_bytes(rho_bytes)).unwrap(); + let rseed = + Option::::from(RandomSeed::from_bytes(*rseed_bytes, &rho)).unwrap(); + let note = Option::::from(Note::from_parts( + address, + NoteValue::from_raw(*value), + rho, + rseed, + note_version, + )) + .unwrap(); + cmxs[i] = ExtractedNoteCommitment::from(note.commitment()).to_bytes(); + parts.push((recipient, *value, *rho_bytes, *rseed_bytes)); + } + + let leaf = |bytes: &[u8; 32]| { + MerkleHashOrchard::from_cmx( + &Option::::from(ExtractedNoteCommitment::from_bytes( + bytes, + )) + .unwrap(), + ) + }; + let (leaf0, leaf1) = (leaf(&cmxs[0]), leaf(&cmxs[1])); + let empty = MerkleHashOrchard::empty_leaf(); + // Fold a leaf up to the root through `siblings[d]` (its sibling at depth `d`). + // Orchard's `combine` takes the CHILDREN's level: leaves combine at level 0. + let fold = |start: MerkleHashOrchard, + index: u64, + siblings: &[MerkleHashOrchard; IRONWOOD_MERKLE_DEPTH]| { + let mut node = start; + for (d, sibling) in siblings.iter().enumerate() { + node = if index & (1 << d) == 0 { + MerkleHashOrchard::combine(Level::new(d as u8), &node, sibling) + } else { + MerkleHashOrchard::combine(Level::new(d as u8), sibling, &node) + }; + } + node + }; + let mut sibs0 = [empty; IRONWOOD_MERKLE_DEPTH]; + sibs0[0] = leaf1; + let mut sibs1 = [empty; IRONWOOD_MERKLE_DEPTH]; + sibs1[0] = leaf0; + let root0 = fold(leaf0, 0, &sibs0); + let root1 = fold(leaf1, 1, &sibs1); + assert_eq!(root0, root1, "both leaves fold to the same root"); + let to_path = |s: &[MerkleHashOrchard; IRONWOOD_MERKLE_DEPTH]| { + std::array::from_fn(|i| s[i].to_bytes()) + }; + let mk_leaf = |i: usize, siblings: &[MerkleHashOrchard; IRONWOOD_MERKLE_DEPTH]| { + let (recipient, value, rho, rseed) = parts[i]; + RealLeaf { + recipient, + value, + rho, + rseed, + cmx: cmxs[i], + witness: IronwoodWitness { + position: i as u32, + auth_path: to_path(siblings), + }, + } + }; + (root0.to_bytes(), [mk_leaf(0, &sibs0), mk_leaf(1, &sibs1)]) + } } diff --git a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs index 591e80b9275..508fdfd26c9 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs @@ -50,6 +50,14 @@ pub enum IronwoodPcztError { BadFieldEncoding(&'static str), /// orchard rejected the reconstructed bundle. Parse(String), + /// Two spends in the bundle carry the same nullifier — orchard does not check this, and + /// consensus rejects a transaction with a repeated nullifier. The deserialize-time guard + /// covers PCZTs that arrive over the wire (a proof-service response, a stored PSBT), not + /// just ones built here. + DuplicateNullifier { + /// The later action whose nullifier repeats an earlier spend's. + action_index: usize, + }, } impl core::fmt::Display for IronwoodPcztError { @@ -76,6 +84,11 @@ impl core::fmt::Display for IronwoodPcztError { write!(f, "ironwood-pczt: invalid encoding for {field}") } Self::Parse(e) => write!(f, "ironwood-pczt: orchard rejected the bundle: {e}"), + Self::DuplicateNullifier { action_index } => write!( + f, + "ironwood-pczt: duplicate nullifier across spend actions (action {action_index} \ + repeats an earlier spend's nullifier)" + ), } } } @@ -274,6 +287,27 @@ pub fn with_spend_value( }) } +/// Install the spending FVK on every real spend (dummy spends keep their fabricating FVK — +/// the IO Finalizer signs them with it). +/// +/// The local prover derives `ak`/`nk` for the circuit from the FVK (`ProverError:: +/// MissingFullViewingKey` otherwise), and real spends had it scrubbed at construction ("the +/// FVK never leaves its holder" — here the holder is the caller, which owns the notes and is +/// proving locally). Transient: the result is consumed in-memory and never stored back. +pub fn with_spend_fvk_on_real_spends( + bytes: &[u8], + fvk: super::ironwood_build::FvkBytes, +) -> Result, IronwoodPcztError> { + with_patched_wire(bytes, |wire| { + for action in wire.actions.iter_mut() { + if action.spend.dummy_sk.is_none() { + action.spend.fvk = Some(fvk.to_vec()); + } + } + Ok(()) + }) +} + /// Clear one action spend's `fvk` in an already-serialized PCZT. /// /// The Constructor role's real spends are built with the full viewing key held @@ -469,7 +503,7 @@ fn wire_to_bundle(w: BundleWire) -> Result { .map(|a| wire_to_action(a, note_version)) .collect::, _>>()?; - PcztBundle::parse( + let bundle = PcztBundle::parse( actions, w.flags, bundle_version, @@ -478,7 +512,30 @@ fn wire_to_bundle(w: BundleWire) -> Result { w.zkproof, w.bsk, ) - .map_err(|e| IronwoodPcztError::Parse(format!("{e:?}"))) + .map_err(|e| IronwoodPcztError::Parse(format!("{e:?}")))?; + + // Ingestion guard: two spends deriving the same nullifier are consensus-invalid (orchard + // does not check this), and PCZTs arrive over the wire from elsewhere, so this cannot live + // only at construction. + if let Some(action_index) = duplicate_nullifier_index(&bundle) { + return Err(IronwoodPcztError::DuplicateNullifier { action_index }); + } + Ok(bundle) +} + +/// Index of the later action whose nullifier repeats an earlier spend's, if any. +/// +/// Orchard does not check this, and consensus rejects a transaction with a repeated nullifier. +/// Both the construction-time verifier (which returns the build-layer error) and the +/// deserialize-time ingestion guard (the wire-layer error) share this scan. +pub(crate) fn duplicate_nullifier_index(bundle: &PcztBundle) -> Option { + let mut seen = std::collections::HashSet::new(); + for (i, action) in bundle.actions().iter().enumerate() { + if !seen.insert(action.spend().nullifier().to_bytes()) { + return Some(i); + } + } + None } fn wire_to_action( @@ -767,6 +824,53 @@ mod tests { ); } + #[test] + fn deserialize_rejects_duplicate_nullifiers() { + // Ingestion guard: construction-time checking alone does not cover PCZTs that arrive + // over the wire (a proof-service response, a stored PSBT), so the duplicate-nullifier + // check runs at deserialize too. Two dummy-spend actions, nullifiers forced equal. + let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); + let fvk = FullViewingKey::from(&sk); + let bundle_version = BundleVersion::ironwood_v3(); + let mut builder = Builder::new( + BundleType::UNPADDED, + bundle_version, + bundle_version.default_flags(), + Anchor::empty_tree(), + ) + .unwrap(); + builder + .add_output( + None, + fvk.address_at(0u32, Scope::External), + NoteValue::from_raw(100_000_000), + [0u8; 512], + ) + .unwrap(); + builder + .add_output( + None, + fvk.address_at(1u32, Scope::External), + NoteValue::from_raw(90_000_000), + [0u8; 512], + ) + .unwrap(); + let bundle = builder.build_for_pczt(OsRng).unwrap().0; + let bytes = serialize_pczt(&bundle).unwrap(); + deserialize_pczt(&bytes).expect("the honest bundle decodes"); + + let patched = with_patched_wire(&bytes, |wire| { + let nf = wire.actions[0].spend.nullifier; + wire.actions[1].spend.nullifier = nf; + Ok(()) + }) + .unwrap(); + assert!(matches!( + deserialize_pczt(&patched), + Err(IronwoodPcztError::DuplicateNullifier { action_index: 1 }) + )); + } + #[test] fn with_spend_patchers_reject_bad_context() { let bytes = serialize_pczt(&sample_pczt()).unwrap();