fix: Add timeout to model based stateful lockstep
Lez-fuzzing
Coverage-guided fuzzing & adversarial testing infrastructure for the Logos Execution Zone (LEZ) protocol.
📂 Repository Layout
lez-fuzzing/
├── Cargo.toml # Workspace root (members: fuzz_props)
├── Justfile # Turn-key entry-points
├── rust-toolchain.toml # Pins Rust nightly (required by cargo-fuzz)
├── .gitignore
├── fuzz_props/ # Shared invariant framework + input generators
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs
│ ├── arbitrary_types.rs # Arbitrary impl wrappers for LEZ types (libFuzzer)
│ ├── invariants.rs # ProtocolInvariant trait + concrete invariants
│ └── generators.rs # Arbitrary / proptest strategies
├── fuzz/ # cargo-fuzz crate (own [workspace] sentinel)
│ ├── Cargo.toml
│ ├── fuzz_targets/ # 23 targets total — see table below
│ │ ├── _template.rs # Template for `just new-target`
│ │ └── fuzz_*.rs
│ └── corpus/ # Curated seed inputs (one dir per target)
├── .github/
│ └── workflows/
│ ├── fuzz.yml # CI: smoke-fuzz · regression · proptest · perf (libFuzzer)
│ ├── fuzz-afl.yml # CI: AFL++ lane
│ ├── mutants.yml # CI: mutation testing (cargo-mutants)
│ └── lint.yml # CI: fmt + clippy
├── scripts/
│ └── add_fuzz_target.py # Automates new-target scaffolding (called by just new-target)
└── docs/
├── fuzzing.md # Full developer guide
└── mutants-not-fuzzable.md # Policy + mutant→test mapping
The LEZ codebase is consumed as a sibling directory — clone
logos-execution-zone next to this repository so the ../ path deps resolve:
parent/
├── lez-fuzzing/ ← this repo
└── logos-execution-zone/ ← LEZ codebase (path deps resolve via ../)
🚀 Quick Start
1. Prerequisites
rustup install nightly
rustup component add llvm-tools-preview --toolchain nightly
cargo install cargo-fuzz
cargo install just # optional but recommended
2. Setup
# Clone both repositories side by side
git clone <LEZ_REPO_URL> logos-execution-zone
git clone <LEZ_FUZZING_REPO_URL> lez-fuzzing
cd lez-fuzzing
3. Run the fuzz targets
# All targets for 30 s each (RISC0_DEV_MODE=1 is set automatically)
just fuzz
# Specific duration
just fuzz 120
# Single target
RISC0_DEV_MODE=1 cargo fuzz run fuzz_state_transition -- -max_total_time=120
# Corpus regression (replay saved corpus, no mutations)
just fuzz-regression
# Property-based tests only (no libFuzzer)
just fuzz-props
Important
ZK-proof cost:
RISC0_DEV_MODE=1is exported at the top of theJustfileand must be set in every fuzz run to stub out ZK proof generation. Without it, each execution takes seconds instead of microseconds.
🎯 Fuzz Targets
| # | Target | Protocol layer |
|---|---|---|
| 1 | fuzz_transaction_decoding |
Borsh decoding of all tx/block types (LeeTransaction, Block, HashableBlockData) with roundtrip re-encoding |
| 2 | fuzz_stateless_verification |
transaction_stateless_check() no-panic + idempotency |
| 3 | fuzz_state_transition |
V03State transition: StateIsolationOnFailure + BalanceConservation + ReplayRejection invariants across up to 8 txs with fuzz-driven state |
| 4 | fuzz_block_verification |
Block hash integrity: HashRoundTrip · HashPreimage completeness (block_id/prev_hash/timestamp) · TxOrderCommitment |
| 5 | fuzz_encoding_roundtrip |
Borsh encode→decode→encode round-trip identity + canonical encoding for PublicTransaction and ProgramDeploymentTransaction |
| 6 | fuzz_signature_verification |
Signature correctness (sign→verify), no-panic on random bytes, cross-key soundness |
| 7 | fuzz_replay_prevention |
Transaction nonce replay rejection with fuzz-driven initial state |
| 8 | fuzz_state_diff_computation |
ValidatedStateDiff forward containment + reverse completeness (bidirectional isolation check) |
| 9 | fuzz_validate_execute_consistency |
validate_on_state / execute_check_on_state agreement + diff accuracy + BalanceConservation |
| 10 | fuzz_state_serialization |
V03State Borsh decode no-panic + StateSerializationRoundtrip idempotency + NullifierDeduplication (NullifierSet hand-written impl) |
| 11 | fuzz_witness_set_verification |
WitnessSet::is_valid_for no-panic + CorrectVerification (sign→verify) + MessageIsolation (witness set for msg A rejected on msg B) |
| 12 | fuzz_program_deployment_lifecycle |
V03State::transition_from_program_deployment_transaction no-panic + BalanceIsolation (deployment must not move tokens) + StateIsolationOnFailure |
| 13 | fuzz_apply_state_diff_split_path |
SplitPathEquivalence: validate_on_state + apply_state_diff == execute_check_on_state for all known accounts (balance, nonce, data, program_owner); NonceIncrementCorrectness |
| 14 | fuzz_multi_block_state_sequence |
LongRangeBalanceConservation across up to 16 blocks + FailedTxNonceStability (nonce must not change on rejection) + PerBlockReplayRejection |
| 15 | fuzz_sequencer_vs_replayer |
Differential: sequencer path (validate_on_state → apply_state_diff) vs replayer path (execute_check_on_state) — SequencerReplayerEquivalence + ReplayerAcceptsAllSequencerTxs + ClockConsistency |
| 16 | fuzz_merkle_tree |
Commitment Merkle tree via the commitment set: ProofSome · ProofValid (leaf + auth path recomputes the root) · NonMembershipNone · IndicesSequential |
| 17 | fuzz_transaction_properties |
Transaction property invariants: HashDeterministic/HashNonDefault, SignerIds derived from witness keys & non-empty, AffectedAccountsContainSigners, PublicDiffNonEmptyOnSuccess |
| 18 | fuzz_privacy_preserving_witness |
privacy_preserving_transaction::WitnessSet: CorrectVerification (witness for msg A passes signatures_are_valid_for(A)) + MessageIsolation + SignerIdsMatchWitnessKeys |
| 19 | fuzz_encoding_privacy_preserving |
Privacy-preserving encoding: MessageEncodingRoundtrip + TxEncodingDeterministic/NonEmpty |
| 20 | fuzz_nullifier_set_roundtrip |
NullifierSet Borsh serialisation: NullifierSetRoundtrip (decode→encode identity for the hand-written impl) |
| 21 | fuzz_privacy_preserving_state_transition |
Path B — NSSATransaction::PrivacyPreserving through execute_check_on_state with a dev-mode passing proof: reaches commitment/nullifier checks 5–6 + apply_state_diff. Asserts no-panic, StateIsolationOnFailure, PrivateStateIsolationOnFailure, CommitmentInsertion, NonceIncrementCorrectness, PostStateApplied, ReplayRejection (balance conservation intentionally not asserted — the fake proof bypasses the circuit guarantee) |
| 22 | fuzz_transaction_ordering_independence |
Transaction ordering-independence on the shielded path: builds a nullifier-conflicting pair (two distinct privacy-preserving txs declaring the same nullifier) and applies it in both orders on independent clones of a seeded state, at an identical (block_id, timestamp). Asserts NoDoubleSpend (neither ordering accepts both — the shared nullifier is spendable at most once) and OrderIndependentAcceptance (the count of accepted txs is the same in both orderings). The nullifier check is enforced by the state machine, not the circuit, so the dev-mode fake proof does not mask it. Requires RISC0_DEV_MODE=1 |
| 23 | fuzz_stateful_model_lockstep |
Model-based stateful lockstep: steps an independent hand-written reference model of (balance, nonce) per account — reimplementing native-transfer semantics without calling nssa — in lockstep with the real V03State over a generated schedule of Transfer/AdvanceBlock/ReplayLast commands. Asserts ModelAcceptanceAgreement (real accept iff model predicts accept) and ModelStateAgreement (every account's real balance & nonce equals the model) after every command. The only target with a predictive oracle that pins the exact expected outcome per operation, catching history-dependent acceptance/state drift a self-consistent-but-wrong machine would pass |
Each target lives at fuzz/fuzz_targets/<name>.rs.
🧬 Corpus Management
# Minimise all corpora (removes dominated inputs, keeps coverage-equivalent set)
just corpus-cmin
# Minimise a single target's corpus
just corpus-cmin-target fuzz_state_transition
💥 Crash / Failure Workflow
# 1. Minimise a crash artifact
just fuzz-tmin fuzz_state_transition fuzz/artifacts/fuzz_state_transition/crash-abc123
# 2. Print the bytes as a Rust literal (for a regression #[test])
cargo fuzz fmt fuzz_state_transition fuzz/artifacts/fuzz_state_transition/crash-abc123
# 3. Promote the minimised input to the corpus so CI catches regressions
cp fuzz/artifacts/fuzz_state_transition/crash-abc123-minimised \
fuzz/corpus/fuzz_state_transition/regression_001
➕ Adding a New Target
# Scaffold everything automatically (corpus dir, .rs file, Cargo.toml entry)
just new-target my_feature # creates fuzz_my_feature
just new-target calls scripts/add_fuzz_target.py, which
appends the [[bin]] entry to fuzz/Cargo.toml — the single source of
truth. Every workflow and script derives its target list from that file at runtime (the CI
matrices and build loops via the resolve-targets
composite action, and scripts/mutants-corpus-test.sh via
an inline parse), so no CI edits are needed. The only manual step is a prose row in the
target tables of README.md and docs/fuzzing.md;
scripts/check_target_inventory.py (run in CI) fails the
build if either table drifts from fuzz/Cargo.toml.
🧹 Housekeeping
| Command | Removes |
|---|---|
just clean |
Cargo build artefacts (target/ and fuzz/target/) |
just clean-artifacts |
fuzz/artifacts/ (crash/timeout inputs) |
just clean-coverage |
fuzz/coverage/ (LLVM coverage reports) |
just clean-all |
All of the above |
⚙️ CI
GitHub Actions runs these workflows on every push/PR and nightly:
| Workflow | What it does |
|---|---|
fuzz.yml — smoke-fuzz (matrix) |
Builds + runs each libFuzzer target for 60 s |
fuzz.yml — regression (matrix) |
Replays the saved corpus (-runs=0) |
fuzz.yml — proptest |
cargo test -p fuzz_props --release |
fuzz.yml — perf-baseline (nightly only) |
Measures exec/sec per target, uploads perf_baseline.txt |
fuzz-afl.yml |
AFL++ lane over the same targets/corpus |
mutants.yml |
Mutation testing (cargo-mutants) |
lint.yml |
Formatting + Clippy |
📖 Documentation
The full developer guide — how to add new targets, interpret crashes, update
the LEZ sibling clone, and tune performance — lives in
docs/fuzzing.md.
📜 License
Licensed under the MIT License.