diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 3d8a310..413470e 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -2084,7 +2084,6 @@ dependencies = [ "lee", "lee_core", "libfuzzer-sys", - "sha2", "testnet_initial_state", ] diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 762b496..4b61548 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -44,7 +44,6 @@ libfuzzer-sys = { version = "0.4", optional = true } afl = { version = "0.15", optional = true } arbitrary = { version = "1", features = ["derive"] } borsh = "1" -sha2 = "0.10" nssa = { path = "../../logos-execution-zone/lee/state_machine", package = "lee" } nssa_core = { path = "../../logos-execution-zone/lee/state_machine/core", package = "lee_core" } common = { path = "../../logos-execution-zone/lez/common" } @@ -132,3 +131,39 @@ name = "fuzz_genesis_invariants" path = "fuzz_targets/fuzz_genesis_invariants.rs" test = false bench = false + +[[bin]] +name = "fuzz_common_invariants" +path = "fuzz_targets/fuzz_common_invariants.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_transaction_properties" +path = "fuzz_targets/fuzz_transaction_properties.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_privacy_preserving_witness" +path = "fuzz_targets/fuzz_privacy_preserving_witness.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_encoding_privacy_preserving" +path = "fuzz_targets/fuzz_encoding_privacy_preserving.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_nullifier_set_roundtrip" +path = "fuzz_targets/fuzz_nullifier_set_roundtrip.rs" +test = false +bench = false + +[[bin]] +name = "fuzz_system_account_protection" +path = "fuzz_targets/fuzz_system_account_protection.rs" +test = false +bench = false diff --git a/fuzz/fuzz_targets/fuzz_common_invariants.rs b/fuzz/fuzz_targets/fuzz_common_invariants.rs new file mode 100644 index 0000000..d4337ba --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_common_invariants.rs @@ -0,0 +1,171 @@ +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] +//! Fuzz target: common-crate and low-level type invariants. +//! +//! This target is **input-independent**: the fuzz input is always ignored. +//! It asserts deterministic invariants about types in `lez/common` and +//! low-level `lee` types that are not exercised by higher-level state-transition +//! targets. +//! +//! # Corpus note +//! +//! A single `\x00` seed file is sufficient — the input bytes are never read. + +use common::{HashType, config::BasicAuth}; +use nssa::{ + privacy_preserving_transaction::circuit::Proof, + program::Program, + program_deployment_transaction::Message as DeployMessage, + program_methods::{ + AUTHENTICATED_TRANSFER_ELF, TOKEN_ELF, + }, +}; + +fuzz_props::fuzz_entry!(|_data: &[u8]| { + // ── INVARIANT [HashTypeAsRefLength] ──────────────────────────────────────── + // `HashType::as_ref()` must always return exactly 32 bytes. + // Catches mutations that return an empty slice or a slice of the wrong size. + let all_ones = HashType([1_u8; 32]); + assert_eq!( + all_ones.as_ref().len(), + 32, + "INVARIANT VIOLATION [HashTypeAsRefLength]: HashType::as_ref must return 32 bytes", + ); + + let zero = HashType::default(); + assert_eq!( + zero.as_ref().len(), + 32, + "INVARIANT VIOLATION [HashTypeAsRefLength]: HashType::as_ref on default must return 32 bytes", + ); + + // ── INVARIANT [HashTypeAsRefBytes] ──────────────────────────────────────── + // `HashType::as_ref()` must return the exact inner bytes. + // Catches mutations that return `vec![0]` or `vec![1]` instead of `&self.0`. + let known = [0x42_u8; 32]; + let hash = HashType(known); + assert_eq!( + hash.as_ref(), + &known, + "INVARIANT VIOLATION [HashTypeAsRefBytes]: HashType::as_ref must return the inner [u8;32]", + ); + + // ── INVARIANT [BasicAuthPasswordPreserved] ─────────────────────────────── + // Parsing "user:password" must preserve the non-empty password as `Some`. + // Catches the mutation that deletes `!` in the `.filter(|p| !p.is_empty())` + // predicate, which would flip the logic and accept only empty passwords. + let auth: BasicAuth = "user:secret" + .parse() + .expect("INVARIANT VIOLATION: 'user:secret' must parse as BasicAuth"); + assert_eq!( + auth.password.as_deref(), + Some("secret"), + "INVARIANT VIOLATION [BasicAuthPasswordPreserved]: \ + parsing 'user:secret' must give password = Some(\"secret\")", + ); + + let auth2: BasicAuth = "alice:hunter2" + .parse() + .expect("INVARIANT VIOLATION: 'alice:hunter2' must parse"); + assert_eq!( + auth2.password.as_deref(), + Some("hunter2"), + "INVARIANT VIOLATION [BasicAuthPasswordPreserved]: \ + password must match the part after the colon", + ); + + // ── INVARIANT [BasicAuthEmptyPasswordIsNone] ───────────────────────────── + // Parsing "user:" (empty password) must give `password = None`. + // With the `!` deleted, this would become `Some("")` instead of `None`. + let auth_empty: BasicAuth = "user:" + .parse() + .expect("INVARIANT VIOLATION: 'user:' must parse as BasicAuth"); + assert_eq!( + auth_empty.password, + None, + "INVARIANT VIOLATION [BasicAuthEmptyPasswordIsNone]: \ + an empty password (trailing colon) must give password = None", + ); + + // ── INVARIANT [ProgramElfNonEmpty] ─────────────────────────────────────── + // `Program::elf()` must return a non-empty byte slice. + // Catches the mutation that returns `Vec::leak(Vec::new())`. + let at_prog = Program::authenticated_transfer_program(); + assert!( + !at_prog.elf().is_empty(), + "INVARIANT VIOLATION [ProgramElfNonEmpty]: \ + Program::authenticated_transfer_program().elf() must not be empty", + ); + + let token_prog = Program::token(); + assert!( + !token_prog.elf().is_empty(), + "INVARIANT VIOLATION [ProgramElfNonEmpty]: \ + Program::token().elf() must not be empty", + ); + + // ── INVARIANT [ProgramElfCorrect] ──────────────────────────────────────── + // `Program::elf()` must return exactly the compile-time bytecode constant. + // Catches the mutations that return `vec![0]` or `vec![1]`. + assert_eq!( + at_prog.elf(), + AUTHENTICATED_TRANSFER_ELF, + "INVARIANT VIOLATION [ProgramElfCorrect]: \ + Program::authenticated_transfer_program().elf() must equal AUTHENTICATED_TRANSFER_ELF", + ); + + assert_eq!( + token_prog.elf(), + TOKEN_ELF, + "INVARIANT VIOLATION [ProgramElfCorrect]: \ + Program::token().elf() must equal TOKEN_ELF", + ); + + // ── INVARIANT [ProofIntoInnerRoundtrip] ────────────────────────────────── + // `Proof::from_inner(bytes).into_inner()` must return the original bytes. + // Catches the mutations that return `vec![]`, `vec![0]`, or `vec![1]`. + let proof_bytes = vec![0xDE_u8, 0xAD, 0xBE, 0xEF]; + let proof = Proof::from_inner(proof_bytes.clone()); + assert_eq!( + proof.into_inner(), + proof_bytes, + "INVARIANT VIOLATION [ProofIntoInnerRoundtrip]: \ + Proof::from_inner(b).into_inner() must return b", + ); + + // Also test with an empty proof (round-trip must preserve emptiness). + let empty_proof = Proof::from_inner(vec![]); + assert!( + empty_proof.into_inner().is_empty(), + "INVARIANT VIOLATION [ProofIntoInnerRoundtrip]: \ + empty Proof::from_inner(vec![]).into_inner() must be empty", + ); + + // And with a single non-zero byte: + let single = Proof::from_inner(vec![0xFF]); + assert_eq!( + single.into_inner(), + vec![0xFF_u8], + "INVARIANT VIOLATION [ProofIntoInnerRoundtrip]: \ + Proof from single byte must round-trip correctly", + ); + + // ── INVARIANT [DeployMessageBytecodeRoundtrip] ──────────────────────────── + // `Message::new(bytecode).into_bytecode()` must return the original bytecode. + // Catches the mutations that return `vec![]`, `vec![0]`, or `vec![1]`. + let bytecode = vec![0x7F_u8, 0x45, 0x4C, 0x46]; // ELF magic + let msg = DeployMessage::new(bytecode.clone()); + assert_eq!( + msg.into_bytecode(), + bytecode, + "INVARIANT VIOLATION [DeployMessageBytecodeRoundtrip]: \ + Message::new(b).into_bytecode() must return b", + ); + + // Empty bytecode round-trip: + let empty_msg = DeployMessage::new(vec![]); + assert!( + empty_msg.into_bytecode().is_empty(), + "INVARIANT VIOLATION [DeployMessageBytecodeRoundtrip]: \ + empty bytecode must round-trip as empty", + ); +}); diff --git a/fuzz/fuzz_targets/fuzz_encoding_privacy_preserving.rs b/fuzz/fuzz_targets/fuzz_encoding_privacy_preserving.rs new file mode 100644 index 0000000..26defc5 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_encoding_privacy_preserving.rs @@ -0,0 +1,203 @@ +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] +//! Fuzz target: privacy-preserving encoding invariants. +//! +//! Tests that `to_bytes` / `from_bytes` round-trips work correctly for the +//! privacy-preserving `Message` type, and that `try_from_circuit_output` +//! validates ciphertext-to-key length matching. +//! +//! `PrivacyPreservingTransaction` is also tested for serialisation stability +//! (non-empty, deterministic bytes) without requiring a real ZK receipt. + +use nssa::{ + AccountId, PrivateKey, PublicKey, + PrivacyPreservingTransaction, + privacy_preserving_transaction::{ + Message as PPMessage, + WitnessSet as PPWitnessSet, + circuit::Proof, + }, +}; +use nssa_core::{ + PrivacyPreservingCircuitOutput, + account::Nonce, + program::{BlockValidityWindow, TimestampValidityWindow}, +}; + +/// Build a minimal `Message` with no private state. +fn minimal_message() -> PPMessage { + let addr = AccountId::from( + &PublicKey::new_from_private_key( + &PrivateKey::try_new([1_u8; 32]).expect("known-good"), + ), + ); + PPMessage { + public_account_ids: vec![addr], + nonces: vec![Nonce::from(0_u128)], + public_post_states: vec![], + encrypted_private_post_states: vec![], + new_commitments: vec![], + new_nullifiers: vec![], + block_validity_window: BlockValidityWindow::new_unbounded(), + timestamp_validity_window: TimestampValidityWindow::new_unbounded(), + } +} + +fuzz_props::fuzz_entry!(|data: &[u8]| { + // ── INVARIANT [MessageEncodingRoundtrip] ────────────────────────────────── + // `Message::to_bytes()` followed by `Message::from_bytes()` must reproduce + // the original message. Catches mutations that return `vec![]`, `vec![0]`, + // or `vec![1]` — these break round-trip identity. + { + let msg = minimal_message(); + let encoded = msg.to_bytes(); + + // Non-empty: catches `→ vec![]` + assert!( + !encoded.is_empty(), + "INVARIANT VIOLATION [MessageEncodingRoundtrip]: \ + Message::to_bytes must not return an empty vec", + ); + + let decoded = PPMessage::from_bytes(&encoded) + .expect("INVARIANT VIOLATION [MessageEncodingRoundtrip]: \ + from_bytes(to_bytes(msg)) must succeed"); + + let re_encoded = decoded.to_bytes(); + assert_eq!( + encoded, + re_encoded, + "INVARIANT VIOLATION [MessageEncodingRoundtrip]: \ + encode(decode(encode(msg))) != encode(msg)", + ); + } + + // ── INVARIANT [TxEncodingNonEmpty] / [TxEncodingDeterministic] ──────────── + // `PrivacyPreservingTransaction::to_bytes()` must return a non-empty byte + // slice and be deterministic. Catches mutations that return `vec![]` etc. + { + let key = PrivateKey::try_new([1_u8; 32]).expect("known-good"); + let msg = minimal_message(); + let proof = Proof::from_inner(vec![0xDE_u8, 0xAD, 0xBE, 0xEF]); + let ws = PPWitnessSet::for_message(&msg, proof, &[&key]); + let tx = PrivacyPreservingTransaction::new(msg, ws); + + let bytes1 = tx.to_bytes(); + assert!( + !bytes1.is_empty(), + "INVARIANT VIOLATION [TxEncodingNonEmpty]: \ + PrivacyPreservingTransaction::to_bytes must not be empty", + ); + + let bytes2 = tx.to_bytes(); + assert_eq!( + bytes1, + bytes2, + "INVARIANT VIOLATION [TxEncodingDeterministic]: \ + to_bytes must be deterministic — called twice, got different results", + ); + + // Verify round-trip for the full transaction: + let decoded = PrivacyPreservingTransaction::from_bytes(&bytes1) + .expect("INVARIANT VIOLATION: round-trip decode must succeed"); + assert_eq!( + bytes1, + decoded.to_bytes(), + "INVARIANT VIOLATION [TxEncodingDeterministic]: \ + encode(decode(encode(tx))) != encode(tx)", + ); + } + + // ── INVARIANT [LengthMatchAccepted] ─────────────────────────────────────── + // When public_keys.len() == ciphertexts.len() == 0, `try_from_circuit_output` + // must succeed. + // + // Original check: `if public_keys.len() != output.ciphertexts.len() { Err }` + // With mutation `!=` → `==`: `if 0 == 0` → `true` → Err is returned. + // Our assertion that the call SUCCEEDS catches the mutation. + { + let empty_output = PrivacyPreservingCircuitOutput { + public_pre_states: vec![], + public_post_states: vec![], + new_commitments: vec![], + new_nullifiers: vec![], + ciphertexts: vec![], + block_validity_window: BlockValidityWindow::new_unbounded(), + timestamp_validity_window: TimestampValidityWindow::new_unbounded(), + }; + + let result = PPMessage::try_from_circuit_output( + vec![], // public_account_ids + vec![], // nonces + vec![], // public_keys (0 entries) + empty_output, + ); + assert!( + result.is_ok(), + "INVARIANT VIOLATION [LengthMatchAccepted]: \ + try_from_circuit_output must accept when keys(0) == ciphertexts(0), \ + got: {:?} — \ + possible mutation: != changed to == in the length check", + result.err(), + ); + } + + // ── Raw fuzz decode tests ───────────────────────────────────────────────── + // Fuzz the Message decoder for no-panic and canonical round-trip. + { + // No-panic on arbitrary bytes: + let _ = PPMessage::from_bytes(data); + + // Canonical round-trip: if fuzz bytes decode, re-encoding must reproduce them. + if let Ok(msg) = PPMessage::from_bytes(data) { + let re_encoded = msg.to_bytes(); + assert_eq!( + data, + re_encoded.as_slice(), + "INVARIANT VIOLATION: PP Message decoded from raw bytes but \ + re-encoding differs (non-canonical encoding accepted)", + ); + } + } + + // ── Varied-size message round-trips ────────────────────────────────────── + // Verify round-trip for several multi-account messages. + for n_accounts in [0, 1, 2, 3] { + let mut account_ids = Vec::new(); + let mut nonces = Vec::new(); + for i in 0..n_accounts { + let key_bytes = [i + 1_u8; 32]; + if let Ok(key) = PrivateKey::try_new(key_bytes) { + let pk = PublicKey::new_from_private_key(&key); + account_ids.push(AccountId::from(&pk)); + nonces.push(Nonce::from(i as u128)); + } + } + + let msg = PPMessage { + public_account_ids: account_ids, + nonces, + public_post_states: vec![], + encrypted_private_post_states: vec![], + new_commitments: vec![], + new_nullifiers: vec![], + block_validity_window: BlockValidityWindow::new_unbounded(), + timestamp_validity_window: TimestampValidityWindow::new_unbounded(), + }; + + let encoded = msg.to_bytes(); + assert!( + !encoded.is_empty(), + "INVARIANT VIOLATION [MessageEncodingRoundtrip]: \ + Message::to_bytes must not be empty for a {n_accounts}-account message", + ); + + let decoded = PPMessage::from_bytes(&encoded) + .expect("round-trip must succeed for well-formed message"); + assert_eq!( + encoded, + decoded.to_bytes(), + "INVARIANT VIOLATION [MessageEncodingRoundtrip]: \ + round-trip failed for {n_accounts}-account message", + ); + } +}); diff --git a/fuzz/fuzz_targets/fuzz_merkle_tree.rs b/fuzz/fuzz_targets/fuzz_merkle_tree.rs index a88249b..3e54be6 100644 --- a/fuzz/fuzz_targets/fuzz_merkle_tree.rs +++ b/fuzz/fuzz_targets/fuzz_merkle_tree.rs @@ -1,130 +1,136 @@ #![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] -//! Fuzz target: `MerkleTree` structural invariants +//! Fuzz target: Merkle-tree structural invariants, exercised through the +//! **public** commitment-set API (no `pub mod merkle_tree` patch required). //! -//! Covered code paths (all in `lee/state_machine/src/merkle_tree/mod.rs`): +//! The commitment set in `V03State` is a thin wrapper around the internal +//! `MerkleTree`: //! //! ```text -//! MerkleTree::with_capacity(1) ← initial capacity forces reallocate_to_double_capacity -//! MerkleTree::insert(value) ← per-value; also triggers reallocate_to_double_capacity -//! MerkleTree::root() ← sampled once after all inserts -//! MerkleTree::get_authentication_path_for(index) ← per-value -//! prev_power_of_two ← exercised inside reallocate_to_double_capacity +//! V03State::commitment_set_digest() → MerkleTree::root() (→ root_index) +//! V03State::get_proof_for_commitment(c) → (index, MerkleTree::get_authentication_path_for(index)) +//! CommitmentSet::extend(commitments) → MerkleTree::insert(value) per commitment +//! compute_digest_for_path(c, proof) → canonical leaf→root recomputation //! ``` //! +//! Inserting commitments via `V03State::new_with_genesis_accounts` therefore +//! drives `insert`, `root`/`root_index`, `get_authentication_path_for`, `depth`, +//! `get_node`/`set_node`, and — once the count exceeds the genesis capacity (32) +//! — `reallocate_to_double_capacity` and `prev_power_of_two`. +//! +//! Because the genesis commitment set has a fixed capacity of 32, a *small* +//! number of commitments exercises the partial-fill regime (`depth < +//! capacity_depth`, i.e. `root_index`'s else-branch), while a *large* number +//! (> 31) forces one or more reallocations. A single target therefore covers +//! both regimes — the committed corpus carries a small partial-fill seed +//! (`seed_partial6`) and a large reallocation seed (`seed_realloc40`). +//! //! # Input format //! -//! The raw fuzz bytes are sliced into 32-byte chunks; each chunk becomes one -//! value inserted into the tree. This makes the format trivial to reason about -//! and lets us seed the corpus with well-known test vectors. +//! Each 32-byte chunk of the fuzz input is reinterpreted as an `AccountId`, from +//! which a distinct `Commitment` is derived (`Commitment::new`). Duplicate +//! chunks are dropped so every inserted commitment is unique and lands at a +//! distinct, sequential tree index. The number of distinct chunks selects the +//! fill regime (partial-fill vs. reallocation). //! -//! # Invariants checked +//! # Invariants //! -//! 1. **InsertionIndex** — `insert(value)` returns the sequential 0-based index. -//! 2. **AuthPathSome** — `get_authentication_path_for(i)` is `Some` for every -//! `i < length`. -//! 3. **AuthPathValid** — every returned path re-hashes (SHA-256, same hash -//! functions used by the production code) to the value reported by `root()`. -//! 4. **OutOfBoundsNone** — `get_authentication_path_for(length)` returns `None`. +//! 1. **ProofSome** — every inserted commitment has a membership proof. +//! 2. **ProofValid** — `compute_digest_for_path(commitment, proof)` reproduces +//! `commitment_set_digest()` for every inserted commitment. This is the core +//! check: it independently recomputes the root from the leaf + authentication +//! path and compares against the tree's reported root, catching arithmetic +//! bugs in `root_index`, `insert`, and the path-walk. +//! 3. **IndicesSequential** — the genesis dummy commitment occupies index 0, so +//! `N` distinct user commitments must occupy exactly indices `1..=N`. Catches +//! `insert -> 0` / `insert -> 1` return-value mutations. +//! 4. **NonMembershipNone** — a commitment that was never inserted has no proof. -use sha2::{Digest as _, Sha256}; +use std::collections::HashSet; -// ─── Reference hash helpers (mirrors the private functions in merkle_tree/mod.rs) ─── - -/// SHA-256 of a single 32-byte leaf value. Mirrors `hash_value`. -fn sha256_one(v: &[u8; 32]) -> [u8; 32] { - let mut h = Sha256::new(); - h.update(v); - h.finalize().into() -} - -/// SHA-256 of two concatenated 32-byte nodes. Mirrors `hash_two`. -fn sha256_two(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { - let mut h = Sha256::new(); - h.update(left); - h.update(right); - h.finalize().into() -} - -/// Reference implementation of authentication-path verification. -/// -/// Mirrors `verify_authentication_path` from the test module inside -/// `lee/state_machine/src/merkle_tree/mod.rs`. -/// -/// Algorithm: -/// result ← SHA-256(value) -/// for each sibling in path: -/// if level_index is even → result is the LEFT child → hash(result, sibling) -/// if level_index is odd → result is the RIGHT child → hash(sibling, result) -/// level_index >>= 1 -/// return result == root -fn verify_auth_path(value: &[u8; 32], index: usize, path: &[[u8; 32]], root: &[u8; 32]) -> bool { - let mut result = sha256_one(value); - let mut level_index = index; - for sibling in path { - let is_left_child = level_index & 1 == 0; - result = if is_left_child { - sha256_two(&result, sibling) - } else { - sha256_two(sibling, &result) - }; - level_index >>= 1; - } - &result == root -} +use nssa::V03State; +use nssa_core::{ + Commitment, Nullifier, + account::{Account, AccountId}, + compute_digest_for_path, +}; fuzz_props::fuzz_entry!(|data: &[u8]| { - // Treat each 32-byte chunk as one leaf value. Discard any trailing - // incomplete chunk. - let values: Vec<[u8; 32]> = data - .chunks_exact(32) - .map(|c| c.try_into().expect("chunks_exact(32) always yields [u8;32]")) - .collect(); + // Reinterpret each 32-byte chunk as an AccountId; derive one commitment each. + // Dedup chunks so commitments are distinct and indices are clean. + let mut seen: HashSet<[u8; 32]> = HashSet::new(); + let mut pairs: Vec<(Commitment, Nullifier)> = Vec::new(); + for chunk in data.chunks_exact(32) { + let bytes: [u8; 32] = chunk.try_into().expect("chunks_exact(32) yields [u8;32]"); + if !seen.insert(bytes) { + continue; // skip duplicate account ids + } + let commitment = Commitment::new(&AccountId::new(bytes), &Account::default()); + // A distinct nullifier per pair (content is irrelevant to the merkle tree). + let nullifier = Nullifier::from_byte_array(bytes); + pairs.push((commitment, nullifier)); + } - // Nothing to test with an empty input. - if values.is_empty() { + if pairs.is_empty() { return; } - // Start with capacity=1 so the very first pair of insertions triggers - // `reallocate_to_double_capacity`, and each subsequent power-of-two boundary - // triggers it again. This exercises `prev_power_of_two`, the copy loop, - // and the capacity / length bookkeeping inside the reallocation path. - let mut tree = nssa::merkle_tree::MerkleTree::with_capacity(1); + // Keep the commitments so we can query their proofs after the state moves `pairs`. + let commitments: Vec = pairs.iter().map(|(c, _)| c.clone()).collect(); - // ── INVARIANT [InsertionIndex] ──────────────────────────────────────────── - // insert() must return 0, 1, 2, … in order. - for (expected_index, &value) in values.iter().enumerate() { - let actual_index = tree.insert(value); + // Genesis inserts DUMMY_COMMITMENT at index 0, then our commitments at 1..=N. + let state = V03State::new_with_genesis_accounts(&[], pairs, 0); + let digest = state.commitment_set_digest(); + + let mut indices: Vec = Vec::with_capacity(commitments.len()); + for commitment in &commitments { + // ── INVARIANT [ProofSome] ───────────────────────────────────────────── + let proof = state.get_proof_for_commitment(commitment).expect( + "INVARIANT VIOLATION [ProofSome]: \ + get_proof_for_commitment returned None for an inserted commitment", + ); + + // ── INVARIANT [ProofValid] ──────────────────────────────────────────── + // Recompute the root from the leaf + authentication path and compare to + // the tree's reported digest. A bug in root_index / insert / the path + // walk makes these disagree. assert_eq!( - actual_index, - expected_index, - "INVARIANT VIOLATION [InsertionIndex]: \ - insert returned {actual_index} but expected {expected_index}", + compute_digest_for_path(commitment, &proof), + digest, + "INVARIANT VIOLATION [ProofValid]: \ + membership proof for a commitment at index {} does not recompute to \ + commitment_set_digest()", + proof.0, + ); + + indices.push(proof.0); + } + + // ── INVARIANT [IndicesSequential] ───────────────────────────────────────── + // The dummy commitment holds index 0; our N distinct commitments must hold + // exactly indices 1..=N. + indices.sort_unstable(); + for (k, &idx) in indices.iter().enumerate() { + assert_eq!( + idx, + k + 1, + "INVARIANT VIOLATION [IndicesSequential]: \ + inserted commitments must occupy sequential indices 1..=N (dummy at 0); \ + got index {idx} at sorted position {k}", ); } - let root = tree.root(); - - // ── INVARIANTS [AuthPathSome] and [AuthPathValid] ───────────────────────── - for (index, value) in values.iter().enumerate() { - let path = tree - .get_authentication_path_for(index) - .expect("INVARIANT VIOLATION [AuthPathSome]: \ - get_authentication_path_for returned None for a valid index"); - + // ── INVARIANT [NonMembershipNone] ───────────────────────────────────────── + // A commitment derived from an account id that was NOT inserted must have no + // proof. Use an all-0xFF sentinel id and only assert when it is genuinely + // absent from the inserted set. + let sentinel_bytes = [0xFF_u8; 32]; + if !seen.contains(&sentinel_bytes) { + let absent = + Commitment::new(&AccountId::new(sentinel_bytes), &Account::default()); assert!( - verify_auth_path(value, index, &path, &root), - "INVARIANT VIOLATION [AuthPathValid]: \ - authentication path for index {index} does not re-hash to root()", + state.get_proof_for_commitment(&absent).is_none(), + "INVARIANT VIOLATION [NonMembershipNone]: \ + get_proof_for_commitment returned Some for a commitment never inserted", ); } - - // ── INVARIANT [OutOfBoundsNone] ─────────────────────────────────────────── - // The index one past the last inserted element must yield None. - assert!( - tree.get_authentication_path_for(values.len()).is_none(), - "INVARIANT VIOLATION [OutOfBoundsNone]: \ - get_authentication_path_for({}) should return None but returned Some", - values.len(), - ); }); diff --git a/fuzz/fuzz_targets/fuzz_nullifier_set_roundtrip.rs b/fuzz/fuzz_targets/fuzz_nullifier_set_roundtrip.rs new file mode 100644 index 0000000..4a7338d --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_nullifier_set_roundtrip.rs @@ -0,0 +1,75 @@ +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] +//! Fuzz target: `NullifierSet` Borsh serialisation. +//! +//! The `NullifierSet` has a hand-written `BorshDeserialize` (in +//! `lee/state_machine/src/state.rs`) that rejects duplicate nullifiers via +//! `if !set.insert(n)`. This target verifies that: +//! +//! 1. States containing distinct nullifiers survive a Borsh round-trip. The +//! `delete-!` mutation at `state.rs:104` flips the dedup check so that +//! `deserialize_reader` errors on the *first* (non-duplicate) element; a state +//! with two distinct nullifiers then fails to deserialise, tripping Part 1. +//! 2. Feeding arbitrary fuzz bytes to the `V03State` deserialiser never panics. +//! +//! # Corpus note +//! +//! A single `\x00` seed is sufficient — Part 1 uses fixed inputs and catches the +//! `delete-!` mutation without fuzz-driven state. + +use nssa::{Account, AccountId, V03State, system_faucet_account_id}; +use nssa_core::{Commitment, Nullifier}; + +fuzz_props::fuzz_entry!(|data: &[u8]| { + // ── Part 1: State with nullifiers — Borsh round-trip ───────────────────── + // Create a V03State that contains committed nullifiers via the + // `initial_private_accounts` constructor argument. + // + // With state.rs:105 mutation (delete `!`): + // - `BorshDeserialize for NullifierSet` returns `Err` on the FIRST element + // - `borsh::from_slice::(&bytes)` returns Err + // - The assert_eq below fires → mutation CAUGHT + { + // Two deterministic nullifier values (use from_byte_array): + let null1 = Nullifier::from_byte_array([0xAA_u8; 32]); + let null2 = Nullifier::from_byte_array([0xBB_u8; 32]); + // Commitment::new takes (&AccountId, &Account): + let comm1 = Commitment::new(&AccountId::new([0x11_u8; 32]), &Account::default()); + let comm2 = Commitment::new(&AccountId::new([0x22_u8; 32]), &Account::default()); + + // Build a state that holds two nullifiers in its private state. + let state = V03State::new_with_genesis_accounts( + &[(system_faucet_account_id(), 0)], + vec![(comm1, null1), (comm2, null2)], + 0, + ); + + // Serialise the state: + let bytes = borsh::to_vec(&state) + .expect("BorshSerialize for V03State must not fail"); + assert!(!bytes.is_empty()); + + // Deserialise: with the mutation, this returns Err for any state with + // nullifiers, triggering the assertion below. + let state2 = borsh::from_slice::(&bytes) + .expect("INVARIANT VIOLATION [NullifierSetRoundtrip]: \ + borsh::from_slice of a state with nullifiers must succeed \ + (mutation delete-! in NullifierSet::deserialize_reader detected)"); + + // Re-encode and verify idempotence: + let bytes2 = borsh::to_vec(&state2) + .expect("second BorshSerialize must not fail"); + assert_eq!( + bytes, + bytes2, + "INVARIANT VIOLATION [NullifierSetRoundtrip]: \ + encode(decode(encode(state))) != encode(state) — \ + NullifierSet round-trip is not idempotent", + ); + } + + // ── Part 2: Fuzz-driven raw bytes ───────────────────────────────────────── + // Feed raw fuzz bytes through V03State deserialiser — no panic allowed. + { + let _ = borsh::from_slice::(data); // NoPanic: Ok or Err, no panic + } +}); diff --git a/fuzz/fuzz_targets/fuzz_privacy_preserving_witness.rs b/fuzz/fuzz_targets/fuzz_privacy_preserving_witness.rs new file mode 100644 index 0000000..a8b8c1d --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_privacy_preserving_witness.rs @@ -0,0 +1,236 @@ +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] +//! Fuzz target: `privacy_preserving_transaction::WitnessSet` invariants. +//! +//! Mirrors `fuzz_witness_set_verification` but for the privacy-preserving +//! witness set, which additionally holds a ZK `Proof` alongside the ECDSA +//! signatures. +//! +//! # Invariants +//! +//! 1. **CorrectVerification** — a `WitnessSet` built for message A via +//! `WitnessSet::for_message` must pass `signatures_are_valid_for(A)`. +//! +//! 2. **MessageIsolation** — the same `WitnessSet` must NOT pass +//! `signatures_are_valid_for(B)` when B borsh-encodes differently from A. +//! +//! 3. **SignaturesAndPublicKeysNonEmpty** — after `for_message` with N keys, +//! `signatures_and_public_keys()` must return N entries. +//! +//! 4. **SignerIdsMatchWitnessKeys** — `PrivacyPreservingTransaction::signer_account_ids` +//! must equal `AccountId::from(pk)` for every key in the witness set. + +use arbitrary::{Arbitrary, Unstructured}; +use fuzz_props::arbitrary_types::ArbPrivateKey; +use nssa::{ + AccountId, PrivateKey, PublicKey, + privacy_preserving_transaction::{ + Message as PPMessage, + WitnessSet as PPWitnessSet, + circuit::Proof, + }, + PrivacyPreservingTransaction, +}; +use nssa_core::{ + account::Nonce, + program::{BlockValidityWindow, TimestampValidityWindow}, +}; + +/// Build a minimal `Message` for testing — no commitments, no nullifiers, +/// no encrypted states. Sufficient to test signature binding. +fn minimal_message(account_ids: Vec, nonces: Vec) -> PPMessage { + PPMessage { + public_account_ids: account_ids, + nonces, + public_post_states: vec![], + encrypted_private_post_states: vec![], + new_commitments: vec![], + new_nullifiers: vec![], + block_validity_window: BlockValidityWindow::new_unbounded(), + timestamp_validity_window: TimestampValidityWindow::new_unbounded(), + } +} + +/// Build a minimal (fake) `Proof` — bytes don't form a real ZK receipt but +/// are valid for struct construction and serialisation. +fn fake_proof() -> Proof { + Proof::from_inner(vec![0xAB_u8; 32]) +} + +fuzz_props::fuzz_entry!(|data: &[u8]| { + let mut u = Unstructured::new(data); + + // ── Fixed-key deterministic part ────────────────────────────────────────── + // Always runs regardless of input length, ensuring the mutation is caught + // even on an empty corpus. + { + let key1 = PrivateKey::try_new([1_u8; 32]).expect("known-good key"); + let key2 = PrivateKey::try_new([2_u8; 32]).expect("known-good key"); + let pub1 = PublicKey::new_from_private_key(&key1); + let pub2 = PublicKey::new_from_private_key(&key2); + let addr1 = AccountId::from(&pub1); + let addr2 = AccountId::from(&pub2); + + let msg = minimal_message( + vec![addr1, addr2], + vec![Nonce::from(0_u128), Nonce::from(1_u128)], + ); + + let ws = PPWitnessSet::for_message(&msg, fake_proof(), &[&key1, &key2]); + + // ── INVARIANT [SignaturesAndPublicKeysNonEmpty] ─────────────────────── + assert_eq!( + ws.signatures_and_public_keys().len(), + 2, + "INVARIANT VIOLATION [SignaturesAndPublicKeysNonEmpty]: \ + signatures_and_public_keys must return 2 entries for a 2-key witness set", + ); + + // ── INVARIANT [CorrectVerification] ─────────────────────────────────── + assert!( + ws.signatures_are_valid_for(&msg), + "INVARIANT VIOLATION [CorrectVerification]: \ + WitnessSet::for_message produced a witness set that fails \ + signatures_are_valid_for on the same message", + ); + + // ── INVARIANT [SignerIdsMatchWitnessKeys] ───────────────────────────── + // signer_account_ids is pub(crate); derive from signatures_and_public_keys instead. + let signers_from_ws: Vec = ws + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| AccountId::from(pk)) + .collect(); + assert_eq!(signers_from_ws.len(), 2); + assert!(signers_from_ws.contains(&addr1)); + assert!(signers_from_ws.contains(&addr2)); + + // ── INVARIANT [SignerOnlyAccountInAffected] ─────────────────────────── + // `PrivacyPreservingTransaction::affected_public_account_ids` unions + // `signer_account_ids()` with `message.public_account_ids`. To catch the + // `signer_account_ids → vec![]` mutation, build a message whose + // public_account_ids does NOT contain the signer, so the signer can only + // reach `affected` via `signer_account_ids()`. + let isolated_msg = minimal_message( + vec![AccountId::new([0xB1_u8; 32]), AccountId::new([0xB2_u8; 32])], + vec![Nonce::from(0_u128), Nonce::from(1_u128)], + ); + // Sign with key1 — addr1 is (with overwhelming probability) not one of the + // 0xB1/0xB2 placeholder accounts. + if addr1 != AccountId::new([0xB1_u8; 32]) && addr1 != AccountId::new([0xB2_u8; 32]) { + let isolated_ws = PPWitnessSet::for_message(&isolated_msg, fake_proof(), &[&key1]); + let isolated_tx = + PrivacyPreservingTransaction::new(isolated_msg, isolated_ws); + let affected = isolated_tx.affected_public_account_ids(); + assert!( + affected.contains(&addr1), + "INVARIANT VIOLATION [SignerOnlyAccountInAffected]: \ + PP affected_public_account_ids must include the signer {:?} even when it \ + is absent from message.public_account_ids — signer_account_ids() must not \ + return an empty vec", + addr1, + ); + } + + // ── INVARIANT [MessageIsolation] ────────────────────────────────────── + // Build a different message (different nonces) — the witness set for msg + // must NOT validate against msg_b. + let msg_b = minimal_message( + vec![addr1, addr2], + vec![Nonce::from(999_u128), Nonce::from(1000_u128)], + ); + let bytes_a = borsh::to_vec(&msg); + let bytes_b = borsh::to_vec(&msg_b); + if let (Ok(a), Ok(b)) = (bytes_a, bytes_b) { + if a != b { + assert!( + !ws.signatures_are_valid_for(&msg_b), + "INVARIANT VIOLATION [MessageIsolation]: \ + PP WitnessSet for msg accepted for a different msg_b — \ + possible signature-binding bypass", + ); + } + } + + // Single-key variant: + let ws_single = PPWitnessSet::for_message(&msg, fake_proof(), &[&key1]); + assert_eq!(ws_single.signatures_and_public_keys().len(), 1); + + let tx_single = PrivacyPreservingTransaction::new(msg.clone(), ws_single); + // Use affected_public_account_ids (which calls signer_account_ids internally): + let single_affected = tx_single.affected_public_account_ids(); + assert!( + single_affected.contains(&addr1), + "INVARIANT VIOLATION [SignerIdsMatchWitnessKeys]: 1-key tx must include addr1", + ); + } + + // ── Fuzz-driven part ────────────────────────────────────────────────────── + // Generate 0–3 random private keys, build a WitnessSet, verify correct validation. + { + let n_keys = (u8::arbitrary(&mut u).unwrap_or(0) % 4) as usize; + let mut keys = Vec::with_capacity(n_keys); + let mut addrs = Vec::with_capacity(n_keys); + let mut nonces = Vec::with_capacity(n_keys); + + for i in 0..n_keys { + match ArbPrivateKey::arbitrary(&mut u) { + Ok(k) => { + let pk = PublicKey::new_from_private_key(&k.0); + addrs.push(AccountId::from(&pk)); + nonces.push(Nonce::from(i as u128)); + keys.push(k.0); + } + Err(_) => break, + } + } + + if keys.is_empty() { + return; + } + + let msg = minimal_message(addrs.clone(), nonces); + let key_refs: Vec<&PrivateKey> = keys.iter().collect(); + let ws = PPWitnessSet::for_message(&msg, fake_proof(), &key_refs); + + // INVARIANT [SignaturesAndPublicKeysNonEmpty] + assert_eq!( + ws.signatures_and_public_keys().len(), + keys.len(), + "INVARIANT VIOLATION [SignaturesAndPublicKeysNonEmpty]: \ + signatures_and_public_keys count must match number of keys", + ); + + // INVARIANT [CorrectVerification] + assert!( + ws.signatures_are_valid_for(&msg), + "INVARIANT VIOLATION [CorrectVerification]: \ + PP WitnessSet::for_message produced witnesses that fail validation", + ); + + // INVARIANT [SignerIdsMatchWitnessKeys] + // signer_account_ids is pub(crate); verify via affected_public_account_ids + // (which internally calls signer_account_ids) and via signatures_and_public_keys. + let tx = PrivacyPreservingTransaction::new(msg, ws.clone()); + let signer_ids_from_ws: Vec = ws + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| AccountId::from(pk)) + .collect(); + assert_eq!( + signer_ids_from_ws.len(), + addrs.len(), + "INVARIANT VIOLATION [SignerIdsMatchWitnessKeys]: \ + witness set key count must match number of keys provided", + ); + // affected_public_account_ids includes signer IDs: + let affected2 = tx.affected_public_account_ids(); + for addr in &addrs { + assert!( + affected2.contains(addr), + "INVARIANT VIOLATION [SignerIdsMatchWitnessKeys]: \ + affected_public_account_ids must contain {:?}", + addr, + ); + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_system_account_protection.rs b/fuzz/fuzz_targets/fuzz_system_account_protection.rs new file mode 100644 index 0000000..8555b92 --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_system_account_protection.rs @@ -0,0 +1,165 @@ +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] +//! Fuzz target: system-account modification protection. +//! +//! `LeeTransaction::validate_on_state` must reject any transaction that modifies +//! a system account (faucet, bridge, or clock accounts). This is enforced by +//! `validate_doesnt_modify_account` which inspects `ValidatedStateDiff::public_diff()`. +//! +//! # Corpus note +//! +//! This target is **input-independent**. A single `\x00` seed is sufficient. +//! +//! **Performance note**: the `[SystemAccountModificationRejected]` invariant +//! executes a RISC0 program (a native transfer). This is inherently slow +//! (~seconds). Only one corpus file is needed, so the corpus-regression oracle +//! costs one program execution per mutant under test. + +use common::transaction::LeeTransaction; +use nssa::{ + AccountId, PrivateKey, PublicKey, V03State, ValidatedStateDiff, + CLOCK_01_PROGRAM_ACCOUNT_ID, system_bridge_account_id, system_faucet_account_id, +}; + +fuzz_props::fuzz_entry!(|_data: &[u8]| { + // ── INVARIANT [SystemAccountIdsDistinct] ────────────────────────────────── + let faucet_id = system_faucet_account_id(); + let bridge_id = system_bridge_account_id(); + + assert_ne!( + faucet_id, + AccountId::default(), + "INVARIANT VIOLATION [SystemAccountIdsDistinct]: faucet account ID must be non-default", + ); + assert_ne!( + bridge_id, + AccountId::default(), + "INVARIANT VIOLATION [SystemAccountIdsDistinct]: bridge account ID must be non-default", + ); + assert_ne!( + faucet_id, + bridge_id, + "INVARIANT VIOLATION [SystemAccountIdsDistinct]: faucet and bridge must be distinct", + ); + + // ── INVARIANT [ClockInvocationRejected] ────────────────────────────────── + // A native transfer that CREDITS a clock system account modifies exactly one + // system account — the clock account — and that account is *changed* (its + // balance increases from 0). No other system account appears in the diff, so + // this isolates the `validate_doesnt_modify_account` rejection cleanly. + // + // Why not a clock invocation? The clock program writes all three clock + // accounts, but the 01/10/50 clocks tick at different rates, so its diff + // contains BOTH changed and unchanged system accounts. The `!=`→`==` + // mutation then still rejects (citing an unchanged account), so a clock + // invocation cannot distinguish the mutant. Crediting a single clock account + // gives a single, changed system account, which the mutant must accept. + // + // Why not credit the faucet? The faucet holds u128::MAX, so any credit + // overflows and the program execution fails before the protection check is + // reached. A clock account starts at balance 0, so a small credit succeeds. + // + // With mutation `!=` → `==` at transaction.rs:182: + // The clock account is changed (post != pre), so the mutated `post == pre` + // check is false → no error → validate_on_state returns Ok → our assert fires. + // + // With mutation `public_diff → HashMap::new()` at validated_state_diff.rs:479: + // validate_doesnt_modify_account sees an empty map → can never find the + // clock account → returns Ok for every transaction → our assert fires. + { + let sender_key = PrivateKey::try_new([5_u8; 32]).expect("known-good key"); + let sender_pub = PublicKey::new_from_private_key(&sender_key); + let sender_id = AccountId::from(&sender_pub); + + // Fund the sender; clock accounts already exist in genesis (balance 0). + let state = V03State::new_with_genesis_accounts(&[(sender_id, 10_000_u128)], vec![], 0); + + // Transfer tokens TO a clock account — credits (changes) that system account. + let tx = common::test_utils::create_transaction_native_token_transfer( + sender_id, + 0, // nonce + CLOCK_01_PROGRAM_ACCOUNT_ID, + 100, // amount credited to the clock account + &sender_key, + ); + + let result = tx.validate_on_state(&state, 1, 0); + + assert!( + result.is_err(), + "INVARIANT VIOLATION [SystemAccountModificationRejected]: \ + validate_on_state must reject a transfer that credits a clock system \ + account. If this fires, either validate_doesnt_modify_account has a logic \ + inversion (!=→==) or public_diff() returns an empty map", + ); + } + + // ── INVARIANT [PublicDiffNonEmptyOnSuccess] ──────────────────────────────── + // For a valid public transaction with signers, the signer accounts must appear + // in public_diff after successful validation (nonces are updated in the diff). + // + // With mutation `public_diff → HashMap::new()`: + // The map is empty → `contains_key(&signer)` returns false → assert fires. + // + // Uses `common::test_utils::create_transaction_native_token_transfer` to + // construct a semantically valid transaction (correct instruction type). + { + let key = PrivateKey::try_new([7_u8; 32]).expect("known-good key"); + let pubkey = PublicKey::new_from_private_key(&key); + let addr = AccountId::from(&pubkey); + + let key2 = PrivateKey::try_new([8_u8; 32]).expect("known-good key"); + let pubkey2 = PublicKey::new_from_private_key(&key2); + let addr2 = AccountId::from(&pubkey2); + + let state = V03State::new_with_genesis_accounts( + &[(addr, 10_000_u128), (addr2, 10_000_u128)], + vec![], + 0, + ); + + // Use the test utility to build a valid native token transfer. + // This uses the correct authenticated_transfer_core::Instruction::Transfer, + // which the program can actually execute without panicking. + let lee_tx = common::test_utils::create_transaction_native_token_transfer( + addr, + 0, // nonce = 0 (matches initial state nonce) + addr2, + 100, // amount + &key, + ); + + if let LeeTransaction::Public(pub_tx) = &lee_tx { + if let Ok(diff) = ValidatedStateDiff::from_public_transaction(&pub_tx, &state, 1, 0) { + let public_diff = diff.public_diff(); + + // The signer/sender (addr) must be in the diff: a native transfer + // debits its balance, so it MUST appear in public_diff. + // If public_diff() returns an empty HashMap, this assert fires. + // + // Note: nonce increments are applied separately during + // `apply_state_diff` via `signer_account_ids` and are NOT recorded + // in `public_diff`, so we do not assert on the nonce field here. + assert!( + public_diff.contains_key(&addr), + "INVARIANT VIOLATION [PublicDiffNonEmptyOnSuccess]: \ + public_diff must contain the sender {:?} (its balance is debited) \ + after a successful native transfer \ + (mutation public_diff→HashMap::new() detected)", + addr, + ); + + // The diff must reflect the balance debit on the sender — the + // balance recorded in the diff must differ from the pre-state. + let pre_balance = state.get_account_by_id(addr).balance; + let post_balance = public_diff[&addr].balance; + assert_ne!( + post_balance, + pre_balance, + "INVARIANT VIOLATION [PublicDiffNonEmptyOnSuccess]: \ + sender balance in the diff must differ from pre-state after a transfer \ + (pre={pre_balance}, post={post_balance})", + ); + } + } + } +}); diff --git a/fuzz/fuzz_targets/fuzz_transaction_properties.rs b/fuzz/fuzz_targets/fuzz_transaction_properties.rs new file mode 100644 index 0000000..4c11c7b --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_transaction_properties.rs @@ -0,0 +1,271 @@ +#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)] +//! Fuzz target: transaction property invariants. +//! +//! Tests that key accessor methods on `LeeTransaction`, `PublicTransaction`, and +//! `ValidatedStateDiff` return correct, non-stub values. + +use arbitrary::{Arbitrary, Unstructured}; +use common::transaction::LeeTransaction; +use fuzz_props::arbitrary_types::ArbPrivateKey; +use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state}; +use nssa::{ + AccountId, PrivateKey, PublicKey, ValidatedStateDiff, V03State, + public_transaction::{Message, WitnessSet}, + PublicTransaction, + program::Program, +}; +use nssa_core::account::Nonce; + +fuzz_props::fuzz_entry!(|data: &[u8]| { + let mut u = Unstructured::new(data); + + // ── Part 1: Known-good witness set / transaction using fixed keys ────────── + // Uses deterministic keys so we always have at least one valid transaction. + // This ensures hash, signer_account_ids, into_raw_parts, and affected_accounts + // are always tested, even when the fuzzer input is insufficient for arb generators. + { + let key1 = PrivateKey::try_new([1_u8; 32]).expect("known-good key"); + let key2 = PrivateKey::try_new([2_u8; 32]).expect("known-good key"); + let pub1 = PublicKey::new_from_private_key(&key1); + let pub2 = PublicKey::new_from_private_key(&key2); + let addr1 = AccountId::from(&pub1); + let addr2 = AccountId::from(&pub2); + + let nonces = vec![Nonce::from(0_u128), Nonce::from(0_u128)]; + let message = Message::try_new( + Program::authenticated_transfer_program().id(), + vec![addr1, addr2], + nonces, + 1337_u64, + ) + .expect("known-good message"); + + let ws = WitnessSet::for_message(&message, &[&key1, &key2]); + let pub_tx = PublicTransaction::new(message, ws); + + // ── INVARIANT [SignerIdsNonEmpty] ───────────────────────────────────── + // A transaction signed by 2 keys must expose 2 signer (key, sig) pairs. + // `signer_account_ids` is pub(crate); we verify via the public witness_set API. + let ws_pairs = pub_tx.witness_set().signatures_and_public_keys(); + assert_eq!( + ws_pairs.len(), + 2, + "INVARIANT VIOLATION [SignerIdsNonEmpty]: \ + witness_set signatures_and_public_keys must have 2 entries", + ); + + // ── INVARIANT [IntoRawPartsCount] ───────────────────────────────────── + // `into_raw_parts` must return the same number of pairs as the witness set. + // Catches the mutation that returns `vec![]`. + let ws2 = WitnessSet::for_message(pub_tx.message(), &[&key1, &key2]); + let parts = ws2.into_raw_parts(); + assert_eq!( + parts.len(), + 2, + "INVARIANT VIOLATION [IntoRawPartsCount]: \ + WitnessSet::into_raw_parts must return 2 pairs for a 2-key witness set", + ); + + // ── INVARIANT [AffectedAccountsContainSigners] ─────────────────────── + // `affected_public_account_ids` must include the signer accounts. + // Catches the mutation that returns `vec![]` or `vec![Default::default()]`. + let affected = pub_tx.affected_public_account_ids(); + assert!( + !affected.is_empty(), + "INVARIANT VIOLATION [AffectedAccountsContainSigners]: \ + affected_public_account_ids must be non-empty for a 2-signer tx", + ); + assert!( + affected.contains(&addr1), + "INVARIANT VIOLATION [AffectedAccountsContainSigners]: \ + affected_public_account_ids must include addr1 (signer)", + ); + assert!( + affected.contains(&addr2), + "INVARIANT VIOLATION [AffectedAccountsContainSigners]: \ + affected_public_account_ids must include addr2 (signer)", + ); + + // ── INVARIANT [HashNonDefault] ──────────────────────────────────────── + // The transaction hash must not be the all-zero default. + // Catches the mutation that returns `Default::default()`. + let lee_tx = LeeTransaction::Public(pub_tx); + let hash = lee_tx.hash(); + assert_ne!( + hash.0, + [0_u8; 32], + "INVARIANT VIOLATION [HashNonDefault]: \ + LeeTransaction::hash must not return all-zero bytes", + ); + + // Also verify it's deterministic (same tx → same hash): + let hash2 = lee_tx.hash(); + assert_eq!( + hash, + hash2, + "INVARIANT VIOLATION [HashDeterministic]: \ + LeeTransaction::hash must be deterministic", + ); + + // LeeTransaction::affected_public_account_ids must also be non-empty: + let lee_affected = lee_tx.affected_public_account_ids(); + assert!( + lee_affected.contains(&addr1), + "INVARIANT VIOLATION [AffectedAccountsContainSigners]: \ + LeeTransaction::affected_public_account_ids must include addr1", + ); + } + + // ── INVARIANT [SignerOnlyAccountInAffected] ─────────────────────────────── + // Build a transaction signed by a key whose AccountId is NOT among + // `message.account_ids`. Then `affected_public_account_ids` can only contain + // the signer's AccountId via `signer_account_ids()` — it is absent from the + // message's account list. This directly catches the `signer_account_ids` + // mutations (`→ vec![]` / `→ vec![Default::default()]`) on PublicTransaction, + // which the earlier checks miss because there the signer also appears in + // `message.account_ids`. + { + // Signer key — its AccountId must NOT appear in the message account list. + let signer_key = PrivateKey::try_new([9_u8; 32]).expect("known-good key"); + let signer_pub = PublicKey::new_from_private_key(&signer_key); + let signer_addr = AccountId::from(&signer_pub); + + // Two unrelated account IDs for the message (deterministic, not derived + // from the signer key). + let other1 = AccountId::new([0xA1_u8; 32]); + let other2 = AccountId::new([0xA2_u8; 32]); + + // Guard: ensure the signer is genuinely not one of the message accounts. + if signer_addr != other1 && signer_addr != other2 { + let nonces = vec![Nonce::from(0_u128)]; + if let Ok(msg) = Message::try_new( + Program::authenticated_transfer_program().id(), + vec![other1, other2], + nonces, + 7_u64, + ) { + let ws = WitnessSet::for_message(&msg, &[&signer_key]); + let pt = PublicTransaction::new(msg, ws); + + let affected = pt.affected_public_account_ids(); + assert!( + affected.contains(&signer_addr), + "INVARIANT VIOLATION [SignerOnlyAccountInAffected]: \ + affected_public_account_ids must include the signer {:?} even when it \ + is absent from message.account_ids — signer_account_ids() must not \ + return an empty (or defaulted) vec", + signer_addr, + ); + } + } + } + + // ── Part 2: Fuzz-driven state + valid native transfer ───────────────────── + // Generates a random state and a correctly-signed transfer. When the transfer + // succeeds, verifies that `public_diff` is non-empty and contains the + // expected account changes. + { + let fuzz_accs = match arbitrary_fuzz_state(&mut u) { + Ok(accs) => accs, + Err(_) => return, + }; + let init_accs: Vec<(AccountId, u128)> = fuzz_accs + .iter() + .map(|a| (a.account_id, a.balance)) + .collect(); + let state = V03State::new_with_genesis_accounts(&init_accs, vec![], 0); + + let Ok(tx) = arb_fuzz_native_transfer(&mut u, &fuzz_accs) else { + return; + }; + let Ok(checked) = tx.transaction_stateless_check() else { + return; + }; + + let pub_tx = match &checked { + LeeTransaction::Public(pt) => pt, + _ => return, + }; + + // For a public transaction with signers, affected_public_account_ids must + // include all signer account IDs. Derive signers from the public witness API. + let signers: Vec = pub_tx + .witness_set() + .signatures_and_public_keys() + .iter() + .map(|(_, pk)| AccountId::from(pk)) + .collect(); + let affected = pub_tx.affected_public_account_ids(); + for signer in &signers { + assert!( + affected.contains(signer), + "INVARIANT VIOLATION [AffectedAccountsContainSigners]: \ + affected_public_account_ids must include signer {:?}", + signer, + ); + } + + // When from_public_transaction succeeds, public_diff must be non-empty + // (at least the signer nonces are updated). + // Catches the mutation that returns `HashMap::new()`. + if let Ok(diff) = ValidatedStateDiff::from_public_transaction(pub_tx, &state, 1, 0) { + let public_diff = diff.public_diff(); + + // The diff must contain at least the signer accounts (nonce updates): + for signer in &signers { + // Signers appear in diff because their nonces are updated. + // If public_diff() returns empty HashMap, this assert fires. + assert!( + public_diff.contains_key(signer), + "INVARIANT VIOLATION [PublicDiffNonEmptyOnSuccess]: \ + public_diff must contain signer account {:?} after successful validation \ + (nonce must have been updated)", + signer, + ); + } + } + } + + // ── Part 3: Fuzz-driven arbitrary keys for additional coverage ───────────── + { + if let Ok(key_wrap) = ArbPrivateKey::arbitrary(&mut u) { + let key = key_wrap.0; + let pubkey = PublicKey::new_from_private_key(&key); + let addr = AccountId::from(&pubkey); + + let nonces = vec![Nonce::from(0_u128)]; + if let Ok(msg) = Message::try_new( + Program::authenticated_transfer_program().id(), + vec![addr], + nonces, + 42_u64, + ) { + let ws = WitnessSet::for_message(&msg, &[&key]); + let pt = PublicTransaction::new(msg, ws); + + // Single-signer checks via witness_set (signer_account_ids is pub(crate)): + let ws_pairs2 = pt.witness_set().signatures_and_public_keys(); + assert_eq!( + ws_pairs2.len(), + 1, + "INVARIANT VIOLATION [SignerIdsNonEmpty]: 1-key witness set must have 1 pair", + ); + let derived_addr = AccountId::from(&ws_pairs2[0].1); + assert_eq!( + derived_addr, + addr, + "INVARIANT VIOLATION [SignerIdsDerivedFromKeys]: \ + derived signer address must match expected addr", + ); + + let ws2 = WitnessSet::for_message(pt.message(), &[&key]); + let parts = ws2.into_raw_parts(); + assert_eq!( + parts.len(), + 1, + "INVARIANT VIOLATION [IntoRawPartsCount]: 1-signer witness set → 1 raw part", + ); + } + } + } +}); diff --git a/scripts/mutants-corpus-test.sh b/scripts/mutants-corpus-test.sh index 0b409f1..bb04eee 100755 --- a/scripts/mutants-corpus-test.sh +++ b/scripts/mutants-corpus-test.sh @@ -38,6 +38,12 @@ targets=( fuzz_sequencer_vs_replayer fuzz_merkle_tree fuzz_genesis_invariants + fuzz_common_invariants + fuzz_transaction_properties + fuzz_privacy_preserving_witness + fuzz_encoding_privacy_preserving + fuzz_nullifier_set_roundtrip + fuzz_system_account_protection ) # cargo-fuzz requires the nightly toolchain (-Zsanitizer=address etc.).