21 KiB
LEZ Fuzzing — QA Team Presentation
Project:
lez-fuzzing— Automated Fuzz Testing for the Logos Execution Zone (LEZ) Audience: QA Team Date: April 2026
Agenda
- What Is This Project?
- Why Fuzzing? (Not Just Unit Tests)
- Architecture Overview
- What We Are Testing — 9 Fuzz Targets
- Protocol Invariants — The Safety Net
- Input Generation Strategy
- How to Run Locally
- CI/CD Integration
- Performance Characteristics
- Known Limitations & Future Work
- Key Takeaways for QA
1. What Is This Project?
lez-fuzzing is a coverage-guided, structured mutation fuzzing system for the Logos Execution Zone (LEZ) blockchain protocol.
High-Level Context
<parent directory>/
├── logos-execution-zone/ ← Production codebase (LEZ protocol)
│ ├── nssa/ ← Node State & State Accumulator
│ ├── common/ ← Shared types (transactions, blocks)
│ └── key_protocol/ ← Cryptographic primitives
└── lez-fuzzing/ ← This repository (fuzzing harness)
├── fuzz_props/ ← Reusable: generators + invariants
└── fuzz/ ← Fuzz targets + pre-seeded corpus
└── fuzz_targets/ ← 9 individual fuzz entry points
What the Fuzzer Does
The fuzzer automatically generates millions of malformed, adversarial, and boundary-case inputs and feeds them into the protocol. It then checks that:
- The process never panics or crashes unexpectedly
- Protocol invariants (safety rules) are never violated
- Encoding/decoding is lossless and deterministic
- State integrity is preserved even on rejected transactions
2. Why Fuzzing? (Not Just Unit Tests)
The Gap Unit Tests Leave
Unit tests check what engineers think of in advance. Fuzzing discovers what engineers don't think of.
| Technique | Finds Known Bugs | Finds Unknown Bugs | Coverage Guidance | Scale |
|---|---|---|---|---|
| Unit tests | ✅ | ❌ | Manual | Hundreds of cases |
| Property tests (proptest) | ✅ | Partial | ❌ None | Thousands of cases |
| Fuzzing (libFuzzer) | ✅ | ✅ | ✅ LLVM-driven | Millions/sec |
Bugs Fuzzing Is Uniquely Good At Finding
- Panic on malformed input — decoder receives garbled bytes → should return
Err, not crash - State leakage on rejection — a rejected transaction changes account balances (silent corruption)
- Replay attacks — a transaction accepted in block N is accepted again in block N+1
- Encoding non-determinism —
encode(decode(encode(x))) ≠ encode(x) - Integer overflow / underflow in balance arithmetic
- Phantom account attacks — transfers from accounts that don't exist in genesis state
Why This Matters for a Blockchain Protocol
On a blockchain, a single invariant violation can lead to:
- Double-spend (state leakage on failure or replay acceptance)
- Consensus split (non-deterministic hashing)
- Fund loss (overflow in balance computation)
3. Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ lez-fuzzing │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ fuzz_props crate │ │
│ │ │ │
│ │ arbitrary_types.rs ← Typed Arbitrary wrappers │ │
│ │ generators.rs ← proptest + libFuzzer helpers │ │
│ │ invariants.rs ← ProtocolInvariant trait │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ fuzz/fuzz_targets/ │ │
│ │ │ │
│ │ fuzz_transaction_decoding.rs │ │
│ │ fuzz_stateless_verification.rs │ │
│ │ fuzz_state_transition.rs (9 targets) │ │
│ │ fuzz_block_verification.rs │ │
│ │ fuzz_encoding_roundtrip.rs │ │
│ │ fuzz_signature_verification.rs │ │
│ │ fuzz_replay_prevention.rs │ │
│ │ fuzz_state_diff_computation.rs │ │
│ │ fuzz_validate_execute_consistency.rs │ │
│ └──────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ fuzz/corpus/ (pre-seeded) │ │
│ │ ~150 minimised seed files per target │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
↕ path dependencies
┌─────────────────────────────────────────────────────────────┐
│ ../logos-execution-zone (LEZ) │
│ nssa · common · key_protocol · token_core … │
└─────────────────────────────────────────────────────────────┘
Technology Stack
| Component | Technology |
|---|---|
| Fuzzer engine | libFuzzer via cargo-fuzz |
| Coverage instrumentation | LLVM SanitizerCoverage |
| Structured input generation | arbitrary crate (typed wrappers) |
| Property-based strategies | proptest |
| ZK proof layer | RISC0 (stubbed out with RISC0_DEV_MODE=1) |
| Serialization format | Borsh (binary object representation) |
| Language | Rust (nightly toolchain) |
| CI | GitHub Actions |
4. What We Are Testing — 9 Fuzz Targets
Target Map
| # | Target | What Is Being Tested | Key Invariant |
|---|---|---|---|
| 1 | fuzz_transaction_decoding |
Borsh decode of all tx/block types | Never panic; roundtrip stable |
| 2 | fuzz_stateless_verification |
transaction_stateless_check() signature validation |
No panic on any input |
| 3 | fuzz_state_transition |
V03State::transition_from_*() with 0–8 txs |
Balances unchanged on rejection |
| 4 | fuzz_block_verification |
Block hash integrity + replayer pipeline | block_hash() is deterministic |
| 5 | fuzz_encoding_roundtrip |
decode(encode(tx)) == Ok(tx) |
Encoding is lossless |
| 6 | fuzz_signature_verification |
Sign→verify correctness, cross-key soundness | No false positive verifications |
| 7 | fuzz_replay_prevention |
Tx accepted in block N rejected in block N+1 | Nonce consumed, replay blocked |
| 8 | fuzz_state_diff_computation |
ValidatedStateDiff scope correctness |
Only declared accounts modified |
| 9 | fuzz_validate_execute_consistency |
validate_on_state vs execute_check_on_state agree |
No divergence between validators |
Target Deep-Dives
fuzz_state_transition — The Core Safety Target
This is the most important target from a protocol correctness standpoint.
Input bytes
↓
[Generate up to 8 transactions from fuzz bytes]
↓
[Filter: pass only stateless-valid txs]
↓
[Apply each tx to V03State]
↓
[INVARIANT CHECK on rejection]:
for every account in genesis:
assert balance_before == balance_after
What it catches: Any code path where a rejected transaction silently mutates account state — the classic "partial write" class of state corruption bug.
fuzz_transaction_decoding — The Crash-Safety Target
fuzz_target!(|data: &[u8]| {
// 1. If it decodes: roundtrip must be stable
if let Ok(tx) = borsh::from_slice::<NSSATransaction>(data) {
let re_encoded = borsh::to_vec(&tx).expect("must succeed");
// assert stability...
}
// 2. Block decode: must never panic
let _ = borsh::from_slice::<Block>(data);
// 3. HashableBlockData decode: must never panic
let _ = borsh::from_slice::<HashableBlockData>(data);
});
What it catches: Panics in the Borsh decoder when receiving malformed bytes (e.g., truncated input, wrong variant tags, overflow in length fields).
fuzz_block_verification — Determinism Target
fuzz_target!(|data: &[u8]| {
let Ok(block) = borsh::from_slice::<Block>(data) else { return; };
let hashable = HashableBlockData::from(block.clone());
let hash1 = hashable.block_hash(); // first call
let hash2 = hashable.block_hash(); // second call — must match
assert_eq!(hash1, hash2, "block_hash() is not deterministic");
});
What it catches: Non-deterministic hashing — a critical consensus bug where two nodes compute different block hashes for the same block content.
5. Protocol Invariants — The Safety Net
The fuzz_props/src/invariants.rs module defines a pluggable invariant framework.
The ProtocolInvariant Trait
pub trait ProtocolInvariant {
fn name(&self) -> &'static str;
fn check(&self, ctx: &InvariantCtx<'_>) -> Option<InvariantViolation>;
}
Every invariant receives a snapshot of the world before and after a transaction:
pub struct InvariantCtx<'a> {
pub state_before: &'a V03State,
pub state_after: &'a V03State,
pub tx: &'a NSSATransaction,
pub result: &'a Result<(), NssaError>,
pub balances_before: BalanceSnapshot,
}
Currently Registered Invariants
| Invariant | Rule |
|---|---|
StateIsolationOnFailure |
If a tx is rejected, all account balances must be identical before and after |
ReplayRejection |
A tx accepted in block N must be rejected in block N+1 (nonce consumed) |
How to Add a New Invariant (for QA team)
// 1. Define a zero-size struct
pub struct BalanceConservation;
// 2. Implement the trait
impl ProtocolInvariant for BalanceConservation {
fn name(&self) -> &'static str { "BalanceConservation" }
fn check(&self, ctx: &InvariantCtx<'_>) -> Option<InvariantViolation> {
let before = ctx.balances_before.total();
let after = /* sum state_after balances */;
if before != after {
Some(InvariantViolation {
invariant: self.name(),
message: format!("total balance changed: {} → {}", before, after),
})
} else {
None
}
}
}
// 3. Register in assert_invariants()
let invariants: &[&dyn ProtocolInvariant] = &[
&StateIsolationOnFailure,
&ReplayRejection,
&BalanceConservation, // ← new
];
6. Input Generation Strategy
The fuzz_props/src/generators.rs module provides two generation layers:
Layer 1 — Typed Arbitrary Wrappers (for libFuzzer)
These give libFuzzer structured, valid-looking inputs instead of random bytes:
| Wrapper | Generates |
|---|---|
ArbNSSATransaction |
Full transaction with realistic fields |
ArbPublicTransaction |
Native token transfer |
ArbProgramDeploymentTransaction |
Smart contract deploy |
ArbPrivateKey / ArbPublicKey |
Cryptographic key pairs |
ArbSignature |
ECDSA signatures |
Why this matters: Without typed wrappers, libFuzzer would spend most of its time generating bytes that fail Borsh deserialization at the outermost layer — never reaching deeper code paths.
Layer 2 — proptest Strategies (richer adversarial scenarios)
| Strategy | Tests Scenario |
|---|---|
arb_native_transfer_tx() |
Valid transfer between known genesis accounts |
arb_borsh_transaction_bytes() |
Valid + intentionally invalid Borsh encodings |
arb_invalid_account_state_tx() |
Phantom accounts, overflow amounts (IS-3) |
arb_duplicate_tx_sequence() |
Duplicated + re-ordered tx sequences (IS-4) |
arb_pathological_sequence() |
Zero-value, self-transfer, max-nonce inputs (IS-5) |
arb_hashable_block_data() |
Block with 0–8 native transfers |
The Hybrid Approach
libFuzzer mutation engine
↓
arbitrary bytes
↓
arbitrary_transaction()
├── 50%: ArbNSSATransaction (structured)
└── 50%: raw Borsh decode (may fail → libFuzzer learns)
This hybrid means half the inputs are structurally valid (reach deep code), and half stress the decoder boundary.
7. How to Run Locally
Prerequisites
# Nightly Rust is required by cargo-fuzz / libFuzzer
rustup install nightly
rustup component add llvm-tools-preview --toolchain nightly
cargo install cargo-fuzz
Repository Setup
# Clone both repositories side-by-side:
git clone <LEZ_REPO_URL> logos-execution-zone
git clone <LEZ_FUZZING_REPO_URL> lez-fuzzing
# Required directory layout:
# <parent>/
# ├── logos-execution-zone/
# └── lez-fuzzing/ ← work from here
Common Commands
# Run ALL targets for 30 seconds each (smoke test)
just fuzz
# Run regression suite (no mutations — just corpus replay)
just fuzz-regression
# Run a specific target for 2 minutes
RISC0_DEV_MODE=1 cargo fuzz run fuzz_state_transition -- -max_total_time=120
# Minimise a crash artifact
just fuzz-tmin fuzz_state_transition fuzz/artifacts/fuzz_state_transition/crash-abc123
# View all available targets
cargo fuzz list
⚠️ Always use
RISC0_DEV_MODE=1— without it, ZK proof generation runs at ~1 proof/sec, making fuzzing impractical. Thejustrecipes set this automatically.
Adding a New Fuzz Target
# Scaffold everything automatically (corpus dir, target file, Cargo.toml, CI matrix)
just new-target my_feature
# Then implement the target body
$EDITOR fuzz/fuzz_targets/fuzz_my_feature.rs
# Build and verify
RISC0_DEV_MODE=1 cargo fuzz build fuzz_my_feature
just fuzz-regression
8. CI/CD Integration
Three GitHub Actions jobs run on every pull request:
| Job | Trigger | What It Does | Duration |
|---|---|---|---|
smoke-fuzz |
Every PR | Runs each target for 30 seconds | ~5 min |
regression |
Every PR | Replays all corpus files (no mutations) | ~2 min |
perf-baseline |
Every PR | Measures exec/sec and fails if throughput drops >20% | ~10 min |
Failure Workflow
CI detects crash
↓
cargo fuzz tmin → minimised input
↓
cargo fuzz fmt → Rust byte literal
↓
Add to corpus/ → permanent regression test
↓
Open PR → regression job blocks reintroduction forever
9. Performance Characteristics
Measured on a 4-core x86_64 Linux runner with RISC0_DEV_MODE=1:
| Target | Throughput | Why |
|---|---|---|
fuzz_transaction_decoding |
~200,000 exec/sec | Pure decode, no state |
fuzz_encoding_roundtrip |
~150,000 exec/sec | Decode + encode, no state |
fuzz_block_verification |
~50,000 exec/sec | Hash computation |
fuzz_state_transition |
~5,000 exec/sec | Full state machine execution |
fuzz_replay_prevention |
~5,000 exec/sec | Two state transitions per input |
fuzz_validate_execute_consistency |
~3,000 exec/sec | Two paths compared |
Running on All Cores for Long Sessions
RISC0_DEV_MODE=1 cargo fuzz run fuzz_state_transition \
-- -max_total_time=3600 -jobs=$(nproc) -workers=$(nproc)
10. Known Limitations & Future Work
Current Gaps
| Gap | Impact | Status |
|---|---|---|
StateIsolationOnFailure invariant is a partial placeholder |
Balance corruption may go undetected | Known — needs full account iterator API from LEZ |
PrivacyPreservingTransaction excluded from encoding roundtrip |
ZK receipts can't be reconstructed in fuzzing loop | Documented; dedicated slow target planned |
| No version pin between repos | Stale LEZ checkout silently fuzzes wrong code | Known limitation — just update-lez is manual |
Highest-Value Future Extensions (Priority Order)
-
Complete stub invariants in
fuzz_props/src/invariants.rs—StateIsolationOnFailureandReplayRejectionneed their full implementations. Cost: < 1 day. Impact: immediately hardens all 9 targets. -
Sequencer-vs-Replayer differential target — Feed the same block to
SequencerCoreandindexer_core, assert identical state roots. Catches consensus-splitting divergence. Cost: 1–2 engineer-weeks. Impact: unique bug class not catchable any other way. -
Add AFL++ as a parallel fuzzing lane (
just fuzz-afl) — Same corpus, different mutation engine, finds different bugs. Cost: ~1 day. Zero corpus migration. -
Add
cargo-mutantsbefore security audit — Proves the invariant assertions are actually capable of catching the bugs they claim to detect. Cost: ~1 day. -
Co-locate fuzz/ into logos-execution-zone/ — Eliminates version drift; standard
cargo fuzzconvention. LEZ CI would runcargo fuzz buildon every PR. Cost: ~1 day migration.
11. Key Takeaways for QA
What the Fuzzer Covers
✅ No crash on any byte sequence — all decoders handle malformed input gracefully ✅ State integrity on rejection — failed transactions don't mutate balances ✅ Replay protection — spent nonces are permanently rejected ✅ Encoding determinism — identical inputs produce identical bytes every time ✅ Signature soundness — no false positives, no cross-key verification ✅ Diff scope — state changes only affect declared accounts
What the Fuzzer Does NOT Cover
⚠️ Business logic correctness — fuzzing checks safety properties, not "is the amount correct"
⚠️ ZK proof validity — mocked out; proofs are not generated during fuzzing
⚠️ Network/consensus layer — only state machine and encoding layers are fuzzed
⚠️ PrivacyPreservingTransaction encoding roundtrip — excluded (ZK receipts)
QA Team Action Items
| Action | Who | When |
|---|---|---|
Run just fuzz-regression before merging LEZ changes |
Dev / QA | Each LEZ PR |
Review crash artifacts in fuzz/artifacts/ when CI fails |
QA | On CI failure |
| Add new invariants when new protocol rules are introduced | QA + Dev | Feature additions |
Run just update-lez before long fuzzing sessions |
QA | Before overnight runs |
| Add new fuzz targets for new transaction types | QA + Dev | New tx types |
Appendix: Project File Map
| File / Directory | Purpose |
|---|---|
fuzz_props/src/invariants.rs |
ProtocolInvariant trait + registered invariants |
fuzz_props/src/generators.rs |
proptest strategies + Arbitrary helpers |
fuzz/fuzz_targets/ |
9 fuzz entry points |
fuzz/corpus/ |
Pre-seeded corpus files (minimised, binary) |
docs/fuzzing.md |
Full operational guide (how-to, commands, CI) |
current_vs_alternative_approach.md |
Comparison with AFL++, proptest-only, formal verification |
colocated_vs_separated.md |
Architecture decision: separate repo vs. co-located |
fuzz/Cargo.toml |
Fuzz workspace manifest (all 9 [[bin]] entries) |
Cargo.toml |
Root workspace (fuzz_props + LEZ path dependencies) |
Presentation generated from the lez-fuzzing repository. For full operational details, see docs/fuzzing.md.