lez-fuzzing/presentation_qa_team.md
2026-04-24 14:37:11 +08:00

21 KiB
Raw Blame History

LEZ Fuzzing — QA Team Presentation

Project: lez-fuzzing — Automated Fuzz Testing for the Logos Execution Zone (LEZ) Audience: QA Team Date: April 2026


Agenda

  1. What Is This Project?
  2. Why Fuzzing? (Not Just Unit Tests)
  3. Architecture Overview
  4. What We Are Testing — 9 Fuzz Targets
  5. Protocol Invariants — The Safety Net
  6. Input Generation Strategy
  7. How to Run Locally
  8. CI/CD Integration
  9. Performance Characteristics
  10. Known Limitations & Future Work
  11. 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-determinismencode(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 08 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 08 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. The just recipes 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)

  1. Complete stub invariants in fuzz_props/src/invariants.rsStateIsolationOnFailure and ReplayRejection need their full implementations. Cost: < 1 day. Impact: immediately hardens all 9 targets.

  2. Sequencer-vs-Replayer differential target — Feed the same block to SequencerCore and indexer_core, assert identical state roots. Catches consensus-splitting divergence. Cost: 12 engineer-weeks. Impact: unique bug class not catchable any other way.

  3. Add AFL++ as a parallel fuzzing lane (just fuzz-afl) — Same corpus, different mutation engine, finds different bugs. Cost: ~1 day. Zero corpus migration.

  4. Add cargo-mutants before security audit — Proves the invariant assertions are actually capable of catching the bugs they claim to detect. Cost: ~1 day.

  5. Co-locate fuzz/ into logos-execution-zone/ — Eliminates version drift; standard cargo fuzz convention. LEZ CI would run cargo fuzz build on 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.