lez-fuzzing/docs/fuzzing.md

608 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Fuzzing Guide
This document covers how to run fuzz targets, add new targets, minimise failures,
and convert findings into regression tests.
The fuzzing infrastructure lives in a **separate repository** (`lez-fuzzing/`) which
reads the Logos Execution Zone (LEZ) codebase from `../logos-execution-zone/` (a sibling
directory that must be cloned separately).
---
## Architecture
The fuzz workspace (`fuzz/`) is a single Cargo workspace that covers **both** fuzzing
engines via Cargo features. No separate Cargo manifest is needed.
| | libFuzzer lane | AFL++ lane |
|---|---|---|
| **Build command** | `cargo fuzz build <TARGET>` | `cd fuzz && cargo afl build --no-default-features --features fuzzer-afl --release --bin <TARGET>` |
| **Run command** | `cargo fuzz run <TARGET>` | `afl-fuzz -i fuzz/corpus/<TARGET> -o afl-output/<TARGET> -- fuzz/target/release/<TARGET>` |
| **Cargo feature** | `fuzzer-libfuzzer` (default) | `fuzzer-afl` |
| **Harness entry** | `::libfuzzer_sys::fuzz_target!(…)` | `fn main() { ::afl::fuzz!(…) }` |
| **`main()` presence** | Suppressed via `#![no_main]` | Required; provided by `afl::fuzz!` |
| **`fuzz/Cargo.toml`** | ✅ Source of truth | ✅ Same file — covers both lanes |
The engine is selected at the call site via the `fuzz_props::fuzz_entry!` macro:
```rust
#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]
fuzz_props::fuzz_entry!(|data: &[u8]| {
// … harness body …
});
```
The `cfg` attributes in the macro expansion resolve against the **calling crate's** features
(`fuzz/`), not `fuzz_props`'s features.
---
## Prerequisites
```bash
# libFuzzer lane
rustup install nightly
rustup component add llvm-tools-preview --toolchain nightly
cargo install cargo-fuzz
# AFL++ lane (additional)
# macOS:
brew install afl-fuzz
# Linux — build from source (apt packages are several major versions behind):
git clone https://github.com/AFLplusplus/AFLplusplus
cd AFLplusplus && make distrib && sudo make install
cd ..
# Rust wrapper (all platforms):
cargo install cargo-afl
```
---
## Repository Setup
`lez-fuzzing` is a **standalone repository** — it does **not** use git submodules.
It expects the LEZ codebase to be cloned at `../logos-execution-zone` relative to itself.
```bash
# Clone both repositories side-by-side into the same parent directory:
git clone <LEZ_REPO_URL> logos-execution-zone
git clone <LEZ_FUZZING_REPO_URL> lez-fuzzing
# The directory layout must be:
# <parent>/
# ├── logos-execution-zone/
# └── lez-fuzzing/
```
---
## How to Run
All fuzz targets must be run with `RISC0_DEV_MODE=1` to disable expensive ZK
proof generation. The `just` recipes handle this automatically.
```bash
# From lez-fuzzing/
# Run all targets for 30 s each (libFuzzer)
just fuzz
# Run a specific target for 120 s (libFuzzer)
RISC0_DEV_MODE=1 cargo fuzz run fuzz_state_transition -- -max_total_time=120
# Run the saved corpus (regression mode, no mutations)
just fuzz-regression
```
---
## Available Fuzz Targets
| Target | What it fuzzes | Entry point |
|--------|---------------|-------------|
| `fuzz_transaction_decoding` | Borsh decoding of `NSSATransaction`, `Block`, and `HashableBlockData`; roundtrip re-encoding of successfully decoded transactions | `fuzz/fuzz_targets/fuzz_transaction_decoding.rs` |
| `fuzz_stateless_verification` | `transaction_stateless_check()` no-panic on arbitrary bytes; idempotency — a transaction that passes the check must pass it again | `fuzz/fuzz_targets/fuzz_stateless_verification.rs` |
| `fuzz_state_transition` | `execute_check_on_state()` across up to 8 transactions with fuzz-driven initial state and monotonically-advancing block context; asserts **StateIsolationOnFailure** (balances unchanged on rejection), **BalanceConservation** (total balance unchanged on success), and **ReplayRejection** (nonce consumed on first acceptance) | `fuzz/fuzz_targets/fuzz_state_transition.rs` |
| `fuzz_block_verification` | Three block-hash invariants: **HashRoundTrip** (`HashableBlockData::from(Block)` is lossless), **HashPreimage** (block_id, prev_block_hash, timestamp each individually affect the hash), **TxOrderCommitment** (reversing the transaction list changes the hash) | `fuzz/fuzz_targets/fuzz_block_verification.rs` |
| `fuzz_encoding_roundtrip` | `decode(encode(tx)) == Ok(tx)` and `encode(decode(encode(tx))) == encode(tx)` for `PublicTransaction` and `ProgramDeploymentTransaction`; raw bytes that decode successfully must re-encode identically (canonical encoding) | `fuzz/fuzz_targets/fuzz_encoding_roundtrip.rs` |
| `fuzz_signature_verification` | Signature correctness (sign→verify), no-panic on random bytes, cross-key soundness | `fuzz/fuzz_targets/fuzz_signature_verification.rs` |
| `fuzz_replay_prevention` | A tx accepted in block N must be rejected when replayed in block N+1 (nonce consumed); fuzz-driven initial state exposes nonce edge cases (nonce 0, `u128::MAX`, zero-balance sender) | `fuzz/fuzz_targets/fuzz_replay_prevention.rs` |
| `fuzz_state_diff_computation` | **Forward containment**: `ValidatedStateDiff` only modifies accounts declared in `affected_public_account_ids()`; **Reverse completeness**: every declared account actually modified by `execute_check_on_state` appears in the diff | `fuzz/fuzz_targets/fuzz_state_diff_computation.rs` |
| `fuzz_validate_execute_consistency` | `validate_on_state` and `execute_check_on_state` must agree on success/failure; diff accuracy (forward + reverse); **BalanceConservation** on success | `fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs` |
| `fuzz_state_serialization` | `V03State` Borsh no-panic (**NoPanic**) + **StateSerializationRoundtrip** (`encode(decode(encode(decode(data)))) == encode(decode(data))`) + **NullifierDeduplication** (hand-written `NullifierSet` deserializer returns `Err`, not panic, on duplicate nullifiers) | `fuzz/fuzz_targets/fuzz_state_serialization.rs` |
| `fuzz_witness_set_verification` | `WitnessSet::is_valid_for` no-panic on adversarial input (**NoPanic**); **CorrectVerification** (`WitnessSet::for_message` always passes `is_valid_for` on the same message); **MessageIsolation** (witness set built for message A fails `is_valid_for` on any Borsh-distinct message B) | `fuzz/fuzz_targets/fuzz_witness_set_verification.rs` |
| `fuzz_program_deployment_lifecycle` | `V03State::transition_from_program_deployment_transaction` no-panic on arbitrary WASM bytecode (**NoPanic**); **BalanceIsolation** (successful deployment must not move tokens); **StateIsolationOnFailure** (failed deployment must not change any genesis account balance or nonce) | `fuzz/fuzz_targets/fuzz_program_deployment_lifecycle.rs` |
| `fuzz_apply_state_diff_split_path` | **SplitPathEquivalence**: for every known account, `validate_on_state` + `apply_state_diff` must produce exactly the same balance, nonce, data, and program_owner as `execute_check_on_state`; **NonceIncrementCorrectness**: nonce after the split path equals nonce after the direct path for all signer accounts (catches bugs in the two-step `apply_state_diff` nonce-increment logic) | `fuzz/fuzz_targets/fuzz_apply_state_diff_split_path.rs` |
| `fuzz_multi_block_state_sequence` | **LongRangeBalanceConservation**: total genesis-account balance identical before and after all N (≤ 16) blocks; **FailedTxNonceStability**: every genesis-account nonce unchanged after a rejected transaction; **PerBlockReplayRejection**: every transaction accepted in block B is rejected in block B+1 (cumulative nonce-interaction coverage) | `fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs` |
| `fuzz_sequencer_vs_replayer` | **SequencerReplayerEquivalence**: for every known account (genesis diff-declared), the sequencer path (`validate_on_state``apply_state_diff`) and the replayer path (`execute_check_on_state`) must produce identical balance, nonce, data, and program_owner after applying a full block of up to 8 transactions plus the mandatory clock invocation; **ReplayerAcceptsAllSequencerTxs**: every transaction accepted by `validate_on_state` must also be accepted by `execute_check_on_state`; **ClockConsistency**: the mandatory clock invocation must succeed on both paths and leave both states identical | `fuzz/fuzz_targets/fuzz_sequencer_vs_replayer.rs` |
---
## How to Add a New Fuzz Target
### Step 1 — Scaffold with `just new-target`
```bash
just new-target my_feature
```
This single command does four things automatically:
| What | Where |
|---|---|
| Creates the corpus directory | `fuzz/corpus/fuzz_my_feature/` |
| Writes a typed fuzz target template | `fuzz/fuzz_targets/fuzz_my_feature.rs` |
| Appends `[[bin]]` entry to `fuzz/Cargo.toml` | Covers **both** the libFuzzer and AFL++ lanes |
| Inserts target into every CI matrix + perf loop | `.github/workflows/fuzz.yml` |
The generated template uses `fuzz_props::fuzz_entry!` and works with both engines
without modification.
### Step 2 — Implement the target
Edit `fuzz/fuzz_targets/fuzz_my_feature.rs`. Replace the placeholder with the
function under test and any invariant assertions. Use the typed wrappers from
[`fuzz_props::arbitrary_types`](../fuzz_props/src/arbitrary_types.rs) for
structured input, or the proptest generators from
[`fuzz_props::generators`](../fuzz_props/src/generators.rs) for richer strategies.
### Step 3 — Automated registration (cargo-fuzz + CI)
`just new-target` calls [`scripts/add_fuzz_target.py`](../scripts/add_fuzz_target.py)
which:
- Appends the `[[bin]]` entry to [`fuzz/Cargo.toml`](../fuzz/Cargo.toml).
This **single entry** covers both the libFuzzer lane (`cargo fuzz build`) and
the AFL++ lane (`cargo afl build --no-default-features --features fuzzer-afl`).
- Inserts the target name into every strategy matrix and the perf-baseline shell
loop in [`.github/workflows/fuzz.yml`](../.github/workflows/fuzz.yml).
> **Manual fallback:** if you create a target without `just new-target`, add the
> entry yourself:
>
> ```toml
> [[bin]]
> name = "fuzz_my_feature"
> path = "fuzz_targets/fuzz_my_feature.rs"
> test = false
> bench = false
> ```
### Step 4 — Verify
```bash
# Verify the libFuzzer build
RISC0_DEV_MODE=1 cargo fuzz build fuzz_my_feature
just fuzz-regression # runs the new target against its (empty) corpus
# Verify the AFL++ build (same fuzz/Cargo.toml — no separate manifest needed)
cd fuzz && cargo afl build \
--no-default-features \
--features fuzzer-afl \
--release \
--bin fuzz_my_feature
```
### Quick reference: what to touch
| File | Action | Automated? |
|---|---|---|
| `fuzz/fuzz_targets/fuzz_<name>.rs` | Create | ✅ `just new-target` |
| `fuzz/corpus/fuzz_<name>/` | Create | ✅ `just new-target` |
| `fuzz/Cargo.toml` | Add `[[bin]]` (covers both lanes) | ✅ `just new-target` |
| `Justfile` | Nothing — auto-discovers | ✅ automatic |
| `.github/workflows/fuzz.yml` | Add to 3 matrix lists | ✅ `just new-target` |
---
## AFL++ Parallel Fuzzing Lane
### Prerequisites
Install AFL++ natively on your machine.
> **Note on Linux package versions**: The `afl++` package in Debian stable (Bookworm)
> and Ubuntu LTS is several major versions behind the current AFL++ 4.x series and may
> be incompatible with `cargo-afl`. **Build from source** for a current version.
```bash
# macOS — Homebrew keeps the formula up to date
brew install afl-fuzz
# Linux — build from source (~5 min)
git clone https://github.com/AFLplusplus/AFLplusplus
cd AFLplusplus
make distrib # builds all components: afl-fuzz, afl-cc, afl-clang-fast, …
sudo make install
cd ..
# Rust build wrapper (all platforms)
cargo install cargo-afl
```
> **macOS: crash reporter must be disabled** — AFL++ detects the macOS `ReportCrash`
> daemon and aborts if it is active (it delays crash notifications and causes AFL++ to
> mis-classify crashes as timeouts). The `just fuzz-afl` and `just fuzz-afl-parallel`
> recipes disable it automatically for the duration of the run and re-enable it on exit
> (via a shell `trap`). You can also manage it manually:
>
> ```bash
> # Disable (run once before a long session)
> just afl-macos-setup
>
> # Re-enable afterward
> just afl-macos-teardown
> ```
>
> Or use the raw `launchctl` commands shown in the AFL++ error message:
>
> ```bash
> SL=/System/Library; PL=com.apple.ReportCrash
> launchctl unload -w ${SL}/LaunchAgents/${PL}.plist
> sudo launchctl unload -w ${SL}/LaunchDaemons/${PL}.Root.plist
> ```
### Build
```bash
# All targets
just afl-build
# Single target
just afl-build-target fuzz_state_transition
```
Both commands compile `fuzz/` with `--no-default-features --features fuzzer-afl`.
Output binaries land in `fuzz/target/release/`.
### Run (single instance)
```bash
# 120-second smoke run
just fuzz-afl fuzz_state_transition
# Custom duration
just fuzz-afl fuzz_state_transition 600
```
### Run (parallel)
```bash
# 1 main + 3 secondary instances for 5 minutes
just fuzz-afl-parallel fuzz_state_transition 4 300
# AFL++ rule: always start the main instance first;
# secondary instances are started with -S flags automatically.
```
### Monitor
```bash
just afl-status fuzz_state_transition
# … calls afl-whatsup afl-output/fuzz_state_transition
```
### Triage
```bash
# Minimise a crash artifact to the smallest reproducing input
just afl-tmin fuzz_state_transition afl-output/fuzz_state_transition/default/crashes/id:000000,...
# Pretty-print as Rust byte literal (for pasting into a unit test)
just afl-fmt afl-output/fuzz_state_transition/default/crashes/id:000000,...
```
### Sync queue to shared corpus
```bash
# Copies afl-output/*/queue/id:* into fuzz/corpus/<target>/
# Run this after any AFL++ session to share findings with cargo-fuzz
just afl-corpus-sync
```
### How the shared harness works
| Mechanism | libFuzzer | AFL++ |
|---|---|---|
| **Entry macro** | `::libfuzzer_sys::fuzz_target!(…)` | `::afl::fuzz!(…)` inside `fn main()` |
| **`no_main` suppression** | `#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]` | Not applied (AFL++ needs a real `main`) |
| **Feature gate** | `cfg(feature = "fuzzer-libfuzzer")` | `cfg(feature = "fuzzer-afl")` |
| **Feature resolution** | Resolved at `fuzz/` (calling crate), not at `fuzz_props/` | Same |
| **`libfuzzer-sys` dep** | Optional, active under `fuzzer-libfuzzer` | Not compiled — avoids `main()` conflict |
| **`afl` dep** | Not compiled | Optional, active under `fuzzer-afl` |
| **Default build** | `default = ["fuzzer-libfuzzer"]``cargo fuzz` just works | Requires `--no-default-features --features fuzzer-afl` |
The `fuzz_props::fuzz_entry!` macro defined in [`fuzz_props/src/lib.rs`](../fuzz_props/src/lib.rs)
expands to the right entry point based on the active feature:
```rust
#[macro_export]
macro_rules! fuzz_entry {
(|$data:ident: &[u8]| $body:block) => {
#[cfg(feature = "fuzzer-libfuzzer")]
::libfuzzer_sys::fuzz_target!(|$data: &[u8]| $body);
#[cfg(feature = "fuzzer-afl")]
fn main() {
::afl::fuzz!(|$data: &[u8]| $body);
}
};
}
```
### CI (`.github/workflows/fuzz-afl.yml`)
The nightly AFL++ CI workflow has two jobs:
| Job | Triggers | Matrix |
|-----|----------|--------|
| `afl-smoke` | nightly + `workflow_dispatch` | 7 priority targets, 120 s each |
| `afl-coverage` | nightly, `needs: afl-smoke` | 3 key targets; LLVM HTML report |
The smoke job:
1. Builds the target with `cargo afl build --no-default-features --features fuzzer-afl`
2. Runs `afl-fuzz` for 120 s in `aflplusplus/aflplusplus:v4.40c` container
3. Syncs new queue entries into `fuzz/corpus/<target>/` and opens a corpus PR
4. Uploads crashes/hangs as a workflow artifact
The coverage job:
1. Downloads the smoke findings
2. Rebuilds with `RUSTFLAGS="-C instrument-coverage"`
3. Runs all corpus + queue inputs through the binary
4. Merges `.profraw``.profdata` → HTML report via `llvm-cov show`
---
## Updating the LEZ Dependency
`lez-fuzzing` reads LEZ source directly from `../logos-execution-zone`. To pick up LEZ
changes, simply update that repo:
```bash
cd ../logos-execution-zone
git pull --ff-only
cd ../lez-fuzzing
# Rebuild to confirm compatibility:
cargo build -p fuzz_props
RISC0_DEV_MODE=1 cargo fuzz build
```
The `just update-lez` recipe automates the pull:
```bash
just update-lez
```
---
## Minimising & Reproducing Failures
When `cargo fuzz` finds a crash it writes an artifact to
`fuzz/artifacts/fuzz_<target>/crash-<hash>`.
### Minimise (libFuzzer)
```bash
# Produces a smaller input that still triggers the same crash
just fuzz-tmin fuzz_state_transition fuzz/artifacts/fuzz_state_transition/crash-abc123
```
### Minimise (AFL++)
```bash
just afl-tmin fuzz_state_transition afl-output/fuzz_state_transition/default/crashes/id:000000,...
```
### Convert to a regression test
```bash
# libFuzzer: print bytes as a Rust byte-literal
cargo fuzz fmt fuzz_state_transition fuzz/artifacts/fuzz_state_transition/crash-abc123
# AFL++: print bytes as a Rust byte-literal
just afl-fmt afl-output/fuzz_state_transition/default/crashes/id:000000,...
```
Add the minimised file to the corpus so CI always reproduces it:
```bash
cp fuzz/artifacts/fuzz_state_transition/crash-abc123-minimised \
fuzz/corpus/fuzz_state_transition/regression_001
```
Open a PR. The `regression` CI job will permanently block re-introduction of this bug.
---
## Coverage Reports
### Step 1 — libFuzzer coverage (via `cargo fuzz coverage`)
```bash
# Generates coverage for a single target
cargo fuzz coverage fuzz_state_transition
# Generates coverage for all targets
just coverage-all
```
Reports land in `fuzz/coverage/<target>/`.
### Step 2 — AFL++ LLVM coverage
Run after a successful AFL++ session (queue data in `afl-output/<target>/`):
```bash
# Combines libFuzzer + AFL++ corpus into a single LLVM HTML report
just coverage fuzz_state_transition
```
This:
1. Runs `cargo fuzz coverage` (step 1)
2. Detects `afl-output/fuzz_state_transition/` and builds the target with
`RUSTFLAGS="-C instrument-coverage" cargo build --manifest-path fuzz/Cargo.toml --no-default-features --features fuzzer-afl --release`
3. Runs all AFL++ queue entries through the binary, collects `.profraw` files
4. Merges profiles with `llvm-profdata merge` and generates an HTML report with `llvm-cov show`
5. Writes the report to `coverage/afl/fuzz_state_transition/html/index.html`
The AFL++ CI coverage job (`afl-coverage` in [`.github/workflows/fuzz-afl.yml`](../.github/workflows/fuzz-afl.yml))
automates steps 25 and uploads the report as a workflow artifact.
---
## Invariant Framework
Shared invariants live in `fuzz_props/src/invariants.rs`. Each invariant implements
`ProtocolInvariant` and is automatically run by `assert_invariants()`.
Concrete invariants currently registered in `assert_invariants()`:
| Invariant | Description | Implementation status |
|-----------|-------------|----------------------|
| `StateIsolationOnFailure` | Per-account balance must not change for any tracked account when a transaction is rejected | ✅ Fully implemented |
| `BalanceConservation` | Total balance of all known accounts must be conserved when a transaction succeeds | ✅ Fully implemented |
| `FailedTxNonceStability` | Every account's nonce must remain unchanged when a transaction is rejected | ✅ Fully implemented |
| `ReplayRejection` | An accepted transaction must be rejected when replayed | ⚠️ Registry stub — always returns `None` from `InvariantCtx`; use `assert_replay_rejection()` directly (see note below) |
| `NonceIncrementCorrectness` | Every signer account's nonce must be incremented by exactly one after a successful transaction | ⚠️ Registry stub — always returns `None` from `InvariantCtx`; use `assert_nonce_increment_correctness()` directly (see note below) |
> **Note on stub invariants:** `ReplayRejection` and `NonceIncrementCorrectness` cannot be
> fully exercised through `InvariantCtx` alone. Each requires information that is consumed
> before `InvariantCtx` is built:
>
> - **`ReplayRejection`**: `execute_check_on_state` returns the `NSSATransaction` on `Ok`,
> consuming `self`. Replaying it requires re-applying the returned transaction to the
> post-execution state — not possible via a shared `&InvariantCtx`. Use the standalone
> `assert_replay_rejection(applied_tx, state, next_block_id, next_timestamp)` helper
> immediately after each successful execution. The proptest suite `replay_rejection_proptest`
> in `fuzz_props/src/invariants.rs` provides reproducible structured coverage of this
> invariant.
>
> - **`NonceIncrementCorrectness`**: `apply_state_diff` consumes the `ValidatedStateDiff`
> whose signer-account list is private to the `nssa` crate. The caller must derive signer
> IDs from the transaction's witness set before consuming the diff, then call the standalone
> `assert_nonce_increment_correctness(signer_ids, nonces_before, state_after)` helper.
> The `signer_account_ids()` helper in `fuzz_props::generators` extracts signer `AccountId`s
> from an `NSSATransaction`'s witness set.
Additional invariants enforced **inline** in specific targets (not via `ProtocolInvariant`):
| Invariant | Targets |
|-----------|---------|
| `HashRoundTrip` / `HashPreimage` / `TxOrderCommitment` | `fuzz_block_verification` |
| Diff forward containment / reverse completeness | `fuzz_state_diff_computation` |
To add a new invariant:
1. Add a zero-size struct implementing `ProtocolInvariant`.
2. Register it in the `invariants` slice inside `assert_invariants()`.
3. Write a `#[test]` in `fuzz_props` that triggers and detects a synthetic violation.
---
## Input Generators
The `fuzz_props` crate provides two layers of input generation:
### `fuzz_props::arbitrary_types` (libFuzzer / `Arbitrary`)
Typed wrappers that implement `Arbitrary` for LEZ structs. Use them directly as
fuzz target parameters for zero-boilerplate structured fuzzing.
| Wrapper | Wraps |
|---------|-------|
| `ArbAccountId` | `AccountId` (any 32-byte array) |
| `ArbNonce` | `Nonce` (any `u128`) |
| `ArbPrivateKey` | `PrivateKey` (valid scalar; known-good fallback for the negligible invalid range) |
| `ArbPublicKey` | `PublicKey` (50 % derived from a valid private key; 50 % raw bytes with fallback) |
| `ArbSignature` | `Signature` (random 64-byte value; may be cryptographically invalid) |
| `ArbPubTxMessage` | `Message` for `PublicTransaction` (07 accounts, arbitrary instruction data) |
| `ArbWitnessSet` | `WitnessSet` (03 `(Signature, PublicKey)` pairs; mixes valid and invalid) |
| `ArbPublicTransaction` | `PublicTransaction` (composed from `ArbPubTxMessage` + `ArbWitnessSet`) |
| `ArbProgramDeploymentTransaction` | `ProgramDeploymentTransaction` (arbitrary bytecode) |
| `ArbHashableBlockData` | `HashableBlockData` (07 `ArbNSSATransaction` entries, random header fields) |
| `ArbNSSATransaction` | `NSSATransaction` (`Public` or `ProgramDeployment` variant; `PrivacyPreserving` excluded) |
### `fuzz_props::generators` (libFuzzer helpers + proptest strategies)
| Generator | Covers |
|-----------|--------|
| `arbitrary_fuzz_state()` | 18 fuzz-driven accounts with arbitrary IDs, balances, and private keys; used by `fuzz_state_transition`, `fuzz_replay_prevention`, `fuzz_validate_execute_consistency`, `fuzz_state_diff_computation` |
| `arb_fuzz_native_transfer()` | Correctly-signed native-transfer `NSSATransaction` referencing accounts from an `arbitrary_fuzz_state()` result; gives the fuzzer a path to successful state transitions |
| `arbitrary_transaction()` | Structured `NSSATransaction` (`Public` or `ProgramDeployment`) from unstructured bytes via `ArbNSSATransaction` |
| `arb_borsh_transaction_bytes()` | Raw Borsh bytes including invalid encodings |
| `signer_account_ids()` | Extracts `AccountId`s of all signers from an `NSSATransaction`'s witness set; used to derive signer IDs before `apply_state_diff` consumes the diff |
| `arb_native_transfer_tx()` | Valid native-transfer `NSSATransaction` between known testnet genesis accounts (proptest strategy) |
| `test_accounts()` | Returns `(AccountId, PrivateKey)` pairs from `testnet_initial_state` |
| `arb_hashable_block_data()` | `HashableBlockData` with 08 valid native transfers (proptest strategy) |
| `arb_invalid_account_state_tx()` | Phantom accounts + overflow amounts — expected to be rejected (IS-3) |
| `arb_duplicate_tx_sequence()` | Duplicated + re-ordered transaction sequences (IS-4) |
| `arb_pathological_sequence()` | Zero-value, self-transfer, max-nonce inputs (IS-5) |
---
## Performance Baseline
Measured on a 4-core x86_64 Linux runner with `RISC0_DEV_MODE=1`:
| Target | Throughput |
|--------|-----------|
| `fuzz_transaction_decoding` | ~200 000 exec/sec |
| `fuzz_stateless_verification` | ~30 000 exec/sec |
| `fuzz_state_transition` | ~5 000 exec/sec |
| `fuzz_block_verification` | ~50 000 exec/sec |
| `fuzz_encoding_roundtrip` | ~150 000 exec/sec |
| `fuzz_signature_verification` | ~20 000 exec/sec |
| `fuzz_replay_prevention` | ~5 000 exec/sec |
| `fuzz_state_diff_computation` | ~10 000 exec/sec |
| `fuzz_validate_execute_consistency` | ~3 000 exec/sec |
| `fuzz_state_serialization` | ~100 000 exec/sec *(estimate)* |
| `fuzz_witness_set_verification` | ~15 000 exec/sec *(estimate)* |
| `fuzz_program_deployment_lifecycle` | ~4 000 exec/sec *(estimate)* |
| `fuzz_apply_state_diff_split_path` | ~5 000 exec/sec *(estimate)* |
| `fuzz_multi_block_state_sequence` | ~1 000 exec/sec *(estimate)* |
| `fuzz_sequencer_vs_replayer` | ~2 000 exec/sec *(estimate)* |
> Throughput figures for the five new targets are rough estimates; run `just perf-baseline`
> locally or check the `perf-baseline` CI artifact for up-to-date measurements.
Recommended local settings for longer runs:
```bash
# libFuzzer — use all available cores
RISC0_DEV_MODE=1 cargo fuzz run fuzz_state_transition \
-- -max_total_time=3600 -jobs=$(nproc) -workers=$(nproc)
# AFL++ — parallel (1 main + N-1 secondary)
just fuzz-afl-parallel fuzz_state_transition $(nproc) 3600
```
---
## ZK-Proof Cost Warning
`PrivacyPreservingTransaction` uses `risc0-zkvm` (seconds per proof).
All fuzz targets **must** set `RISC0_DEV_MODE=1` in the environment and the `just`
recipes handle this automatically via:
```just
export RISC0_DEV_MODE := "1"
```
Do **not** invoke full proof generation inside any fuzz target. The `RISC0_DEV_MODE=1`
flag stubs out ZK proof generation and replaces it with a fast mock implementation.
---
## Known Limitations & Future Work
| Item | Notes |
|------|-------|
| `PrivacyPreservingTransaction` coverage | Excluded from `fuzz_encoding_roundtrip` because its ZK receipt cannot be reconstructed in a fuzzing loop. A dedicated slow target with `RISC0_DEV_MODE=1` and `proptest` should be added after the current targets are stable |
| `fuzz_validate_execute_consistency` new-account detection | If `execute_check_on_state` creates a brand-new account absent from both the genesis set and the diff, that state-widening will not be detected — full detection requires iterating all accounts in `V03State`, which the API does not currently expose |
| Differential testing (sequencer vs replayer) | ✅ Implemented — `fuzz_sequencer_vs_replayer` feeds the same block through the sequencer path (`validate_on_state``apply_state_diff`) and the replayer path (`execute_check_on_state`) and asserts identical state for all known accounts |
| AFL++ integration | ✅ Implemented — `just afl-build`, `just fuzz-afl`, `just fuzz-afl-parallel`; nightly CI in `.github/workflows/fuzz-afl.yml`; single `fuzz/Cargo.toml` covers both engines via feature flags |
| LEZ version tracking | There is no submodule pin — `lez-fuzzing` reads `../logos-execution-zone` as checked out. Update that repo to a release tag or a tested commit, then run `just update-lez` (which does `git pull --ff-only`) and open a PR to bump it |