From 0f4cf52491ff0a22f718184735ee22ea3196ce48 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 2 Jul 2026 13:55:36 +0200 Subject: [PATCH] test: transaction ordering independence --- .github/workflows/fuzz-afl.yml | 1 + .github/workflows/fuzz.yml | 2 + .../fuzz_state_transition/seed_empty_tx | Bin 149 -> 149 bytes .../fuzz_stateless_verification/seed_empty_tx | Bin 149 -> 149 bytes .../fuzz_transaction_decoding/seed_empty_tx | Bin 149 -> 149 bytes fuzz/Cargo.toml | 6 + .../fuzz_transaction_ordering_independence.rs | 133 ++++++++++++++++ fuzz_props/src/privacy.rs | 86 +++++++++++ fuzz_props/src/tests/privacy.rs | 142 +++++++++++++++++- 9 files changed, 368 insertions(+), 2 deletions(-) create mode 100644 fuzz/fuzz_targets/fuzz_transaction_ordering_independence.rs diff --git a/.github/workflows/fuzz-afl.yml b/.github/workflows/fuzz-afl.yml index ce019cc3d..086e8a892 100644 --- a/.github/workflows/fuzz-afl.yml +++ b/.github/workflows/fuzz-afl.yml @@ -47,6 +47,7 @@ jobs: fuzz_encoding_privacy_preserving fuzz_nullifier_set_roundtrip fuzz_privacy_preserving_state_transition + fuzz_transaction_ordering_independence EOF ) echo "targets=$targets" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 94e8cf11e..47dc1dde6 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -49,6 +49,7 @@ jobs: - fuzz_encoding_privacy_preserving - fuzz_nullifier_set_roundtrip - fuzz_privacy_preserving_state_transition + - fuzz_transaction_ordering_independence steps: - uses: actions/checkout@v4 @@ -227,6 +228,7 @@ jobs: - fuzz_encoding_privacy_preserving - fuzz_nullifier_set_roundtrip - fuzz_privacy_preserving_state_transition + - fuzz_transaction_ordering_independence steps: - uses: actions/checkout@v4 - name: Checkout logos-execution-zone diff --git a/corpus/libfuzz/fuzz_state_transition/seed_empty_tx b/corpus/libfuzz/fuzz_state_transition/seed_empty_tx index b00ba8713272de3f59aa4066cc5913d33a92b319..61f4c0a635a9a36a5880da4fa4854c927d7a1479 100644 GIT binary patch delta 74 zcmV-Q0JZ;>0hIxeH9$p-1nJOBUy delta 74 zcmV-Q0JZ;>0hIxeH9!l+)sdoUDZ&WSRS3-J4>Ewra3cz|=KuR^5?xAQtr`K>z>% diff --git a/corpus/libfuzz/fuzz_stateless_verification/seed_empty_tx b/corpus/libfuzz/fuzz_stateless_verification/seed_empty_tx index b00ba8713272de3f59aa4066cc5913d33a92b319..61f4c0a635a9a36a5880da4fa4854c927d7a1479 100644 GIT binary patch delta 74 zcmV-Q0JZ;>0hIxeH9$p-1nJOBUy delta 74 zcmV-Q0JZ;>0hIxeH9!l+)sdoUDZ&WSRS3-J4>Ewra3cz|=KuR^5?xAQtr`K>z>% diff --git a/corpus/libfuzz/fuzz_transaction_decoding/seed_empty_tx b/corpus/libfuzz/fuzz_transaction_decoding/seed_empty_tx index b00ba8713272de3f59aa4066cc5913d33a92b319..61f4c0a635a9a36a5880da4fa4854c927d7a1479 100644 GIT binary patch delta 74 zcmV-Q0JZ;>0hIxeH9$p-1nJOBUy delta 74 zcmV-Q0JZ;>0hIxeH9!l+)sdoUDZ&WSRS3-J4>Ewra3cz|=KuR^5?xAQtr`K>z>% diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index ef906cdea..a138fde00 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -157,3 +157,9 @@ name = "fuzz_privacy_preserving_state_transition" path = "fuzz_targets/fuzz_privacy_preserving_state_transition.rs" test = false bench = false + +[[bin]] +name = "fuzz_transaction_ordering_independence" +path = "fuzz_targets/fuzz_transaction_ordering_independence.rs" +test = false +bench = false diff --git a/fuzz/fuzz_targets/fuzz_transaction_ordering_independence.rs b/fuzz/fuzz_targets/fuzz_transaction_ordering_independence.rs new file mode 100644 index 000000000..9504b8059 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_transaction_ordering_independence.rs @@ -0,0 +1,133 @@ +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] +//! Fuzz target: transaction **ordering-independence** for the shielded (privacy-preserving) +//! path — the property no other target asserts. +//! +//! Every other target applies transactions in a single fixed order and checks the *result*. +//! None asks whether the *order* of two transactions can change which ones are accepted. This +//! target does: it applies a nullifier-conflicting pair in both orders on independent copies +//! of the same base state and asserts the outcome is order-independent. +//! +//! # Why the shielded path (and not native transfers) +//! +//! The original `fuzz_transaction_non_interference` proposal aimed this idea at native +//! transfers, but `V03State`'s only cross-transaction state is the append-only +//! `(CommitmentSet, NullifierSet)`; two disjoint public transfers touch only per-account maps +//! and commute trivially, so that target would be permanently green. The nullifier set is the +//! real shared state — it is what prevents double-spends — so that is where ordering can +//! actually matter. +//! +//! # The oracle +//! +//! `arb_conflicting_nullifier_pair` builds two *distinct* transactions `B` and `C` that +//! declare the **same** nullifier. A correct state machine enforces first-come-first-served: +//! whichever is applied first spends the nullifier, and the second is rejected at +//! `check_nullifiers_are_valid`. Both orderings are applied at an **identical** `(block_id, +//! timestamp)` so the *only* difference between them is transaction order — fixing a flaw in +//! the original sketch, which varied the clock per position and so could not distinguish +//! ordering effects from validity-window effects. +//! +//! Note on scope: this nullifier check is enforced by the *state machine*, not by the ZK +//! circuit, so the dev-mode synthesised proof (which bypasses the circuit) does **not** mask +//! it — unlike balance conservation, which this path deliberately never asserts. +//! +//! Requires `RISC0_DEV_MODE=1` (set by every `just fuzz` recipe) for the synthesised proofs. +//! +//! # Invariants asserted +//! +//! * **NoDoubleSpend** — in neither ordering are both conflicting transactions accepted; a +//! shared nullifier is spendable at most once. A violation is a literal double-spend. +//! * **OrderIndependentAcceptance** — the number of transactions accepted from the pair is the +//! same in both orderings. A violation means acceptance leaks across transactions depending +//! on order (order-dependent interference through global state). + +use arbitrary::{Arbitrary, Unstructured}; +use common::transaction::LeeTransaction; +use fuzz_props::generators::arbitrary_fuzz_state; +use fuzz_props::privacy::{arb_conflicting_nullifier_pair, arb_privacy_preserving_tx}; +use nssa::{AccountId, PrivacyPreservingTransaction}; + +/// Apply the production stateless gate and wrap for execution; `None` drops the input. +fn gate(tx: PrivacyPreservingTransaction) -> Option { + LeeTransaction::PrivacyPreserving(tx) + .transaction_stateless_check() + .ok() +} + +fuzz_props::fuzz_entry!(|data: &[u8]| { + let mut u = Unstructured::new(data); + + // Need at least two keyed accounts so the conflicting pair can use distinct signers. + let fuzz_accs = match arbitrary_fuzz_state(&mut u) { + Ok(accs) if accs.len() >= 2 => accs, + _ => return, + }; + let init_accs: Vec<(AccountId, u128)> = fuzz_accs + .iter() + .map(|a| (a.account_id, a.balance)) + .collect(); + let mut base = fuzz_props::genesis::genesis_state(&init_accs, vec![]); + + // ── Seed the commitment set ────────────────────────────────────────────────────────── + // A nullifier only passes check 6 when its digest is in `root_history`, which starts empty + // and is seeded only once a commitment-bearing transaction applies. Reuse the + // proven-reachable generator to grow it; individual outcomes don't matter here. + let n_seed: u8 = u8::arbitrary(&mut u).unwrap_or(0) % 4; + for i in 0..n_seed { + let Ok(tx) = arb_privacy_preserving_tx(&mut u, &base, &fuzz_accs) else { + break; + }; + let Some(lee) = gate(tx) else { continue }; + let _ = lee.execute_check_on_state(&mut base, 1 + u64::from(i), u64::from(i)); + } + + // ── Build the nullifier-conflicting pair against the seeded base ───────────────────── + let Ok((tx_b, tx_c)) = arb_conflicting_nullifier_pair(&mut u, &base, &fuzz_accs) else { + return; + }; + + // Two independent instances of each so both orderings get a fresh, un-consumed copy. + let (Some(b1), Some(c1)) = (gate(tx_b.clone()), gate(tx_c.clone())) else { + return; + }; + let (Some(b2), Some(c2)) = (gate(tx_b), gate(tx_c)) else { + return; + }; + + // Identical clock for both orderings: the sole difference is transaction order. The pair's + // validity windows are unbounded, so the specific values are immaterial. + const BLOCK: u64 = 1; + const TS: u64 = 0; + + // ── Order 1: B → C ─────────────────────────────────────────────────────────────────── + let mut s_bc = base.clone(); + let rb1 = b1.execute_check_on_state(&mut s_bc, BLOCK, TS).is_ok(); + let rc1 = c1.execute_check_on_state(&mut s_bc, BLOCK, TS).is_ok(); + + // ── Order 2: C → B ─────────────────────────────────────────────────────────────────── + let mut s_cb = base.clone(); + let rc2 = c2.execute_check_on_state(&mut s_cb, BLOCK, TS).is_ok(); + let rb2 = b2.execute_check_on_state(&mut s_cb, BLOCK, TS).is_ok(); + + // ── INVARIANT [NoDoubleSpend] ──────────────────────────────────────────────────────── + // The shared nullifier must be spendable at most once, in either order. + assert!( + !(rb1 && rc1), + "INVARIANT VIOLATION [NoDoubleSpend]: both conflicting transactions accepted in order \ + B→C — the shared nullifier was double-spent" + ); + assert!( + !(rc2 && rb2), + "INVARIANT VIOLATION [NoDoubleSpend]: both conflicting transactions accepted in order \ + C→B — the shared nullifier was double-spent" + ); + + // ── INVARIANT [OrderIndependentAcceptance] ─────────────────────────────────────────── + // The count of accepted transactions from the pair must not depend on ordering. + let accepted_bc = u8::from(rb1) + u8::from(rc1); + let accepted_cb = u8::from(rb2) + u8::from(rc2); + assert_eq!( + accepted_bc, accepted_cb, + "INVARIANT VIOLATION [OrderIndependentAcceptance]: {accepted_bc} of the pair accepted \ + as B→C but {accepted_cb} as C→B — transaction acceptance is order-dependent", + ); +}); diff --git a/fuzz_props/src/privacy.rs b/fuzz_props/src/privacy.rs index f88923a78..8b1f46594 100644 --- a/fuzz_props/src/privacy.rs +++ b/fuzz_props/src/privacy.rs @@ -321,3 +321,89 @@ pub fn arb_privacy_preserving_tx( let witness_set = PPWitnessSet::for_message(&message, proof, &keys); Ok(PrivacyPreservingTransaction::new(message, witness_set)) } + +/// Build a minimal *pure-private* transaction: one signer, no public accounts, the given +/// commitments and nullifiers, and unbounded validity windows. +/// +/// Two properties matter for the ordering-independence oracle in +/// `fuzz_transaction_ordering_independence`: +/// +/// * **`public_account_ids` is empty**, so `synthesize_passing_proof` reconstructs an empty +/// `public_pre_states` and the journal does not depend on live chain state. The proof +/// therefore stays valid at check 4 even after another transaction has mutated the state — +/// i.e. it is valid whether this tx is applied *first* or *second*. That is what lets us +/// apply the same transaction in both orderings and compare outcomes soundly. +/// * The single signer's nonce is read live from `state`, so check 3c passes by construction +/// at first application; because the paired transaction uses a *different* signer, this +/// signer's nonce is untouched when this tx is applied second — so any rejection of the +/// second application is attributable to the shared nullifier, not to a nonce mismatch. +fn build_pure_private_tx( + state: &V03State, + key: &PrivateKey, + new_commitments: Vec, + new_nullifiers: Vec<(Nullifier, CommitmentSetDigest)>, +) -> PrivacyPreservingTransaction { + let signer_id = account_id_for_key(key); + let message = PPMessage { + public_account_ids: Vec::new(), + nonces: vec![state.get_account_by_id(signer_id).nonce], + public_post_states: Vec::new(), + encrypted_private_post_states: Vec::new(), + new_commitments, + new_nullifiers, + block_validity_window: ValidityWindow::new_unbounded(), + timestamp_validity_window: ValidityWindow::new_unbounded(), + }; + let proof = synthesize_passing_proof(&message, state, &[signer_id]); + let witness_set = PPWitnessSet::for_message(&message, proof, &[key]); + PrivacyPreservingTransaction::new(message, witness_set) +} + +/// Build a **nullifier-conflicting pair**: two *distinct* privacy-preserving transactions +/// (different signers, different fresh commitments) that both declare the **same** nullifier. +/// +/// The shared nullifier's digest is bound to the current commitment-set root +/// (`state.commitment_set_digest()`); this only satisfies check 6's `root_history` membership +/// once a commitment-bearing transaction has already grown the set, so callers should seed the +/// state first (see the target). The two transactions are otherwise independently valid, so a +/// correct state machine must accept *at most one* of them regardless of application order — +/// the property the ordering-independence target asserts. +/// +/// Returns `Err` when there are fewer than two distinct keyed accounts to draw signers from. +pub fn arb_conflicting_nullifier_pair( + u: &mut Unstructured<'_>, + state: &V03State, + accounts: &[FuzzAccount], +) -> ArbResult<(PrivacyPreservingTransaction, PrivacyPreservingTransaction)> { + if accounts.len() < 2 { + return Err(arbitrary::Error::IncorrectFormat); + } + // Two distinct signer accounts so the pair's nonce checks are independent. + let i = (u8::arbitrary(u)? as usize) % accounts.len(); + let mut j = (u8::arbitrary(u)? as usize) % accounts.len(); + if j == i { + j = (i + 1) % accounts.len(); + } + let key_b = &accounts[i].private_key; + let key_c = &accounts[j].private_key; + if account_id_for_key(key_b) == account_id_for_key(key_c) { + return Err(arbitrary::Error::IncorrectFormat); + } + + // One nullifier, shared by both transactions, bound to a historical commitment-set root. + let root = state.commitment_set_digest(); + let null_aid = AccountId::new(<[u8; 32]>::arbitrary(u)?); + let shared_nullifiers = vec![(Nullifier::for_account_initialization(&null_aid), root)]; + + // Distinct fresh commitments make the two transactions genuinely different (and keep them + // from colliding with each other on check 5). + let comm_b = Commitment::new(&AccountId::new(<[u8; 32]>::arbitrary(u)?), &arb_account(u)?); + let comm_c = Commitment::new(&AccountId::new(<[u8; 32]>::arbitrary(u)?), &arb_account(u)?); + if comm_b == comm_c { + return Err(arbitrary::Error::IncorrectFormat); + } + + let tx_b = build_pure_private_tx(state, key_b, vec![comm_b], shared_nullifiers.clone()); + let tx_c = build_pure_private_tx(state, key_c, vec![comm_c], shared_nullifiers); + Ok((tx_b, tx_c)) +} diff --git a/fuzz_props/src/tests/privacy.rs b/fuzz_props/src/tests/privacy.rs index 2bbde6676..d8317595a 100644 --- a/fuzz_props/src/tests/privacy.rs +++ b/fuzz_props/src/tests/privacy.rs @@ -1,8 +1,9 @@ use arbitrary::Unstructured; -use crate::generators::FuzzAccount; +use crate::generators::{FuzzAccount, account_id_for_key}; use crate::privacy::{ - arb_account, arb_privacy_preserving_tx, arb_validity_window, synthesize_passing_proof, + arb_account, arb_conflicting_nullifier_pair, arb_privacy_preserving_tx, arb_validity_window, + synthesize_passing_proof, }; use nssa::privacy_preserving_transaction::{Message as PPMessage, WitnessSet as PPWitnessSet}; use nssa::{AccountId, PrivacyPreservingTransaction, PrivateKey}; @@ -478,3 +479,140 @@ fn arb_privacy_preserving_tx_generator_invariants() { "garbage-proof rate {garbage}/{oks} is below 1/16 (expected ~1/8)" ); } + +// ── 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 +// attributable to the shared nullifier, not a nonce clash) and index only within the account +// set. These tests pin the account-count guard, the collision-repair branch, and the modular +// index arithmetic. + +/// `n` distinct keyed fuzz accounts (`[1; 32]`, `[2; 32]`, …). Distinct nonzero scalars give +/// distinct keys and therefore distinct key-derived signer ids. +fn keyed_accounts(n: u8) -> Vec { + (1..=n) + .map(|i| FuzzAccount { + account_id: AccountId::new([i; 32]), + balance: 1_000_000, + private_key: PrivateKey::try_new([i; 32]).expect("nonzero scalar is a valid key"), + }) + .collect() +} + +/// The key-derived signer ids the validator would recover from a transaction's witness set. +fn signer_ids(tx: &PrivacyPreservingTransaction) -> Vec { + tx.witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| AccountId::from(pk)) + .collect() +} + +/// The builder needs two distinct accounts: fewer than two is always rejected (before any +/// index arithmetic runs), exactly two always succeeds. This pins the `accounts.len() < 2` +/// guard against `==` / `>` / `<=` mutations. +#[test] +fn arb_conflicting_nullifier_pair_requires_two_accounts() { + let accounts = keyed_accounts(2); + let genesis: Vec<(AccountId, u128)> = + accounts.iter().map(|a| (a.account_id, a.balance)).collect(); + let state = crate::genesis::genesis_state(&genesis, vec![]); + + let mut rng = Rng::new(); + let mut buf = vec![0_u8; 512]; + rng.fill(&mut buf); + + // Zero accounts: rejected by the guard. A guard mutated to `== 2` / `> 2` would fall + // through and divide by `accounts.len() == 0`, panicking — also a failure this catches. + let mut u0 = Unstructured::new(&buf); + assert!( + arb_conflicting_nullifier_pair(&mut u0, &state, &[]).is_err(), + "an empty account set must be rejected" + ); + // One account: still rejected — there is no room for two distinct signers. + let mut u1 = Unstructured::new(&buf); + assert!( + arb_conflicting_nullifier_pair(&mut u1, &state, &accounts[..1]).is_err(), + "a single-account set must be rejected" + ); + // Two accounts: must succeed. A guard mutated to `== 2` / `<= 2` would reject this. + let mut u2 = Unstructured::new(&buf); + assert!( + arb_conflicting_nullifier_pair(&mut u2, &state, &accounts).is_ok(), + "two distinct accounts must yield a pair" + ); +} + +/// When both index bytes select the same account (`i == j`), the repair branch +/// `j = (i + 1) % len` must pick the *other* account so the pair keeps distinct signers. +/// Forcing `i == j == 0` over two accounts exercises that branch: the only correct outcome is +/// signers `{account 0, account 1}`. This pins the `j == i` test, the `(i + 1) % len` repair, +/// and the two distinctness guards (`== 2` mutations of them all reject this otherwise-valid +/// input, and the arithmetic mutations either repair to `j == i` again or index out of range). +#[test] +fn arb_conflicting_nullifier_pair_repairs_colliding_indices() { + let accounts = keyed_accounts(2); + let genesis: Vec<(AccountId, u128)> = + accounts.iter().map(|a| (a.account_id, a.balance)).collect(); + let state = crate::genesis::genesis_state(&genesis, vec![]); + + let mut rng = Rng::new(); + let mut buf = vec![0_u8; 512]; + rng.fill(&mut buf); + // The first two bytes are the `i` and `j` index draws; zero both so `i == j == 0`. + buf[0] = 0; + buf[1] = 0; + + let mut u = Unstructured::new(&buf); + let (tx_b, tx_c) = arb_conflicting_nullifier_pair(&mut u, &state, &accounts) + .expect("colliding indices must be repaired into two distinct signers, not rejected"); + + let sb = signer_ids(&tx_b); + let sc = signer_ids(&tx_c); + assert_eq!(sb.len(), 1, "tx_b must carry exactly one signer"); + assert_eq!(sc.len(), 1, "tx_c must carry exactly one signer"); + assert_ne!( + sb[0], sc[0], + "a conflicting pair must use two distinct signers" + ); + + let known: std::collections::HashSet = accounts + .iter() + .map(|a| account_id_for_key(&a.private_key)) + .collect(); + assert!( + known.contains(&sb[0]) && known.contains(&sc[0]), + "both signers must be drawn from the account set" + ); +} + +/// Over many random inputs the index arithmetic must stay within the account slice. A `% len` +/// mutated to `/ len` or `+ len` computes an out-of-range index and panics on `accounts[i]`; +/// the modulo keeps every draw in range and the two signers distinct. +#[test] +fn arb_conflicting_nullifier_pair_indexes_in_range() { + let accounts = keyed_accounts(2); + let genesis: Vec<(AccountId, u128)> = + accounts.iter().map(|a| (a.account_id, a.balance)).collect(); + let state = crate::genesis::genesis_state(&genesis, vec![]); + let known: std::collections::HashSet = accounts + .iter() + .map(|a| account_id_for_key(&a.private_key)) + .collect(); + + let mut rng = Rng::new(); + let mut buf = vec![0_u8; 512]; + for _ in 0..500_usize { + rng.fill(&mut buf); + let mut u = Unstructured::new(&buf); + let (tx_b, tx_c) = arb_conflicting_nullifier_pair(&mut u, &state, &accounts) + .expect("two distinct accounts always yield a pair"); + let sb = signer_ids(&tx_b); + let sc = signer_ids(&tx_c); + assert_ne!(sb[0], sc[0], "conflicting-pair signers must be distinct"); + assert!( + known.contains(&sb[0]) && known.contains(&sc[0]), + "signers must be drawn from the account set" + ); + } +}