diff --git a/fuzz_props/src/privacy.rs b/fuzz_props/src/privacy.rs index 4bc2f003c..723ac60c0 100644 --- a/fuzz_props/src/privacy.rs +++ b/fuzz_props/src/privacy.rs @@ -199,6 +199,22 @@ fn arb_private_action( }) } +/// Append `action` to `actions` unless its nullifier **or** its commitment already appears +/// there — either duplicate *alone* trips validator check 2 (nullifiers and commitments must +/// each be unique across a message's private actions), so a partial collision must be +/// dropped just like a full one. +pub(crate) fn push_private_action_if_unique( + actions: &mut Vec, + action: PrivateAction, +) { + if !actions + .iter() + .any(|a| a.nullifier == action.nullifier || a.commitment == action.commitment) + { + actions.push(action); + } +} + /// Generate a privacy-preserving transaction aimed at the **state-transition executor**. /// /// The transaction is built to *frequently* pass every validation check up to and including @@ -288,12 +304,7 @@ pub fn arb_privacy_preserving_tx( let mut private_actions: Vec = Vec::new(); for _ in 0..n_priv { let action = arb_private_action(u, live_root)?; - if !private_actions - .iter() - .any(|a| a.nullifier == action.nullifier || a.commitment == action.commitment) - { - private_actions.push(action); - } + push_private_action_if_unique(&mut private_actions, action); } // Validator check 1: the private-action list must be non-empty. diff --git a/fuzz_props/src/tests/privacy.rs b/fuzz_props/src/tests/privacy.rs index 7bc4ced56..a06016f2b 100644 --- a/fuzz_props/src/tests/privacy.rs +++ b/fuzz_props/src/tests/privacy.rs @@ -3,7 +3,7 @@ use arbitrary::Unstructured; use crate::generators::{FuzzAccount, account_id_for_key}; use crate::privacy::{ arb_account, arb_conflicting_nullifier_pair, arb_privacy_preserving_tx, arb_validity_window, - synthesize_passing_proof, + push_private_action_if_unique, synthesize_passing_proof, }; use nssa::privacy_preserving_transaction::{Message as PPMessage, WitnessSet as PPWitnessSet}; use nssa::{AccountId, PrivacyPreservingTransaction, PrivateKey, V03State}; @@ -470,6 +470,51 @@ fn arb_privacy_preserving_tx_generator_invariants() { ); } +/// The private-action dedup guard must reject a *partial* collision — a candidate sharing +/// only the nullifier, or only the commitment, with an already-kept action — because +/// validator check 2 requires nullifiers and commitments to *each* be unique across the +/// message. Random fuzz draws never produce partial collisions (both fields derive from +/// independent 32-byte draws), so this pins the guard's `||` directly: mutated to `&&`, +/// both partial-collision cases below would be accepted and the assertions fail. +#[test] +fn push_private_action_if_unique_rejects_partial_collisions() { + let state = crate::genesis::genesis_state(&[], vec![]); + let kept = valid_private_action(&state, 1); + let fresh = valid_private_action(&state, 2); + + let mut actions = Vec::new(); + push_private_action_if_unique(&mut actions, kept.clone()); + assert_eq!(actions.len(), 1, "first action must always be accepted"); + + // Same nullifier, different commitment → rejected (duplicate-nullifier check 2). + let mut nullifier_clash = fresh.clone(); + nullifier_clash.nullifier = kept.nullifier; + push_private_action_if_unique(&mut actions, nullifier_clash); + assert_eq!( + actions.len(), + 1, + "an action sharing only the nullifier must be rejected" + ); + + // Different nullifier, same commitment → rejected (duplicate-commitment check 2). + let mut commitment_clash = fresh.clone(); + commitment_clash.commitment = kept.commitment; + push_private_action_if_unique(&mut actions, commitment_clash); + assert_eq!( + actions.len(), + 1, + "an action sharing only the commitment must be rejected" + ); + + // Fully distinct → accepted. + push_private_action_if_unique(&mut actions, fresh.clone()); + assert_eq!(actions.len(), 2, "a fully distinct action must be kept"); + + // Exact duplicate → rejected. + push_private_action_if_unique(&mut actions, fresh); + assert_eq!(actions.len(), 2, "an exact duplicate must be rejected"); +} + // ── arb_conflicting_nullifier_pair ────────────────────────────────────────────────────── // The pair builder underpins `fuzz_transaction_ordering_independence`: it must always yield // two transactions that use *distinct* signers (so a rejection of the second application is