chore: add new fuzz targets to cover 40 missed mutants

This commit is contained in:
Roman 2026-06-10 16:29:05 +08:00
parent c9d37b88d1
commit 9cb0d43c40
No known key found for this signature in database
GPG Key ID: 583BDF43C238B83E
10 changed files with 1272 additions and 105 deletions

1
fuzz/Cargo.lock generated
View File

@ -2084,7 +2084,6 @@ dependencies = [
"lee",
"lee_core",
"libfuzzer-sys",
"sha2",
"testnet_initial_state",
]

View File

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

View File

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

View File

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

View File

@ -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<Commitment> = 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<usize> = 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(),
);
});

View File

@ -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::<V03State>(&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::<V03State>(&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::<V03State>(data); // NoPanic: Ok or Err, no panic
}
});

View File

@ -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<AccountId>, nonces: Vec<Nonce>) -> 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<AccountId> = 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 03 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<AccountId> = 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,
);
}
}
});

View File

@ -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_diffHashMap::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})",
);
}
}
}
});

View File

@ -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<AccountId> = 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",
);
}
}
}
});

View File

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