test: transaction ordering independence

This commit is contained in:
Roman 2026-07-02 13:55:36 +02:00
parent 687d1152ed
commit 0f4cf52491
No known key found for this signature in database
GPG Key ID: 583BDF43C238B83E
9 changed files with 368 additions and 2 deletions

View File

@ -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"

View File

@ -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

Binary file not shown.

View File

@ -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

View File

@ -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> {
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 \
BC the shared nullifier was double-spent"
);
assert!(
!(rc2 && rb2),
"INVARIANT VIOLATION [NoDoubleSpend]: both conflicting transactions accepted in order \
CB 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 BC but {accepted_cb} as CB transaction acceptance is order-dependent",
);
});

View File

@ -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<Commitment>,
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))
}

View File

@ -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<FuzzAccount> {
(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<AccountId> {
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<AccountId> = 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<AccountId> = 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"
);
}
}