Merge pull request #3 from logos-blockchain/chore-mutants

chore: Test quality diagnostics with cargo mutants
This commit is contained in:
Roman Zajic 2026-06-16 09:14:50 +08:00 committed by GitHub
commit 1558d21406
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 4588 additions and 685 deletions

1
.gitattributes vendored Normal file
View File

@ -0,0 +1 @@
corpus/** linguist-generated=true

View File

@ -41,6 +41,11 @@ jobs:
- fuzz_transaction_decoding
- fuzz_validate_execute_consistency
- fuzz_witness_set_verification
- fuzz_merkle_tree
- fuzz_transaction_properties
- fuzz_privacy_preserving_witness
- fuzz_encoding_privacy_preserving
- fuzz_nullifier_set_roundtrip
steps:
- name: Checkout repository
@ -239,7 +244,7 @@ jobs:
if-no-files-found: ignore
# ────────────────────────────────────────────────────────────────────────────
# afl-coverage-aggregate — single HTML report merging all 15 targets
# afl-coverage-aggregate — single HTML report merging all 20 targets
# ────────────────────────────────────────────────────────────────────────────
afl-coverage-aggregate:
name: "AFL++ coverage — aggregated"
@ -302,6 +307,11 @@ jobs:
fuzz_transaction_decoding
fuzz_validate_execute_consistency
fuzz_witness_set_verification
fuzz_merkle_tree
fuzz_transaction_properties
fuzz_privacy_preserving_witness
fuzz_encoding_privacy_preserving
fuzz_nullifier_set_roundtrip
)
for TARGET in "${TARGETS[@]}"; do
cargo build \
@ -330,6 +340,11 @@ jobs:
fuzz_transaction_decoding
fuzz_validate_execute_consistency
fuzz_witness_set_verification
fuzz_merkle_tree
fuzz_transaction_properties
fuzz_privacy_preserving_witness
fuzz_encoding_privacy_preserving
fuzz_nullifier_set_roundtrip
)
PROFRAW_DIR="coverage/afl/aggregated/profraw"
mkdir -p "$PROFRAW_DIR"
@ -409,6 +424,11 @@ jobs:
fuzz_transaction_decoding
fuzz_validate_execute_consistency
fuzz_witness_set_verification
fuzz_merkle_tree
fuzz_transaction_properties
fuzz_privacy_preserving_witness
fuzz_encoding_privacy_preserving
fuzz_nullifier_set_roundtrip
)
# llvm-cov show: first binary is a positional arg; the rest use --object
first=1

View File

@ -35,6 +35,11 @@ jobs:
- fuzz_apply_state_diff_split_path
- fuzz_multi_block_state_sequence
- fuzz_sequencer_vs_replayer
- fuzz_merkle_tree
- fuzz_transaction_properties
- fuzz_privacy_preserving_witness
- fuzz_encoding_privacy_preserving
- fuzz_nullifier_set_roundtrip
steps:
- uses: actions/checkout@v4
@ -218,6 +223,11 @@ jobs:
- fuzz_apply_state_diff_split_path
- fuzz_multi_block_state_sequence
- fuzz_sequencer_vs_replayer
- fuzz_merkle_tree
- fuzz_transaction_properties
- fuzz_privacy_preserving_witness
- fuzz_encoding_privacy_preserving
- fuzz_nullifier_set_roundtrip
steps:
- uses: actions/checkout@v4
- name: Checkout logos-execution-zone
@ -285,7 +295,12 @@ jobs:
fuzz_program_deployment_lifecycle \
fuzz_apply_state_diff_split_path \
fuzz_multi_block_state_sequence \
fuzz_sequencer_vs_replayer; do
fuzz_sequencer_vs_replayer \
fuzz_merkle_tree \
fuzz_transaction_properties \
fuzz_privacy_preserving_witness \
fuzz_encoding_privacy_preserving \
fuzz_nullifier_set_roundtrip; do
echo "=== $target ===" | tee -a perf_baseline.txt
cargo fuzz run "$target" -- -max_total_time=30 2>&1 \
| grep -E "exec/s|execs_per_sec" | tail -1 | tee -a perf_baseline.txt

236
.github/workflows/mutants.yml vendored Normal file
View File

@ -0,0 +1,236 @@
name: Mutation Testing
# ── When to run ───────────────────────────────────────────────────────────────
# Plane A (fuzz_props invariants) runs on every PR that touches harness code.
# Plane B (LEZ protocol vs corpus) is slow (minutes per mutant × many mutants)
# so it only runs on a weekly schedule or on manual dispatch.
on:
pull_request:
paths:
- "fuzz_props/**"
- "fuzz/fuzz_targets/**"
- ".github/workflows/mutants.yml"
schedule:
- cron: "0 4 * * 1" # 04:00 UTC every Monday
workflow_dispatch:
env:
RISC0_DEV_MODE: "1"
CARGO_TERM_COLOR: always
jobs:
# ── Plane A: mutate fuzz_props (invariant harness) ────────────────────────
# Oracle: cargo test -p fuzz_props --release
# Fast (~30120 s total). Blocks PRs if any invariant-check logic is
# under-tested.
mutants-harness:
name: Mutants — fuzz_props invariants
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout logos-execution-zone
uses: ./.github/actions/checkout-lez
- name: Install stable Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install logos-blockchain-circuits
uses: ./logos-execution-zone/.github/actions/install-logos-blockchain-circuits
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: mutants-harness-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
- name: Install cargo-mutants
run: cargo install cargo-mutants --locked
# workspace.metadata.cargo-mutants in Cargo.toml sets:
# additional_cargo_args = ["--release"]
# exclude_globs = ["fuzz/fuzz_targets/**"]
# timeout_multiplier = 3.0
- name: Run mutation tests on fuzz_props
run: |
cargo mutants \
--package fuzz_props \
--in-place \
--output mutants-harness.out
- name: Upload mutants report
if: always()
uses: actions/upload-artifact@v4
with:
name: mutants-harness-report
path: mutants-harness.out/
- name: Write GitHub Step Summary
if: always()
run: |
MISSED=$(wc -l < mutants-harness.out/missed.txt 2>/dev/null | tr -d ' ' || echo 0)
CAUGHT=$(wc -l < mutants-harness.out/caught.txt 2>/dev/null | tr -d ' ' || echo 0)
{
echo "## Mutation Testing — \`fuzz_props\` invariants"
echo ""
echo "| Result | Count |"
echo "|--------|-------|"
echo "| ✅ Caught | ${CAUGHT} |"
echo "| ❌ Survived | ${MISSED} |"
echo ""
if [ "${MISSED}" -gt 0 ]; then
echo "### Surviving mutants (invariant-checker gaps)"
echo '```'
cat mutants-harness.out/missed.txt 2>/dev/null || true
echo '```'
echo ""
echo "> Each surviving mutant represents a mutation in the invariant-checking"
echo "> code that \`cargo test -p fuzz_props\` did not detect."
echo "> Add a property-test that specifically exercises that code path."
else
echo "> All mutants caught — invariant-checking logic is fully covered."
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Fail if any mutations survived
run: |
if [ -s mutants-harness.out/missed.txt ]; then
echo "ERROR: surviving mutants found in fuzz_props — see artifact and Step Summary"
cat mutants-harness.out/missed.txt
exit 1
fi
# ── Plane B: mutate LEZ protocol code, oracle = corpus regression ─────────
# Each mutant: rebuild nssa/common + replay all 20 fuzz corpora (-runs=0).
# Surviving mutants = protocol bugs the committed corpus has never caught.
# Runs on schedule (weekly Monday) or manual workflow_dispatch only.
mutants-protocol:
name: Mutants — LEZ protocol vs corpus
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
steps:
- uses: actions/checkout@v4
- name: Checkout logos-execution-zone
uses: ./.github/actions/checkout-lez
# cargo-fuzz requires nightly.
- name: Install Rust nightly toolchain
uses: dtolnay/rust-toolchain@nightly
with:
components: llvm-tools-preview
- name: Install logos-blockchain-circuits
uses: ./logos-execution-zone/.github/actions/install-logos-blockchain-circuits
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: mutants-protocol-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
- name: Install cargo-fuzz and cargo-mutants
run: |
cargo install cargo-fuzz --locked
cargo install cargo-mutants --locked
- name: Make corpus-regression wrapper executable
run: chmod +x scripts/mutants-corpus-test.sh
# Build all 20 fuzz targets once before the mutation loop so that each
# mutant only needs to rebuild the mutated crate, not the fuzz harness.
# Keep this list in sync with scripts/mutants-corpus-test.sh.
- name: Pre-build fuzz targets
run: |
for target in \
fuzz_transaction_decoding fuzz_stateless_verification \
fuzz_state_transition fuzz_block_verification \
fuzz_encoding_roundtrip fuzz_signature_verification \
fuzz_replay_prevention fuzz_state_diff_computation \
fuzz_validate_execute_consistency fuzz_state_serialization \
fuzz_witness_set_verification fuzz_program_deployment_lifecycle \
fuzz_apply_state_diff_split_path fuzz_multi_block_state_sequence \
fuzz_sequencer_vs_replayer fuzz_merkle_tree \
fuzz_transaction_properties fuzz_privacy_preserving_witness \
fuzz_encoding_privacy_preserving fuzz_nullifier_set_roundtrip; do
cargo fuzz build "${target}"
done
# cargo-mutants >=24 dropped --test-command; intercept "cargo test" with a
# fake cargo wrapper that runs the corpus oracle instead. cargo-mutants is
# called as a direct binary (not through `cargo`) so the CARGO env var we
# set is respected rather than being overridden by cargo's process launch.
- name: Run mutation tests against LEZ (nssa + common)
run: |
REAL_CARGO="$(command -v cargo)"
FAKE_CARGO="$(mktemp /tmp/fake-cargo-XXXXXX)"
# Intercept the test *execution* phase only; forward the build phase
# (cargo test --no-run) to the real cargo so mutants are compiled.
# cargo-mutants uses:
# Build phase: cargo test --no-run --verbose --package=...
# Test phase: cargo test --verbose --package=...
printf '#!/bin/bash\n_has_no_run=false\nfor _a in "$@"; do [ "$_a" = "--no-run" ] && _has_no_run=true && break; done\nif [ "${1:-}" = "test" ] && [ "$_has_no_run" = "false" ]; then\n FUZZ_REPO="%s" exec "%s"\nelse\n exec "%s" "$@"\nfi\n' \
"${{ github.workspace }}" \
"${{ github.workspace }}/scripts/mutants-corpus-test.sh" \
"$REAL_CARGO" > "$FAKE_CARGO"
chmod +x "$FAKE_CARGO"
# cargo install places cargo-mutants next to cargo in the same bin dir.
MUTANTS_BIN="$(command -v cargo-mutants 2>/dev/null || echo "$(dirname "$REAL_CARGO")/cargo-mutants")"
cd "${{ github.workspace }}/logos-execution-zone"
# cargo-mutants is a Cargo plugin; when invoked directly (not via
# `cargo mutants`) we must supply "mutants" as argv[1] ourselves.
CARGO="$FAKE_CARGO" \
"$MUTANTS_BIN" mutants \
--package nssa \
--package common \
--in-place \
--output "${{ github.workspace }}/mutants-protocol.out" \
--timeout-multiplier 5.0
rm -f "$FAKE_CARGO"
- name: Upload mutants report
if: always()
uses: actions/upload-artifact@v4
with:
name: mutants-protocol-report
path: mutants-protocol.out/
- name: Write GitHub Step Summary
if: always()
run: |
MISSED=$(wc -l < mutants-protocol.out/missed.txt 2>/dev/null | tr -d ' ' || echo 0)
CAUGHT=$(wc -l < mutants-protocol.out/caught.txt 2>/dev/null | tr -d ' ' || echo 0)
{
echo "## Mutation Testing — LEZ protocol vs committed corpus"
echo ""
echo "| Result | Count |"
echo "|--------|-------|"
echo "| ✅ Caught by corpus | ${CAUGHT} |"
echo "| ❌ Survived (corpus gap) | ${MISSED} |"
echo ""
if [ "${MISSED}" -gt 0 ]; then
echo "### Surviving mutants (corpus gaps — protocol bugs not yet reached)"
echo '```'
cat mutants-protocol.out/missed.txt 2>/dev/null || true
echo '```'
echo ""
echo "> For each surviving mutant:"
echo "> 1. Run \`cargo fuzz run <target>\` targeting the mutated function."
echo "> 2. Save the crashing input to \`corpus/libfuzz/<target>/\`."
echo "> 3. Commit the corpus entry — the next run will show \`CAUGHT\`."
else
echo "> All mutants caught — committed corpus covers all tested mutation points."
fi
} >> "$GITHUB_STEP_SUMMARY"

7
.gitignore vendored
View File

@ -27,6 +27,13 @@ fuzz/coverage/
.DS_Store
**/.DS_Store
# ── cargo-mutants outputs ─────────────────────────────────────────────────────
# Local mutation-testing reports (caught.txt, missed.txt, etc.)
# Created by `just mutants-harness` and `just mutants-protocol`.
mutants.out/
mutants-harness.out*
mutants-protocol.out*
# ── Misc ──────────────────────────────────────────────────────────────────────
# Performance baseline output from `just perf-baseline` or CI
perf_baseline.txt

1606
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -52,11 +52,13 @@ unsafe_code = "deny"
[workspace.dependencies]
# ── LEZ crates — expects logos-execution-zone/ to be cloned at ../logos-execution-zone ──
nssa = { path = "../logos-execution-zone/nssa" }
nssa_core = { path = "../logos-execution-zone/nssa/core" }
common = { path = "../logos-execution-zone/common" }
key_protocol = { path = "../logos-execution-zone/key_protocol" }
testnet_initial_state = { path = "../logos-execution-zone/testnet_initial_state" }
# LEZ reorganised its directory layout; the package= key keeps the old dependency
# alias so that fuzz_props source code (use nssa::...) compiles unchanged.
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" }
key_protocol = { path = "../logos-execution-zone/lee/key_protocol" }
testnet_initial_state = { path = "../logos-execution-zone/lez/testnet_initial_state" }
token_core = { path = "../logos-execution-zone/programs/token/core" }
test_program_methods = { path = "../logos-execution-zone/test_program_methods" }
@ -84,3 +86,19 @@ hmac-sha512 = "1.1.7"
itertools = "0.14.0"
risc0-build = "3.0.5"
logos-blockchain-common-http-client = { git = "https://github.com/logos-blockchain/logos-blockchain.git" }
# ── cargo-mutants configuration (Plane A: mutate fuzz_props invariants) ───────
# Run with --release to match CI timing.
# fuzz/fuzz_targets/** entry-points use fuzz_entry!() macros that are not
# reachable via `cargo test`; mutations there produce false survivors.
[workspace.metadata.cargo-mutants]
additional_cargo_args = ["--release"]
exclude_globs = ["fuzz/fuzz_targets/**"]
# RISC0 release builds are slower than typical crates; give each mutant extra time.
timeout_multiplier = 3.0
# The workspace uses path dependencies outside its own directory
# (../logos-execution-zone/*). cargo-mutants normally copies the workspace to a
# temp directory, but the copy does not include the sibling LEZ directory, so the
# build fails immediately. --in-place mutates the original source files in-place
# and avoids the copy, letting cargo resolve ../logos-execution-zone as usual.
in_place = true

317
Justfile
View File

@ -39,11 +39,65 @@ fuzz TIME="30":
cargo fuzz run "$target" "corpus/libfuzz/$target" -- -max_total_time={{TIME}}
done
# Re-run the saved corpus for every target (regression mode, no new mutations)
fuzz-regression:
# Run ALL fuzz targets with a bounded job pool for TIME seconds each (default: 30).
# At most JOBS targets run simultaneously (default: 4); as soon as one finishes
# the next queued target is launched, so the pool stays full until all targets
# have been processed.
# Targets are discovered automatically from fuzz/Cargo.toml — no edit needed
# here when a new [[bin]] entry is added.
#
# Usage: just fuzz-parallel # all targets, 30 s each, 4 in parallel
# just fuzz-parallel 120 # all targets, 120 s each, 4 in parallel
# just fuzz-parallel 120 8 # all targets, 120 s each, 8 in parallel
fuzz-parallel TIME="30" JOBS="4":
#!/bin/bash
set -euo pipefail
for target in $(cargo fuzz list 2>/dev/null); do
TARGETS=($(cargo fuzz list 2>/dev/null))
total=${#TARGETS[@]}
echo "Targets: $total | max parallel: {{JOBS}} | time per target: {{TIME}}s"
echo ""
PIDS=()
failed=0
for target in "${TARGETS[@]}"; do
# Wait for a free slot (block until running jobs < JOBS)
while [ "$(jobs -rp | wc -l | tr -d ' ')" -ge "{{JOBS}}" ]; do
# bash 4.3+: wait -n returns when any single child exits
wait -n 2>/dev/null || sleep 0.5
done
echo "=== launching $target ({{TIME}}s) ==="
mkdir -p "corpus/libfuzz/$target"
cargo fuzz run "$target" "corpus/libfuzz/$target" -- -max_total_time={{TIME}} &
PIDS+=($!)
done
# Drain the remaining running targets
for pid in "${PIDS[@]}"; do
wait "$pid" || { echo "WARNING: PID $pid exited with non-zero status"; failed=$((failed + 1)); }
done
echo ""
if [ "$failed" -eq 0 ]; then
echo "✓ All $total target(s) finished successfully."
else
echo "WARNING: $failed of $total target(s) reported errors."
exit 1
fi
# Re-run the saved corpus for one or ALL targets (regression mode, no new mutations).
# When TARGET is omitted every registered target is replayed in sequence.
# Usage: just fuzz-regression # all targets
# just fuzz-regression fuzz_state_transition # single target
fuzz-regression TARGET="":
#!/bin/bash
set -euo pipefail
TARGET="{{TARGET}}"
if [ -z "$TARGET" ]; then
TARGETS=($(cargo fuzz list 2>/dev/null))
else
TARGETS=("$TARGET")
fi
for target in "${TARGETS[@]}"; do
echo "=== regression $target ==="
mkdir -p "corpus/libfuzz/$target"
cargo fuzz run "$target" "corpus/libfuzz/$target" -- -runs=0
@ -295,45 +349,96 @@ fuzz-afl TARGET="" TIME="30":
echo " Format for a report: just afl-fmt <crash-file>"
fi
# Run AFL++ with N parallel instances (1 main + N-1 secondary) for TIME seconds.
# Requires that afl-fuzz is on PATH; all instances share afl-output/{{TARGET}}/.
# On macOS the crash reporter is disabled automatically for the duration of the
# run and re-enabled when the script exits.
# Run AFL++ over all targets, up to JOBS targets at a time (one instance each),
# for TIME seconds per target. As soon as one target finishes the next queued
# target is launched, so the pool stays full until every target is done.
# Requires that afl-fuzz is on PATH.
# On macOS this needs the System V shared-memory limits raised and the crash
# reporter disabled (both require admin). If the current user can use sudo the
# recipe does it automatically; otherwise it asks an administrator to run
# `sudo afl-system-config` once (sets the shm limits and disables the reporter —
# both persist until reboot) and exits.
#
# Usage: just fuzz-afl-parallel fuzz_state_transition
# just fuzz-afl-parallel fuzz_state_transition 8 600
fuzz-afl-parallel TARGET WORKERS="4" TIME="300":
# Usage: just fuzz-afl-parallel # all targets, 30 s each, 4 at a time
# just fuzz-afl-parallel 600 # all targets, 600 s each, 4 at a time
# just fuzz-afl-parallel 600 8 # all targets, 600 s each, 8 at a time
fuzz-afl-parallel TIME="30" JOBS="4":
#!/bin/bash
set -euo pipefail
BINARY="fuzz/target/release/{{TARGET}}"
CORPUS="corpus/libfuzz/{{TARGET}}" # seed from libFuzzer corpus
OUTPUT="afl-output/{{TARGET}}"
mkdir -p "$CORPUS" "$OUTPUT"
if [ ! -f "$BINARY" ]; then
echo "Binary not found — building first…"
just afl-build-target {{TARGET}}
fi
# ── macOS: disable crash reporter for the duration of this run ───────────
# ── Collect targets to run ────────────────────────────────────────────────
TARGETS=($(cargo fuzz list 2>/dev/null))
# ── macOS: shared-memory limits + crash reporter (both need admin) ────────
if [ "$(uname)" = "Darwin" ]; then
SL=/System/Library; PL=com.apple.ReportCrash
# Can this user run privileged commands without prompting for a password?
if sudo -n true 2>/dev/null; then CAN_SUDO=1; else CAN_SUDO=0; fi
# The default kern.sysv.shmmax (4 MB) is too small for AFL++'s shm
# segments, so shmget() fails with EINVAL under parallel fuzzing.
NEED_SHMMAX=524288000
CUR_SHMMAX=$(sysctl -n kern.sysv.shmmax 2>/dev/null || echo 0)
if [ "$CUR_SHMMAX" -lt "$NEED_SHMMAX" ]; then
if [ "$CAN_SUDO" = 1 ]; then
echo "macOS: raising shared-memory limits for parallel AFL++ instances…"
sudo sysctl kern.sysv.shmmax=524288000 kern.sysv.shmmin=1 \
kern.sysv.shmseg=48 kern.sysv.shmall=131072000 \
kern.sysv.shmmni=1024 >/dev/null || true
else
echo "ERROR: System V shared-memory limits are too low for parallel AFL++"
echo " (kern.sysv.shmmax=$CUR_SHMMAX, need >= $NEED_SHMMAX) and this"
echo " user cannot use sudo."
echo ""
echo " Ask an administrator to run this once (persists until reboot):"
echo ""
echo " sudo afl-system-config"
echo ""
echo " It raises the shm limits and disables the crash reporter."
echo " Then re-run: just fuzz-afl-parallel {{TIME}} {{JOBS}}"
exit 1
fi
fi
# Disable the crash reporter for the duration of the run. The per-user
# agent needs no privileges; the system daemon does, so only attempt it
# when we can sudo (an admin's `afl-system-config` covers it otherwise).
echo "macOS: disabling crash reporter (AFL++ requirement)…"
launchctl unload -w "${SL}/LaunchAgents/${PL}.plist" 2>/dev/null || true
sudo launchctl unload -w "${SL}/LaunchDaemons/${PL}.Root.plist" 2>/dev/null || true
launchctl unload -w "${SL}/LaunchAgents/${PL}.plist" 2>/dev/null || true
if [ "$CAN_SUDO" = 1 ]; then
sudo launchctl unload -w "${SL}/LaunchDaemons/${PL}.Root.plist" 2>/dev/null || true
fi
trap '
echo "Re-enabling macOS crash reporter…"
SL=/System/Library; PL=com.apple.ReportCrash
launchctl load -w "${SL}/LaunchAgents/${PL}.plist" 2>/dev/null || true
sudo launchctl load -w "${SL}/LaunchDaemons/${PL}.Root.plist" 2>/dev/null || true
launchctl load -w "${SL}/LaunchAgents/${PL}.plist" 2>/dev/null || true
[ "'"$CAN_SUDO"'" = 1 ] && sudo launchctl load -w "${SL}/LaunchDaemons/${PL}.Root.plist" 2>/dev/null || true
' EXIT
fi
# Main instance
afl-fuzz -M main -i "$CORPUS" -o "$OUTPUT" -- "$BINARY" &
# Secondary instances
for i in $(seq 1 $(( {{WORKERS}} - 1 ))); do
afl-fuzz -S "secondary${i}" -i "$CORPUS" -o "$OUTPUT" -- "$BINARY" &
# ── Run targets, up to JOBS at a time ─────────────────────────────────────
_run_one() {
local t="$1"
local BINARY="fuzz/target/release/$t"
local CORPUS="corpus/libfuzz/$t" # seed from libFuzzer corpus
local OUTPUT="afl-output/$t"
mkdir -p "$CORPUS" "$OUTPUT"
if [ ! -f "$BINARY" ]; then
echo "Binary not found — building $t first…"
just afl-build-target "$t"
fi
timeout {{TIME}} afl-fuzz -i "$CORPUS" -o "$OUTPUT" -- "$BINARY" || true
}
echo "Targets: ${#TARGETS[@]} | max parallel: {{JOBS}} | time per target: {{TIME}}s"
for t in "${TARGETS[@]}"; do
# Wait for a free slot (block until running jobs < JOBS)
while [ "$(jobs -rp | wc -l | tr -d ' ')" -ge "{{JOBS}}" ]; do
wait -n 2>/dev/null || sleep 0.5
done
echo "=== launching afl++ $t ({{TIME}}s) ==="
_run_one "$t" &
done
sleep {{TIME}}
kill $(jobs -p) 2>/dev/null || true
# Drain the remaining running targets
wait 2>/dev/null || true
just afl-corpus-sync
@ -643,12 +748,158 @@ coverage-all ENGINE="all":
fi
fi
# ── Mutation testing ──────────────────────────────────────────────────────────
#
# Prerequisites (install once):
# cargo install cargo-mutants
#
# Two planes — run them independently:
#
# Plane A (fast, ~1-5 min): mutates fuzz_props invariant logic.
# Oracle: cargo test -p fuzz_props --release
# Run on every PR that touches fuzz_props/ or fuzz/fuzz_targets/.
#
# Plane B (slow, ~hours): mutates LEZ protocol code (lee, common).
# Oracle: all 15 fuzz targets replayed against their committed corpus.
# Run weekly or manually to find corpus gaps.
# Plane A — mutation testing of fuzz_props invariant implementations.
#
# Mutates every function in fuzz_props and checks whether `cargo test -p fuzz_props
# --release` catches the mutation. Surviving mutants identify invariant-checker
# logic that the property tests do not fully exercise.
#
# Workspace metadata in Cargo.toml configures --release, exclude_globs, and
# timeout_multiplier automatically.
#
# --in-place is mandatory: fuzz_props depends on LEZ crates via relative path
# (../logos-execution-zone/...) — without it cargo-mutants copies the workspace
# to /tmp and the copy cannot resolve those relative paths.
#
# Output: mutants-harness.out/ (human-readable report also printed to stdout)
mutants-harness:
cargo mutants --package fuzz_props --in-place --output mutants-harness.out
# Plane B — mutation testing of the LEZ protocol code against the committed corpus.
#
# Mutates lee and common in the logos-execution-zone sibling workspace and uses
# scripts/mutants-corpus-test.sh as the oracle. The oracle replays all 15
# committed libFuzzer corpora (cargo fuzz run -runs=0) against each mutant.
#
# A mutant that SURVIVES means there is no corpus input that triggers the
# relevant protocol invariant at that mutation point — a corpus gap worth
# investigating with a longer fuzz run.
#
# Prerequisites:
# - logos-execution-zone cloned at ../logos-execution-zone
# - cargo-fuzz installed (cargo install cargo-fuzz)
# - cargo-mutants installed (cargo install cargo-mutants --locked)
#
# PACKAGES selects which LEZ crates to mutate (space-separated).
# Default covers the two highest-value protocol crates.
#
# Output report: mutants-protocol.out/ in the repository root.
mutants-protocol PACKAGES="lee common":
#!/bin/bash
set -euo pipefail
REPO_DIR="$(pwd)"
if [ ! -d "${REPO_DIR}/../logos-execution-zone" ]; then
echo "ERROR: logos-execution-zone not found at ../logos-execution-zone"
exit 1
fi
LEZ_DIR="$(cd "${REPO_DIR}/../logos-execution-zone" && pwd)"
# Build --package flags (one per crate name)
PKG_FLAGS=()
for pkg in {{PACKAGES}}; do
PKG_FLAGS+=(--package "$pkg")
done
echo "=== Plane B: mutating [{{PACKAGES}}] in logos-execution-zone ==="
echo " Oracle: scripts/mutants-corpus-test.sh (corpus regression, -runs=0)"
echo " Report: ${REPO_DIR}/mutants-protocol.out/"
echo ""
# cargo-mutants must be run from inside the target workspace.
# FUZZ_REPO tells the oracle script where to find the corpus and fuzz/ dir.
# --output puts the report in our repo root so it's easy to browse/commit.
# --in-place is required because LEZ depends on path crates outside its own
# directory (e.g. the Rust standard toolchain); without it cargo-mutants copies
# the workspace to a temp dir where those relative paths would not resolve.
#
# cargo-mutants >=24 dropped --test-command and only supports --test-tool cargo|nextest.
# Work around: create a fake `cargo` wrapper that intercepts `cargo test` and
# runs the corpus oracle instead; every other sub-command is delegated to the
# real cargo. We call the cargo-mutants binary directly so that cargo's own
# process launch doesn't override the CARGO env var back to the real binary.
REAL_CARGO="$(command -v cargo)"
FAKE_CARGO=$(mktemp /tmp/fake-cargo-XXXXXX)
FAKE_CARGO_LOG=$(mktemp /tmp/fake-cargo-log-XXXXXX.txt)
trap 'rm -f "$FAKE_CARGO" "$FAKE_CARGO_LOG"' EXIT
# The fake cargo intercepts the test *execution* phase only.
# cargo-mutants drives two kinds of "cargo test" invocations:
# Build phase: cargo test --no-run --verbose --package=... (compile only)
# Test phase: cargo test --verbose --package=... (run tests)
# The oracle must only replace the test execution phase; the build phase
# must be forwarded to the real cargo so mutants are actually compiled.
printf '#!/bin/bash\necho "FAKE_CARGO: $*" >> "%s"\n_has_no_run=false\nfor _a in "$@"; do [ "$_a" = "--no-run" ] && _has_no_run=true && break; done\nif [ "${1:-}" = "test" ] && [ "$_has_no_run" = "false" ]; then\n FUZZ_REPO="%s" exec "%s"\nelse\n exec "%s" "$@"\nfi\n' \
"$FAKE_CARGO_LOG" \
"$REPO_DIR" \
"${REPO_DIR}/scripts/mutants-corpus-test.sh" \
"$REAL_CARGO" > "$FAKE_CARGO"
chmod +x "$FAKE_CARGO"
# Locate the cargo-mutants binary (installed by `cargo install cargo-mutants`).
MUTANTS_BIN="$(command -v cargo-mutants 2>/dev/null || true)"
if [ -z "$MUTANTS_BIN" ]; then
MUTANTS_BIN="$(dirname "$REAL_CARGO")/cargo-mutants"
fi
if [ ! -x "$MUTANTS_BIN" ]; then
echo "ERROR: cargo-mutants not found. Install with: cargo install cargo-mutants --locked"
exit 1
fi
# cargo-mutants is a Cargo plugin. When invoked via `cargo mutants`, Cargo
# automatically prepends "mutants" as argv[1]. When we invoke the binary
# directly (to keep our CARGO env override alive), we must supply it ourselves.
cd "$LEZ_DIR"
CARGO="$FAKE_CARGO" \
"$MUTANTS_BIN" mutants \
"${PKG_FLAGS[@]}" \
--in-place \
--output "${REPO_DIR}/mutants-protocol.out" \
--timeout-multiplier 5.0 \
|| { echo "--- fake-cargo invocations ---"; cat "$FAKE_CARGO_LOG"; exit 1; }
echo ""
echo "=== Mutation report summary ==="
MISSED_FILE="${REPO_DIR}/mutants-protocol.out/missed.txt"
CAUGHT_FILE="${REPO_DIR}/mutants-protocol.out/caught.txt"
MISSED=$(wc -l < "$MISSED_FILE" 2>/dev/null | tr -d ' ' || echo 0)
CAUGHT=$(wc -l < "$CAUGHT_FILE" 2>/dev/null | tr -d ' ' || echo 0)
echo "Caught: ${CAUGHT} | Survived: ${MISSED}"
echo ""
if [ "${MISSED}" -gt 0 ]; then
echo "Surviving mutants (corpus gaps):"
cat "$MISSED_FILE" || true
echo ""
echo "For each surviving mutant: run 'just fuzz <target>' targeting the"
echo "mutated function, add the crashing input to corpus/libfuzz/<target>/,"
echo "then re-run 'just mutants-protocol' to confirm it is now CAUGHT."
else
echo "All mutants caught — corpus covers all tested mutation points."
fi
# ── Housekeeping ──────────────────────────────────────────────────────────────
# Remove all Cargo build artefacts (workspace + fuzz sub-crate)
# Remove all Cargo build artefacts (workspace + fuzz sub-crate + logos-execution-zone)
# Each command is prefixed with `-` so that a missing sibling workspace (LEZ not cloned)
# does not abort the recipe — cargo clean still removes whatever targets are present.
clean:
cargo clean
cargo clean --manifest-path fuzz/Cargo.toml
-cargo clean
-cargo clean --manifest-path fuzz/Cargo.toml
-cargo clean --manifest-path ../logos-execution-zone/Cargo.toml
# Remove libFuzzer crash/timeout artifacts for all targets (corpus is kept)
clean-artifacts:

171
README.md
View File

@ -1,11 +1,20 @@
# Lez-fuzzing
<div align="center">
Coverage-guided fuzzing and adversarial testing infrastructure for the
**Logos Execution Zone (LEZ)** protocol.
# <img src="logos.avif" alt="" height="32" valign="middle"> Lez-fuzzing
**Coverage-guided fuzzing & adversarial testing infrastructure for the
[Logos Execution Zone (LEZ)](https://github.com/) protocol.**
[![Rust](https://img.shields.io/badge/rust-nightly-orange?logo=rust)](rust-toolchain.toml)
[![Fuzzing](https://img.shields.io/badge/libFuzzer%20%C2%B7%20AFL%2B%2B-20%20targets-blue)](#-fuzz-targets)
[![Mutation testing](https://img.shields.io/badge/cargo--mutants-enabled-green)](.github/workflows/mutants.yml)
[![License](https://img.shields.io/badge/license-MIT-lightgrey)](LICENSE-MIT)
</div>
---
## Repository Layout
## 📂 Repository Layout
```
lez-fuzzing/
@ -22,34 +31,25 @@ lez-fuzzing/
│ └── generators.rs # Arbitrary / proptest strategies
├── fuzz/ # cargo-fuzz crate (own [workspace] sentinel)
│ ├── Cargo.toml
│ ├── fuzz_targets/
│ │ ├── _template.rs # Template for just new-target
│ │ ├── fuzz_transaction_decoding.rs
│ │ ├── fuzz_stateless_verification.rs
│ │ ├── fuzz_state_transition.rs
│ │ ├── 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_state_serialization.rs
│ │ ├── fuzz_witness_set_verification.rs
│ │ ├── fuzz_program_deployment_lifecycle.rs
│ │ ├── fuzz_apply_state_diff_split_path.rs
│ │ └── fuzz_multi_block_state_sequence.rs
│ ├── fuzz_targets/ # 20 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
│ ├── 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
├── 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:
`logos-execution-zone` next to this repository so the `../` path deps resolve:
```
parent/
@ -59,25 +59,18 @@ parent/
---
## Quick Start
## 🚀 Quick Start
### Prerequisites
### 1. Prerequisites
```bash
rustup install nightly
rustup component add llvm-tools-preview --toolchain nightly
cargo install cargo-fuzz
# Optional but recommended:
cargo install just
cargo install just # optional but recommended
```
> **Why nightly?** `cargo-fuzz` passes `-Zsanitizer=address` and
> `-Zinstrument-coverage` (unstable flags) to `rustc`, and depends on the
> `llvm-tools-preview` nightly component for coverage reporting. The
> `rust-toolchain.toml` pins the whole repository to nightly so you never
> need an explicit `+nightly` flag.
### Setup
### 2. Setup
```bash
# Clone both repositories side by side
@ -86,7 +79,7 @@ git clone <LEZ_FUZZING_REPO_URL> lez-fuzzing
cd lez-fuzzing
```
### Run the fuzz targets
### 3. Run the fuzz targets
```bash
# All targets for 30 s each (RISC0_DEV_MODE=1 is set automatically)
@ -105,35 +98,44 @@ just fuzz-regression
just fuzz-props
```
> [!IMPORTANT]
> **ZK-proof cost:** `RISC0_DEV_MODE=1` is exported at the top of the
> `Justfile` and must be set in every fuzz run to stub out ZK proof
> generation. Without it each execution takes seconds instead of
> microseconds.
> generation. Without it, each execution takes **seconds** instead of
> **microseconds**.
---
## Fuzz Targets
## 🎯 Fuzz Targets
| Target | Protocol layer | Entry point |
|--------|---------------|-------------|
| `fuzz_transaction_decoding` | Borsh decoding of all tx/block types (`NSSATransaction`, `Block`, `HashableBlockData`) with roundtrip re-encoding | `fuzz/fuzz_targets/fuzz_transaction_decoding.rs` |
| `fuzz_stateless_verification` | `transaction_stateless_check()` no-panic + idempotency | `fuzz/fuzz_targets/fuzz_stateless_verification.rs` |
| `fuzz_state_transition` | `V03State` transition: StateIsolationOnFailure + BalanceConservation + ReplayRejection invariants across up to 8 txs with fuzz-driven state | `fuzz/fuzz_targets/fuzz_state_transition.rs` |
| `fuzz_block_verification` | Block hash integrity: HashRoundTrip · HashPreimage completeness (block_id/prev_hash/timestamp) · TxOrderCommitment | `fuzz/fuzz_targets/fuzz_block_verification.rs` |
| `fuzz_encoding_roundtrip` | Borsh encode→decode→encode round-trip identity + canonical encoding for `PublicTransaction` and `ProgramDeploymentTransaction` | `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` | Transaction nonce replay rejection with fuzz-driven initial state | `fuzz/fuzz_targets/fuzz_replay_prevention.rs` |
| `fuzz_state_diff_computation` | `ValidatedStateDiff` forward containment + reverse completeness (bidirectional isolation check) | `fuzz/fuzz_targets/fuzz_state_diff_computation.rs` |
| `fuzz_validate_execute_consistency` | `validate_on_state` / `execute_check_on_state` agreement + diff accuracy + BalanceConservation | `fuzz/fuzz_targets/fuzz_validate_execute_consistency.rs` |
| `fuzz_state_serialization` | `V03State` Borsh decode no-panic + StateSerializationRoundtrip idempotency + NullifierDeduplication (`NullifierSet` hand-written impl) | `fuzz/fuzz_targets/fuzz_state_serialization.rs` |
| `fuzz_witness_set_verification` | `WitnessSet::is_valid_for` no-panic + CorrectVerification (sign→verify) + MessageIsolation (witness set for msg A rejected on msg B) | `fuzz/fuzz_targets/fuzz_witness_set_verification.rs` |
| `fuzz_program_deployment_lifecycle` | `V03State::transition_from_program_deployment_transaction` no-panic + BalanceIsolation (deployment must not move tokens) + StateIsolationOnFailure | `fuzz/fuzz_targets/fuzz_program_deployment_lifecycle.rs` |
| `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 | `fuzz/fuzz_targets/fuzz_apply_state_diff_split_path.rs` |
| `fuzz_multi_block_state_sequence` | LongRangeBalanceConservation across up to 16 blocks + FailedTxNonceStability (nonce must not change on rejection) + PerBlockReplayRejection | `fuzz/fuzz_targets/fuzz_multi_block_state_sequence.rs` |
| # | 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) |
Each target lives at `fuzz/fuzz_targets/<name>.rs`.
---
## Corpus Management
## 🧬 Corpus Management
```bash
# Minimise all corpora (removes dominated inputs, keeps coverage-equivalent set)
@ -145,73 +147,70 @@ just corpus-cmin-target fuzz_state_transition
---
## Crash / Failure Workflow
## 💥 Crash / Failure Workflow
```bash
# Minimise a crash artifact
# 1. Minimise a crash artifact
just fuzz-tmin fuzz_state_transition fuzz/artifacts/fuzz_state_transition/crash-abc123
# Print the bytes as a Rust literal (for a regression #[test])
# 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
# Promote the minimised input to the corpus so CI catches regressions
# 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
## Adding a New Target
```bash
# Scaffold everything automatically (corpus dir, .rs file, Cargo.toml entry, CI matrix entry)
just new-target my_feature # creates fuzz_my_feature
```
`just new-target` calls [`scripts/add_fuzz_target.py`](scripts/add_fuzz_target.py) which
`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) and inserts the target
into every strategy matrix in [`.github/workflows/fuzz.yml`](.github/workflows/fuzz.yml).
---
## Housekeeping
## 🧹 Housekeeping
```bash
just clean # Remove Cargo build artefacts (target/ and fuzz/target/)
just clean-artifacts # Remove fuzz/artifacts/ (crash/timeout inputs)
just clean-coverage # Remove fuzz/coverage/ (LLVM coverage reports)
just clean-all # All of the above
```
| 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
## ⚙️ CI
GitHub Actions runs four jobs on every push/PR and nightly:
GitHub Actions runs these workflows on every push/PR and nightly:
| Job | What it does |
|-----|-------------|
| `smoke-fuzz` (matrix, 9 targets) | Builds + runs each target for 60 s |
| `regression` (matrix, 9 targets) | Replays the saved corpus (`-runs=0`) |
| `proptest` | `cargo test -p fuzz_props --release` |
| `perf-baseline` (nightly only) | Measures exec/sec per target, uploads `perf_baseline.txt` |
> **Note:** The CI matrix currently lists the original 9 targets. The 5 new targets
> (`fuzz_state_serialization`, `fuzz_witness_set_verification`,
> `fuzz_program_deployment_lifecycle`, `fuzz_apply_state_diff_split_path`,
> `fuzz_multi_block_state_sequence`) need to be added to `.github/workflows/fuzz.yml`
> — see [`docs/fuzzing.md`](docs/fuzzing.md) for the manual fallback instructions.
| 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
## 📖 Documentation
Full developer guide — how to add new targets, interpret crashes, update
the LEZ sibling clone, and tune performance — is in
The full developer guide — how to add new targets, interpret crashes, update
the LEZ sibling clone, and tune performance — lives in
[`docs/fuzzing.md`](docs/fuzzing.md).
---
## License
## 📜 License
Licensed under the [MIT License](LICENSE-MIT).

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,6 +1,6 @@
# Alternative Approaches vs. Current Implementation
## What the Current Project Does
## 🧩 What the Current Project Does
The `lez-fuzzing` repository is a **coverage-guided, structured mutation fuzzing system** built on **cargo-fuzz / libFuzzer**, operating as a standalone companion to the Logos Execution Zone (LEZ) codebase. Its key design pillars:
@ -11,13 +11,13 @@ The `lez-fuzzing` repository is a **coverage-guided, structured mutation fuzzing
| Rich generators | [`fuzz_props::generators`](fuzz_props/src/generators.rs) adds `proptest` strategies for pathological sequences, phantom-account attacks, overflow amounts, replay sequences |
| Protocol invariants | [`fuzz_props::invariants`](fuzz_props/src/invariants.rs) expresses zero-mutation-on-rejection and replay-rejection as reusable `ProtocolInvariant` objects |
| ZK-awareness | `RISC0_DEV_MODE=1` stubs out `risc0-zkvm` proofs, enabling ~5 000200 000 exec/sec depending on target |
| 15 dedicated targets | Covers encoding, signature verification, stateless checks, state transitions, state diffs, replay prevention, validate/execute consistency, block verification, state serialization, witness-set verification, program deployment lifecycle, split-path equivalence, multi-block sequences, sequencer-vs-replayer differential |
| CI integration | GitHub Actions smoke, regression, and performance-baseline jobs run on every PR |
| 20 dedicated targets | Covers encoding, signature verification, stateless checks, state transitions, state diffs, replay prevention, validate/execute consistency, block verification, state serialization, witness-set verification, program deployment lifecycle, split-path equivalence, multi-block sequences, sequencer-vs-replayer differential, Merkle-tree invariants, transaction properties, privacy-preserving witness/encoding, and nullifier-set round-trips. Input-independent invariant checks (genesis contents, getters, system-account guard) are kept as **LEZ unit tests**, not targets — see [`docs/mutants-not-fuzzable.md`](docs/mutants-not-fuzzable.md) |
| CI integration | GitHub Actions libFuzzer (`fuzz.yml`), AFL++ (`fuzz-afl.yml`), and mutation-testing (`mutants.yml`) workflows run on every PR / nightly |
| Pre-seeded corpus | Hundreds of minimised seed files in [`fuzz/corpus/`](fuzz/corpus/) ensure regressions are caught instantly |
---
## Alternative Approaches
## 🔬 Alternative Approaches
### 1. AFL++ (American Fuzzy Lop++)
@ -32,7 +32,7 @@ The `lez-fuzzing` repository is a **coverage-guided, structured mutation fuzzing
| CI ergonomics | Requires AFL++ binary in CI image | `cargo install cargo-fuzz` only |
| Rust integration | `cargo-afl` | `cargo-fuzz` |
**Decision-maker view**: AFL++ and libFuzzer find *different* bugs because they use different mutation heuristics. Running both on the same corpus is the industry-standard "belt and suspenders" approach. [`docs/fuzzing.md`](docs/fuzzing.md:355) already lists `just fuzz-afl` as planned future work. **Incremental cost is low** — the same [`fuzz_props`](fuzz_props/src/lib.rs) crate and seed corpus work unchanged.
**Decision-maker view**: **Implemented.** AFL++ and libFuzzer find *different* bugs because they use different mutation heuristics, and running both on the same corpus is the industry-standard "belt and suspenders" approach. AFL++ is now a live lane: `just fuzz-afl` / `just fuzz-afl-parallel` and the `.github/workflows/fuzz-afl.yml` nightly job, sharing the same [`fuzz_props`](fuzz_props/src/lib.rs) crate and seed corpus at **zero migration cost**.
---
@ -111,28 +111,42 @@ The extension noted in [`docs/fuzzing.md`](docs/fuzzing.md:356) is:
| Execution time | Slow (recompile per mutation) | Continuous |
| Output | Surviving mutants = assertion gaps | Crash artifacts |
**Decision-maker view**: `cargo-mutants` would **audit the invariant assertions themselves** — revealing if [`assert_invariants()`](fuzz_props/src/invariants.rs) has gaps. Three invariants are fully implemented and registered in `assert_invariants()`: [`StateIsolationOnFailure`](fuzz_props/src/invariants.rs:60), [`BalanceConservation`](fuzz_props/src/invariants.rs:94), and [`FailedTxNonceStability`](fuzz_props/src/invariants.rs:130). Two additional invariants — [`ReplayRejection`](fuzz_props/src/invariants.rs:167) and [`NonceIncrementCorrectness`](fuzz_props/src/invariants.rs:194) — are enforced exclusively via standalone helpers (`assert_replay_rejection`, `assert_nonce_increment_correctness`) and are **not** in the `assert_invariants()` registry; this is intentional because they require data consumed before `InvariantCtx` is built. This is a **complementary quality gate**, not a fuzzing replacement. Low cost (~1 day), highly useful before an external security audit.
**Decision-maker view**: ✅ **Implemented.** `cargo-mutants` runs in two modes —
`just mutants-harness` (mutates `fuzz_props`, oracle = `cargo test`, auditing the
invariant assertions themselves) and `just mutants-protocol` (mutates the LEZ
`lee`/`common` crates, oracle = a fuzz-corpus replay), with a `mutants.yml` CI job.
The two oracles correspond to a deliberate **Plane A / Plane B** split — see
[`docs/mutants-not-fuzzable.md`](docs/mutants-not-fuzzable.md), which catalogues
the mutants each plane is and isn't expected to catch and why. (For reference, the
`fuzz_props` registry still implements [`StateIsolationOnFailure`](fuzz_props/src/invariants.rs),
[`BalanceConservation`](fuzz_props/src/invariants.rs), and
[`FailedTxNonceStability`](fuzz_props/src/invariants.rs) in `assert_invariants()`,
with `ReplayRejection` and `NonceIncrementCorrectness` enforced via standalone
helpers outside the registry.) This is a **complementary quality gate**, not a
fuzzing replacement.
---
## Summary Comparison Matrix
## 📊 Summary Comparison Matrix
| Approach | Bug-finding depth | CI cost | Impl. cost | Complements current? | Recommended action |
|---|---|---|---|---|---|
| **Current (cargo-fuzz/libFuzzer)** | High | Medium | ✅ Done | — | Maintain & expand |
| AFL++ | High (different bugs) | Medium | Low | ✅ Yes | Add `just fuzz-afl` (already planned) |
| AFL++ | High (different bugs) | Medium | ✅ Done | ✅ Yes | ✅ Implemented (`just fuzz-afl`, `fuzz-afl.yml`) |
| Honggfuzz | High on Linux | Medium | Medium | ✅ Yes | Add for Linux CI only |
| proptest-only | Lowmedium | Low | ✅ Done | Already present | Keep as unit-test layer |
| Differential (sequencer/replayer) | Very high (new bug class) | Medium | ✅ Done | ✅ Yes | ✅ Implemented (`fuzz_sequencer_vs_replayer`) |
| Formal verification | Exhaustive (selected invariants) | Very high | Very high | ✅ Yes | Long-term supplement |
| Mutation testing (`cargo-mutants`) | Measures assertion quality | High | Low | ✅ Yes | Pre-audit quality gate |
| Mutation testing (`cargo-mutants`) | Measures assertion quality | High | ✅ Done | ✅ Yes | ✅ Implemented (`just mutants-harness` / `mutants-protocol`) |
---
## Decision-maker Recommendations
## 🧭 Decision-maker Recommendations
**Highest-ROI next steps, in priority order:**
**Remaining higher-ROI next steps, in priority order:**
1. **Add AFL++ as a parallel fuzzing lane** (`just fuzz-afl`) — zero corpus migration cost, discovers different mutation paths through the same targets as libFuzzer.
1. **Honggfuzz on Linux CI only** — hardware-counter coverage finds different paths;
gated to Linux since Apple Silicon has no HW counters.
2. **Add `cargo-mutants`** before any external security audit — proves the invariant assertions in [`fuzz_props/src/invariants.rs`](fuzz_props/src/invariants.rs) are actually capable of catching the bugs they claim to detect.
2. **Formal verification of core invariants** (balance conservation, replay
prevention) — a long-term supplement, not a replacement.

View File

@ -1,4 +1,10 @@
# Fuzzing Guide
<div align="center">
# 🔬 Fuzzing Guide
**The full developer guide to running, extending, and triaging the LEZ fuzzing infrastructure.**
</div>
This document covers how to run fuzz targets, add new targets, minimise failures,
and convert findings into regression tests.
@ -9,7 +15,7 @@ directory that must be cloned separately).
---
## Architecture
## 🏗️ Architecture
The fuzz workspace (`fuzz/`) is a single Cargo workspace that covers **both** fuzzing
engines via Cargo features. No separate Cargo manifest is needed.
@ -38,7 +44,7 @@ The `cfg` attributes in the macro expansion resolve against the **calling crate'
---
## Prerequisites
## 🧰 Prerequisites
```bash
# libFuzzer lane
@ -61,7 +67,7 @@ cargo install cargo-afl
---
## Repository Setup
## 📁 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.
@ -79,7 +85,7 @@ git clone <LEZ_FUZZING_REPO_URL> lez-fuzzing
---
## How to Run
## ▶️ 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.
@ -99,11 +105,11 @@ just fuzz-regression
---
## Available Fuzz Targets
## 🎯 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_transaction_decoding` | Borsh decoding of `LeeTransaction`, `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` |
@ -118,10 +124,15 @@ just fuzz-regression
| `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` |
| `fuzz_merkle_tree` | Commitment Merkle tree via the commitment set: **ProofSome**, **ProofValid** (leaf + auth path recomputes the root), **NonMembershipNone**, **IndicesSequential** | `fuzz/fuzz_targets/fuzz_merkle_tree.rs` |
| `fuzz_transaction_properties` | Transaction property invariants: **HashDeterministic** / **HashNonDefault**, **SignerIds** derived from witness keys & non-empty, **AffectedAccountsContainSigners**, **PublicDiffNonEmptyOnSuccess** | `fuzz/fuzz_targets/fuzz_transaction_properties.rs` |
| `fuzz_privacy_preserving_witness` | `privacy_preserving_transaction::WitnessSet`: **CorrectVerification** (witness for message A passes `signatures_are_valid_for(A)`), **MessageIsolation**, **SignerIdsMatchWitnessKeys** | `fuzz/fuzz_targets/fuzz_privacy_preserving_witness.rs` |
| `fuzz_encoding_privacy_preserving` | Privacy-preserving encoding: **MessageEncodingRoundtrip**, **TxEncodingDeterministic** / **NonEmpty** | `fuzz/fuzz_targets/fuzz_encoding_privacy_preserving.rs` |
| `fuzz_nullifier_set_roundtrip` | `NullifierSet` Borsh serialisation: **NullifierSetRoundtrip** (decode→encode identity for the hand-written impl) | `fuzz/fuzz_targets/fuzz_nullifier_set_roundtrip.rs` |
---
## How to Add a New Fuzz Target
## How to Add a New Fuzz Target
### Step 1 — Scaffold with `just new-target`
@ -159,6 +170,7 @@ which:
- Inserts the target name into every strategy matrix and the perf-baseline shell
loop in [`.github/workflows/fuzz.yml`](../.github/workflows/fuzz.yml).
> [!TIP]
> **Manual fallback:** if you create a target without `just new-target`, add the
> entry yourself:
>
@ -197,12 +209,13 @@ cd fuzz && cargo afl build \
---
## AFL++ Parallel Fuzzing Lane
## 🔀 AFL++ Parallel Fuzzing Lane
### Prerequisites
Install AFL++ natively on your machine.
> [!NOTE]
> **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.
@ -222,6 +235,7 @@ cd ..
cargo install cargo-afl
```
> [!IMPORTANT]
> **macOS: run `afl-system-config` once before fuzzing** — AFL++ uses System V shared
> memory (`shmget`) to pass coverage bitmaps between the fuzzer and the target. macOS
> ships with very small defaults (`kern.sysv.shmmax = 4 MB`, `kern.sysv.shmmni = 32`)
@ -245,6 +259,7 @@ cargo install cargo-afl
> each restart. The `just fuzz-afl` and `just fuzz-afl-parallel` recipes **do not**
> call this automatically because it requires `sudo`.
> [!IMPORTANT]
> **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`
@ -361,24 +376,24 @@ 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 |
| `afl-smoke` | nightly + `workflow_dispatch` | all 20 targets, 60 s each |
| `afl-coverage-aggregate` | nightly, `needs: afl-smoke` | all 20 targets merged into one 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 smoke job (one matrix leg per target, on `ubuntu-latest`):
1. Builds AFL++ from source, then builds the target with `cargo afl build --no-default-features --features fuzzer-afl`
2. Runs `afl-fuzz` for 60 s (`timeout 60`)
3. Reports edge-bitmap coverage to the job step summary
4. Uploads the queue/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`
The coverage-aggregate job:
1. Downloads every smoke leg's findings
2. Rebuilds all 20 targets with `RUSTFLAGS="-C instrument-coverage"`
3. Runs all checked-in corpus + AFL queue inputs through each binary
4. Merges every `.profraw`one `.profdata` a single combined HTML report via `llvm-cov show`
---
## Updating the LEZ Dependency
## 🔄 Updating the LEZ Dependency
`lez-fuzzing` reads LEZ source directly from `../logos-execution-zone`. To pick up LEZ
changes, simply update that repo:
@ -401,7 +416,7 @@ just update-lez
---
## Minimising & Reproducing Failures
## 🐛 Minimising & Reproducing Failures
When `cargo fuzz` finds a crash it writes an artifact to
`fuzz/artifacts/fuzz_<target>/crash-<hash>`.
@ -440,7 +455,7 @@ Open a PR. The `regression` CI job will permanently block re-introduction of thi
---
## Coverage Reports
## 📊 Coverage Reports
### Step 1 — libFuzzer coverage (via `cargo fuzz coverage`)
@ -476,7 +491,7 @@ automates steps 25 and uploads the report as a workflow artifact.
---
## Invariant Framework
## 🛡️ Invariant Framework
Shared invariants live in `fuzz_props/src/invariants.rs`. There are two layers:
@ -538,7 +553,7 @@ To add a new invariant:
---
## Input Generators
## 🎲 Input Generators
The `fuzz_props` crate provides two layers of input generation:
@ -558,19 +573,19 @@ fuzz target parameters for zero-boilerplate structured fuzzing.
| `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) |
| `ArbHashableBlockData` | `HashableBlockData` (07 `ArbLeeTransaction` entries, random header fields) |
| `ArbLeeTransaction` | `LeeTransaction` (`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_fuzz_native_transfer()` | Correctly-signed native-transfer `LeeTransaction` referencing accounts from an `arbitrary_fuzz_state()` result; gives the fuzzer a path to successful state transitions |
| `arbitrary_transaction()` | Structured `LeeTransaction` (`Public` or `ProgramDeployment`) from unstructured bytes via `ArbLeeTransaction` |
| `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) |
| `signer_account_ids()` | Extracts `AccountId`s of all signers from an `LeeTransaction`'s witness set; used to derive signer IDs before `apply_state_diff` consumes the diff |
| `arb_native_transfer_tx()` | Valid native-transfer `LeeTransaction` 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) |
@ -579,7 +594,7 @@ fuzz target parameters for zero-boilerplate structured fuzzing.
---
## Performance Baseline
## Performance Baseline
Measured on a 4-core x86_64 Linux runner with `RISC0_DEV_MODE=1`:
@ -600,7 +615,13 @@ Measured on a 4-core x86_64 Linux runner with `RISC0_DEV_MODE=1`:
| `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)* |
| `fuzz_merkle_tree` | ~20 000 exec/sec *(estimate)* |
| `fuzz_transaction_properties` | ~15 000 exec/sec *(estimate)* |
| `fuzz_privacy_preserving_witness` | ~15 000 exec/sec *(estimate)* |
| `fuzz_encoding_privacy_preserving` | ~50 000 exec/sec *(estimate)* |
| `fuzz_nullifier_set_roundtrip` | ~100 000 exec/sec *(estimate)* |
> [!NOTE]
> 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.
@ -617,7 +638,7 @@ just fuzz-afl-parallel fuzz_state_transition $(nproc) 3600
---
## ZK-Proof Cost Warning
## ⚠️ 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`
@ -632,12 +653,44 @@ flag stubs out ZK proof generation and replaces it with a fast mock implementati
---
## Known Limitations & Future Work
## 🧬 Mutation testing — the two planes
Mutation testing here runs in two distinct planes, answering two different questions:
- **Plane A — "does a test catch this mutant?"** Run with a standard `cargo test`
oracle against the `lee` crate's own unit tests.
- **Plane B — "does the committed fuzz corpus catch this mutant?"** Run with
`just mutants-protocol`, which swaps `cargo test` for a fuzz-corpus replay
(`cargo fuzz run … -runs=0`) as the oracle.
A mutant surviving Plane B is **not automatically a corpus gap to fill.** Some
mutations are only reachable by a fully-valid executing transaction or by a
deliberately-misbehaving program — neither of which a fuzzer can synthesise from
random bytes, and both of which are better pinned by deterministic unit tests in
the `lee` crate. Encoding such scenarios as input-independent fuzz targets only
duplicates those tests and slows every corpus replay.
The mutants that are **expected** to survive Plane B (and where each is actually
covered) are catalogued in [`mutants-not-fuzzable.md`](mutants-not-fuzzable.md).
Reconcile new `mutants-protocol` runs against that list: only a surviving mutant
**not** on it warrants a new corpus input.
**No input-independent targets.** A fuzz target whose closure ignores its input
(`|_data|`) is a deterministic unit test, not a fuzzer — it belongs in the LEZ
crate that owns the code. Three such targets once existed
(`fuzz_common_invariants`, `fuzz_genesis_invariants`,
`fuzz_system_account_protection`); their invariants were ported to LEZ unit tests
and the targets removed. The mutant→test mapping is recorded under "Group 2" in
[`mutants-not-fuzzable.md`](mutants-not-fuzzable.md). When adding a target, drive it
from `data`; if a check doesn't depend on the input, write it as a unit test in
`logos-execution-zone` instead.
---
## 🚧 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 |

View File

@ -0,0 +1,233 @@
# Mutants Not Coverable by Fuzzing
This document catalogues the source mutations (from `just mutants-protocol`, the
"Plane B" corpus-replay mutation run over the `lee` / `common` crates) that the
**fuzzing corpus is not the right tool to catch**, together with where each one is
actually covered.
It exists to keep a clean separation between two questions that the tooling can
otherwise blur together:
- **"Does a test catch this mutant?"** — answered by the `lee` crate's own unit
tests via `cargo test` (call this **Plane A**).
- **"Does the committed fuzz corpus catch this mutant?"** — answered by
`just mutants-protocol`, which replaces `cargo test` with a fuzz-corpus replay
(`cargo fuzz run … -runs=0`) as the oracle (call this **Plane B**).
The mutants listed here are **expected Plane-B misses**. A future
`mutants-protocol` run that reports them as surviving is *not* a regression — it
is the documented, intended state.
This file is the complete registry, in **two groups**:
1. **Structurally unreachable by fuzzing** (Group 1) — mutants behind code that a
fuzzer cannot reach from raw bytes (they need a valid executing transaction or a
deliberately-misbehaving program). These were always unit-test territory.
2. **Migrated input-independent targets** (Group 2) — mutants that *were* caught by
input-independent fuzz targets (`fuzz_common_invariants`,
`fuzz_genesis_invariants`, `fuzz_system_account_protection`). Because an
input-independent target is a unit test in disguise, those targets were removed
and their invariants ported to LEZ unit tests; the mutants therefore now survive
Plane B by design.
Reconcile new `mutants-protocol` runs against this registry; only a surviving
mutant on **neither** list warrants a new corpus input.
---
## 🧭 Why fuzzing is the wrong tool for these
Fuzzing earns its keep by exploring a large, *unknown* input space to find inputs
a human wouldn't think of — malformed transactions, adversarial byte sequences,
surprising state-transition orderings. The corpus-replay oracle then re-runs those
discovered inputs cheaply as a regression net.
The mutations below live behind code that is only reachable by a **specific,
valid, semantically rich object** that random bytes essentially never synthesise:
1. **A fully-valid, executing transaction.** Reaching the post-execution
validation logic (authorization checks, claim checks, cycle limit) requires a
transaction whose signature matches its signer, whose nonce matches the
on-chain nonce, and whose program is deployed. A fuzzer mutating raw bytes
almost always breaks one of these and is rejected at the stateless/nonce gate
*before* any program runs — so the code never executes. Constructing such a
transaction is a deterministic "this exact scenario must hold" property, which
is the domain of **unit tests**, not input exploration.
2. **A deliberately-misbehaving program.** Some validator checks only fire when a
program returns malformed output (claims an account it shouldn't, mutates a
default account without claiming it, etc.). The only such programs are the
test fixtures behind `V03State::with_test_programs()` (`program_owner_changer`,
`extra_output_program`, …). They are **never deployed** in genesis or
production, so they are unreachable through the public transaction API that the
fuzzer drives — by construction, no fuzz input can exercise them.
In both cases the behaviour is pinned by deterministic unit tests in the `lee` /
`common` crates. Encoding such scenarios as **input-independent** fuzz targets
(targets that ignore their input and run a fixed battery) is an anti-pattern — it
duplicates the unit-test role, adds heavyweight zkVM work to every corpus replay,
and risks silent corpus rot, all to satisfy a metric (Plane B) better served by
documenting the boundary. `lez-fuzzing` therefore keeps **no** input-independent
targets: the public/privacy execution targets (which duplicated existing `lee`
tests) and the three genesis/common/system targets (whose invariants were ported
to new unit tests — see the companion doc) were all removed.
---
## 📋 Catalogue (Group 1 — structurally unreachable by fuzzing)
The nine mutations reported as MISSED by the `mutants-protocol` run for which
fuzzing is structurally the wrong tool, with their true coverage. Verified by
applying each mutation to the `logos-execution-zone` working tree and running the
cited tests (`RISC0_DEV_MODE=1 cargo test -p lee --lib`). (Group 2 — the migrated
input-independent-target mutants — is summarised further down.)
| # | Location | Mutation | Category | Covered by |
|---|----------|----------|----------|------------|
| 1 | `lee/state_machine/src/program.rs:21:51` | `*``/` (cycle limit `32`) | Valid-tx unit test | transfer-execution tests |
| 2 | `lee/state_machine/src/program.rs:21:51` | `*``+` (cycle limit `33 792`) | Valid-tx unit test | transfer-execution tests |
| 3 | `lee/state_machine/src/program.rs:21:58` | `*``/` (cycle limit `32 768`) | Valid-tx unit test | transfer-execution tests |
| 4 | `lee/state_machine/src/program.rs:21:58` | `*``+` (cycle limit `1 048 608`) | **Near-equivalent — genuine gap** | nothing (see below) |
| 5 | `lee/state_machine/src/validated_state_diff.rs:155:21` | `\|\|``&&` | Valid-tx unit test | transfer-execution tests |
| 6 | `lee/state_machine/src/validated_state_diff.rs:311:34` | `!=``==` | Misbehaving-program unit test | `public_changer_claimer_*` |
| 7 | `lee/state_machine/src/validated_state_diff.rs:314:20` | `==``!=` | Misbehaving-program unit test | `public_changer_claimer_*` + validity-window tests |
| 8 | `lee/state_machine/src/privacy_preserving_transaction/circuit.rs:88:32` | `>=``<` | Valid-PP-tx unit test | PP transition tests |
| 9 | `lee/state_machine/src/state.rs:335:16` | delete `!` | Valid-PP-tx unit test | PP transition tests |
### Category A — Covered by `lee` unit tests, requires a valid *executing* transaction (13, 5, 8, 9)
These fire only after a fully-valid transaction reaches real program execution.
A fuzzer's random bytes are rejected at the nonce/signature gate first, so the
corpus never reaches them; the `lee` crate pins each with a deterministic test.
- **13 (public cycle limit, the catchable variants).**
`MAX_NUM_CYCLES_PUBLIC_EXECUTION = 1024 * 1024 * 32` (= 33 554 432). A real
`authenticated_transfer` execution consumes **between 33 792 and 1 048 608**
RISC-V cycles, so any mutation lowering the limit below that range aborts
execution with *"Session limit exceeded"*.
Covered by `state::tests::transition_from_authenticated_transfer_program_invocation_*`
(and the ~66 other public-execution tests that run a transfer). Verified: limit
`33 792` → 66 tests fail.
- **5 (`||``&&` in `is_authorized`,
`validated_state_diff.rs:155`).** With `&&`, the transaction signer is no longer
treated as authorized, so a valid transfer fails with
`InvalidAccountAuthorization`. Covered by the same transfer-execution tests.
Verified: 3 of 7 `transition_from*` tests fail.
- **8 (`>=``<` in `execute_and_prove`,
`circuit.rs:88`).** With `<`, the chained-call guard fires on the first
iteration (`0 < MAX`) and proving aborts immediately with
`MaxChainedCallsDepthExceeded`. Covered by
`state::tests::transition_from_privacy_preserving_transaction_{shielded,private,deshielded}`.
Verified: 3 PP tests fail.
- **9 (delete `!` in `check_nullifiers_are_valid`,
`state.rs:335`).** Removing the `!` inverts the digest check so a *recognised*
commitment-set digest is rejected, breaking every valid privacy-preserving
transfer that spends a private input. Covered by the same PP transition tests.
Verified: 3 PP tests fail.
### Category B — Covered by `lee` unit tests, requires a *misbehaving* program (6, 7)
These guard against a program returning malformed output (modifying or claiming a
default account incorrectly). Only the test-only fixtures behind
`V03State::with_test_programs()` misbehave this way; they are never deployed, so no
fuzz input can reach this code. The `lee` crate exercises them directly.
- **6 (`!=` → `==`, `validated_state_diff.rs:311`)** — the
"only inspect uninitialised accounts" filter. Verified: 1 test fails under the
full `lee` suite.
- **7 (`==` → `!=`, `validated_state_diff.rs:314`)** — the
"skip unmodified accounts" guard. Verified: 16 tests fail, including
`state::tests::public_changer_claimer_data_change_no_claim_fails` and
`public_changer_claimer_no_data_change_no_claim_succeeds`.
> [!NOTE]
> an earlier analysis guessed 6 and 7 were *equivalent mutants*. They are
> not — they are caught by Plane A, just not reachable by Plane B. They appear
> "equivalent" only if you restrict yourself to the deployed `authenticated_transfer`
> program, which is exactly the restriction fuzzing operates under.
### Category C — The single genuine gap: near-equivalent weak mutant (4)
- **4 (`*``+` at `program.rs:21:58`, cycle limit `1 048 608`).**
Catching this would require a *single* public program execution that consumes
**more than 1 048 608 RISC-V cycles**. The `authenticated_transfer` instruction
uses fewer than that (it is caught only by limits ≤ 33 792 — see category A), and
no deployed program's single instruction reaches ~1M cycles. The difference
between the mutated limit (1.05M) and the real limit (33.5M) is therefore
**unobservable for any realistic workload**, making this a practically
equivalent / weak mutant. Verified: survives the full `lee` suite (211/211 pass).
It is not worth chasing in either plane. If a future deployed program legitimately
performs a >1M-cycle public execution, a normal execution test for that program
would catch this mutation incidentally.
---
## 🔁 Group 2 — migrated input-independent targets
These mutants used to be caught by Plane B via input-independent fuzz targets.
Those targets were removed and their invariants ported to LEZ unit tests, so the
mutants now survive Plane B by design. They are **not** structurally unreachable
like Group 1 — a fuzzer could "catch" them, but only by running a fixed scenario
that ignores its input, which is a unit test, not fuzzing.
Each port below was verified to kill its mutant (apply the mutation → run the named
test → observe a failure). Where a mutant had **no** prior unit-test coverage, the
port *added* coverage rather than merely relocating it; those are marked **(new)**.
**From `fuzz_common_invariants`:**
| Mutant | New unit test |
|---|---|
| `HashType::as_ref``Vec::leak(Vec::new())` / `vec![0]` / `vec![1]` | `common::tests::as_ref_returns_exact_inner_bytes` (`common/src/lib.rs`) **(new)** |
| `BasicAuth` `FromStr` delete `!` in `.filter(\|p\| !p.is_empty())` | `common::config::tests::parse_empty_password_is_none` (+ `parse_preserves_non_empty_password`) **(new)** |
| `Program::elf` → empty / `vec![0]` / `vec![1]` | `program::tests::elf_returns_the_program_bytecode_constant` (was already caught incidentally) |
| `Proof::into_inner` / `from_inner``vec![]` / `vec![0]` / `vec![1]` | `…::circuit::tests::proof_inner_roundtrip` **(new)** |
| `Message::into_bytecode``vec![]` / `vec![0]` / `vec![1]` | `program_deployment_transaction::message::tests::bytecode_roundtrip` **(new)** |
**From `fuzz_genesis_invariants`** (all in `lee/state_machine/src/state.rs`):
| Mutant | New unit test |
|---|---|
| `system_faucet_account``Default` / delete `balance` / delete `program_owner` | `state::tests::genesis_system_accounts_have_expected_contents` **(new)** |
| `system_bridge_account``Default` / delete `program_owner` | `genesis_system_accounts_have_expected_contents` **(new)** |
| `commitment_set_digest``Default` | `state::tests::genesis_commitment_set_digest_differs_from_empty_state` **(new)** |
| `add_pinata_token_program` delete `program_owner` / `data` | `state::tests::add_pinata_token_program_sets_non_default_owner_and_data` **(new)** |
| `system_faucet_account_id` / `system_bridge_account_id``Default` | `genesis_system_accounts_have_expected_contents` + `system_account_ids_are_distinct_and_non_default` (was already caught) |
**From `fuzz_system_account_protection`:**
| Mutant | New unit test |
|---|---|
| `validate_doesnt_modify_account` `!=``==` (`common/src/transaction.rs`) | `common::transaction::tests::validate_on_state_rejects_modifying_a_system_account` **(new)** |
| `public_diff``HashMap::new()` (`lee/.../validated_state_diff.rs`) | `validated_state_diff::tests::public_diff_reflects_a_successful_transfer` (+ the `validate_on_state_rejects…` test) **(new)** |
| `system_*_account_id` non-default / distinct | `common::transaction::tests::system_account_ids_are_distinct_and_non_default` (was already caught) |
---
## ✅ Re-verifying
From `logos-execution-zone/` with the fuzzing repo checked out as a sibling:
```bash
export RISC0_DEV_MODE=1
# Pick a mutation from a table above, apply it to the cited line, then run the
# owning crate's tests (Plane A). A real failure ⇒ unit tests cover it.
cargo test -p lee --lib # lee-owned mutants
cargo test -p common # common-owned mutants (Group 2)
git checkout -- <mutated-file> # always revert
```
A mutation that makes `cargo test` fail is covered by Plane A and belongs in this
registry; a mutation that the corpus replay (`just mutants-protocol`) catches
belongs in the corpus instead. Across both groups, mutation #4 (the near-equivalent
cycle-limit weak mutant) is the only one caught by **neither** plane.
> [!TIP]
> when reverting, prefer reverse-editing only the mutated line rather than
> `git checkout -- <file>` if you have uncommitted unit tests in the same file —
> a whole-file checkout would discard them too.

840
fuzz/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -44,11 +44,11 @@ libfuzzer-sys = { version = "0.4", optional = true }
afl = { version = "0.15", optional = true }
arbitrary = { version = "1", features = ["derive"] }
borsh = "1"
nssa = { path = "../../logos-execution-zone/nssa" }
nssa_core = { path = "../../logos-execution-zone/nssa/core" }
common = { path = "../../logos-execution-zone/common" }
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" }
fuzz_props = { path = "../fuzz_props" }
testnet_initial_state = { path = "../../logos-execution-zone/testnet_initial_state" }
testnet_initial_state = { path = "../../logos-execution-zone/lez/testnet_initial_state" }
[profile.release]
debug = true
@ -119,3 +119,33 @@ name = "fuzz_sequencer_vs_replayer"
path = "fuzz_targets/fuzz_sequencer_vs_replayer.rs"
test = false
bench = false
[[bin]]
name = "fuzz_merkle_tree"
path = "fuzz_targets/fuzz_merkle_tree.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

View File

@ -33,7 +33,7 @@
use std::collections::HashSet;
use arbitrary::{Arbitrary, Unstructured};
use fuzz_props::arbitrary_types::ArbNSSATransaction;
use fuzz_props::arbitrary_types::ArbLeeTransaction;
use fuzz_props::generators::{arbitrary_fuzz_state, signer_account_ids};
use fuzz_props::invariants::{NonceSnapshot, assert_nonce_increment_correctness};
use nssa::V03State;
@ -52,7 +52,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
.collect();
// Generate and stateless-check a transaction.
let tx_raw = match ArbNSSATransaction::arbitrary(&mut u) {
let tx_raw = match ArbLeeTransaction::arbitrary(&mut u) {
Ok(w) => w.0,
Err(_) => return,
};

View File

@ -33,10 +33,9 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
let base = wrap.0;
let signing_key = PrivateKey::try_new(DUMMY_KEY_BYTES).expect("constant key is valid");
let bedrock = [0u8; 32];
// Compute the canonical hash for the base input.
let block = base.clone().into_pending_block(&signing_key, bedrock);
let block = base.clone().into_pending_block(&signing_key);
let hash_base = block.header.hash;
// ── INVARIANT 1: HashableBlockData::from(Block) is lossless ──────────────────
@ -51,7 +50,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
{
let roundtrip_hashable = HashableBlockData::from(block);
let hash_roundtrip = roundtrip_hashable
.into_pending_block(&signing_key, bedrock)
.into_pending_block(&signing_key)
.header
.hash;
assert_eq!(
@ -67,7 +66,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
{
let mut m = base.clone();
m.block_id = m.block_id.wrapping_add(1);
let hash_m = m.into_pending_block(&signing_key, bedrock).header.hash;
let hash_m = m.into_pending_block(&signing_key).header.hash;
assert_ne!(
hash_base,
hash_m,
@ -81,7 +80,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
{
let mut m = base.clone();
m.prev_block_hash.0[0] ^= 0xFF;
let hash_m = m.into_pending_block(&signing_key, bedrock).header.hash;
let hash_m = m.into_pending_block(&signing_key).header.hash;
assert_ne!(
hash_base,
hash_m,
@ -95,7 +94,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
{
let mut m = base.clone();
m.timestamp = m.timestamp.wrapping_add(1);
let hash_m = m.into_pending_block(&signing_key, bedrock).header.hash;
let hash_m = m.into_pending_block(&signing_key).header.hash;
assert_ne!(
hash_base,
hash_m,
@ -121,7 +120,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
if first != last {
let mut reordered = base.clone();
reordered.transactions.reverse();
let hash_reordered = reordered.into_pending_block(&signing_key, bedrock).header.hash;
let hash_reordered = reordered.into_pending_block(&signing_key).header.hash;
assert_ne!(
hash_base,
hash_reordered,

View File

@ -0,0 +1,197 @@
#![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 [CircuitOutputAccepted] ─────────────────────────────────────
// `try_from_circuit_output` must succeed for a well-formed (empty) circuit
// output, mapping the output fields onto the resulting `Message`.
{
let empty_output = PrivacyPreservingCircuitOutput {
public_pre_states: vec![],
public_post_states: vec![],
new_commitments: vec![],
new_nullifiers: vec![],
encrypted_private_post_states: 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
empty_output,
);
assert!(
result.is_ok(),
"INVARIANT VIOLATION [CircuitOutputAccepted]: \
try_from_circuit_output must accept a well-formed empty output, \
got: {:?}",
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

@ -0,0 +1,136 @@
#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]
//! Fuzz target: Merkle-tree structural invariants, exercised through the
//! **public** commitment-set API (no `pub mod merkle_tree` patch required).
//!
//! The commitment set in `V03State` is a thin wrapper around the internal
//! `MerkleTree`:
//!
//! ```text
//! 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
//!
//! 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
//!
//! 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 std::collections::HashSet;
use nssa::V03State;
use nssa_core::{
Commitment, Nullifier,
account::{Account, AccountId},
compute_digest_for_path,
};
fuzz_props::fuzz_entry!(|data: &[u8]| {
// 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));
}
if pairs.is_empty() {
return;
}
// 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();
// 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!(
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}",
);
}
// ── 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!(
state.get_proof_for_commitment(&absent).is_none(),
"INVARIANT VIOLATION [NonMembershipNone]: \
get_proof_for_commitment returned Some for a commitment never inserted",
);
}
});

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

@ -38,7 +38,7 @@
use std::collections::HashSet;
use arbitrary::{Arbitrary, Unstructured};
use common::transaction::{NSSATransaction, clock_invocation};
use common::transaction::{LeeTransaction, clock_invocation};
use fuzz_props::generators::{arb_fuzz_native_transfer, arbitrary_fuzz_state, arbitrary_transaction};
use nssa::V03State;
@ -81,7 +81,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
// Accepted transaction list — populated here, consumed by the replayer phase
// so that both pipelines process exactly the same set of transactions.
let mut accepted_txs: Vec<NSSATransaction> = Vec::new();
let mut accepted_txs: Vec<LeeTransaction> = Vec::new();
let n_txs: u8 = u8::arbitrary(&mut u).unwrap_or(0) % 8;

View File

@ -19,7 +19,7 @@
//! specific account shapes such as zero balance or `u128::MAX` — are reachable.
use arbitrary::{Arbitrary, Unstructured};
use common::transaction::NSSATransaction;
use common::transaction::LeeTransaction;
use fuzz_props::arbitrary_types::ArbPublicTransaction;
use fuzz_props::generators::arbitrary_fuzz_state;
use nssa::{V03State, ValidatedStateDiff};
@ -47,7 +47,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
// Collect the set of accounts the transaction declares it will touch.
// `affected_public_account_ids()` returns owned data so `pub_tx` remains
// available for both `from_public_transaction` (borrow) and the later move
// into `NSSATransaction::Public`.
// into `LeeTransaction::Public`.
let affected = pub_tx.affected_public_account_ids();
match ValidatedStateDiff::from_public_transaction(&pub_tx, &state, 1, 0) {
@ -77,7 +77,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
// we do not panic on a structurally malformed transaction.
let mut exec_state = state.clone();
// `pub_tx` is moved here; it is no longer borrowed after this point.
let tx_for_exec = NSSATransaction::Public(pub_tx);
let tx_for_exec = LeeTransaction::Public(pub_tx);
if let Ok(checked_tx) = tx_for_exec.transaction_stateless_check() {
if checked_tx.execute_check_on_state(&mut exec_state, 1, 0).is_ok() {
for acc_id in &affected {

View File

@ -1,7 +1,7 @@
#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]
use arbitrary::Unstructured;
use common::transaction::NSSATransaction;
use common::transaction::LeeTransaction;
use fuzz_props::generators::arbitrary_transaction;
fuzz_props::fuzz_entry!(|data: &[u8]| {
@ -22,7 +22,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
}
// Path B: raw decode first, then check — must never panic
if let Ok(tx) = borsh::from_slice::<NSSATransaction>(data) {
if let Ok(tx) = borsh::from_slice::<LeeTransaction>(data) {
let _ = tx.transaction_stateless_check();
}
});

View File

@ -2,19 +2,19 @@
use common::{
block::{Block, HashableBlockData},
transaction::NSSATransaction,
transaction::LeeTransaction,
};
fuzz_props::fuzz_entry!(|data: &[u8]| {
// Attempt 1: decode as NSSATransaction and verify roundtrip
if let Ok(tx) = borsh::from_slice::<NSSATransaction>(data) {
// Attempt 1: decode as LeeTransaction and verify roundtrip
if let Ok(tx) = borsh::from_slice::<LeeTransaction>(data) {
let re_encoded = borsh::to_vec(&tx).expect("re-encode of valid tx must succeed");
let tx2 = borsh::from_slice::<NSSATransaction>(&re_encoded)
let tx2 = borsh::from_slice::<LeeTransaction>(&re_encoded)
.expect("second decode of re-encoded tx must succeed");
assert_eq!(
re_encoded,
borsh::to_vec(&tx2).unwrap(),
"NSSATransaction roundtrip encoding divergence"
"LeeTransaction roundtrip encoding divergence"
);
}

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

@ -25,7 +25,7 @@
//! reachable by the fuzzer.
use arbitrary::{Arbitrary, Unstructured};
use fuzz_props::arbitrary_types::ArbNSSATransaction;
use fuzz_props::arbitrary_types::ArbLeeTransaction;
use fuzz_props::generators::{arbitrary_fuzz_state, signer_account_ids};
use fuzz_props::invariants::{NonceSnapshot, assert_nonce_increment_correctness};
use nssa::V03State;
@ -47,7 +47,7 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
.collect();
// Generate the transaction from the remaining fuzz bytes.
let tx = match ArbNSSATransaction::arbitrary(&mut u) {
let tx = match ArbLeeTransaction::arbitrary(&mut u) {
Ok(w) => w.0,
Err(_) => return,
};

View File

@ -21,3 +21,4 @@ testnet_initial_state = { workspace = true }
[dev-dependencies]
proptest = "1.4"
nssa = { workspace = true, features = ["prove"] }

View File

@ -2,7 +2,7 @@
//!
//! **No changes to `../logos-execution-zone` are required.**
//!
//! The Rust orphan rule forbids `impl Arbitrary for NSSATransaction` when both
//! The Rust orphan rule forbids `impl Arbitrary for LeeTransaction` when both
//! the trait and the type come from external crates. Using newtypes (`ArbXxx`)
//! sidesteps the restriction entirely.
//!
@ -10,10 +10,10 @@
//!
//! ```rust,ignore
//! #![no_main]
//! use fuzz_props::arbitrary_types::ArbNSSATransaction;
//! use fuzz_props::arbitrary_types::ArbLeeTransaction;
//! use libfuzzer_sys::fuzz_target;
//!
//! fuzz_target!(|wrapped: ArbNSSATransaction| {
//! fuzz_target!(|wrapped: ArbLeeTransaction| {
//! let tx = wrapped.0;
//! let Ok(valid_tx) = tx.transaction_stateless_check() else { return; };
//! // …
@ -21,7 +21,7 @@
//! ```
use arbitrary::{Arbitrary, Result as ArbResult, Unstructured};
use common::{HashType, block::HashableBlockData, transaction::NSSATransaction};
use common::{HashType, block::HashableBlockData, transaction::LeeTransaction};
use nssa::{
AccountId, PrivateKey, PublicKey, Signature,
program_deployment_transaction::ProgramDeploymentTransaction,
@ -210,24 +210,24 @@ impl<'a> Arbitrary<'a> for ArbProgramDeploymentTransaction {
}
}
// ── NSSATransaction ───────────────────────────────────────────────────────────
// ── LeeTransaction ───────────────────────────────────────────────────────────
// `PrivacyPreservingTransaction` is intentionally excluded: it embeds a risc0
// ZK receipt that cannot be generated inside a hot fuzzing loop. This matches
// the known limitation documented in `docs/fuzzing.md`.
/// Newtype wrapper providing [`Arbitrary`] for [`NSSATransaction`].
/// Newtype wrapper providing [`Arbitrary`] for [`LeeTransaction`].
///
/// Generates `Public` and `ProgramDeployment` variants only.
#[derive(Debug)]
pub struct ArbNSSATransaction(pub NSSATransaction);
pub struct ArbLeeTransaction(pub LeeTransaction);
impl<'a> Arbitrary<'a> for ArbNSSATransaction {
impl<'a> Arbitrary<'a> for ArbLeeTransaction {
fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult<Self> {
match u8::arbitrary(u)? % 2 {
0 => Ok(Self(NSSATransaction::Public(
0 => Ok(Self(LeeTransaction::Public(
ArbPublicTransaction::arbitrary(u)?.0,
))),
_ => Ok(Self(NSSATransaction::ProgramDeployment(
_ => Ok(Self(LeeTransaction::ProgramDeployment(
ArbProgramDeploymentTransaction::arbitrary(u)?.0,
))),
}
@ -246,7 +246,7 @@ impl<'a> Arbitrary<'a> for ArbHashableBlockData {
fn arbitrary(u: &mut Unstructured<'a>) -> ArbResult<Self> {
// 07 transactions per block
let n = (u8::arbitrary(u)? as usize) % 8;
let transactions = std::iter::repeat_with(|| ArbNSSATransaction::arbitrary(u).map(|t| t.0))
let transactions = std::iter::repeat_with(|| ArbLeeTransaction::arbitrary(u).map(|t| t.0))
.take(n)
.collect::<ArbResult<Vec<_>>>()?;
Ok(Self(HashableBlockData {

View File

@ -1,8 +1,8 @@
use arbitrary::{Arbitrary, Unstructured};
use common::{block::HashableBlockData, transaction::NSSATransaction};
use common::{block::HashableBlockData, transaction::LeeTransaction};
use nssa::{AccountId, PrivateKey};
use crate::arbitrary_types::{ArbAccountId, ArbNSSATransaction, ArbPrivateKey};
use crate::arbitrary_types::{ArbAccountId, ArbLeeTransaction, ArbPrivateKey};
use proptest::prelude::*;
use testnet_initial_state::initial_pub_accounts_private_keys;
@ -12,22 +12,22 @@ use testnet_initial_state::initial_pub_accounts_private_keys;
/// witness set. Used by fuzz targets that need to verify nonce
/// increments after `execute_check_on_state`.
#[must_use]
pub fn signer_account_ids(tx: &common::transaction::NSSATransaction) -> Vec<nssa::AccountId> {
use common::transaction::NSSATransaction;
pub fn signer_account_ids(tx: &common::transaction::LeeTransaction) -> Vec<nssa::AccountId> {
use common::transaction::LeeTransaction;
match tx {
NSSATransaction::Public(pt) => pt
LeeTransaction::Public(pt) => pt
.witness_set()
.signatures_and_public_keys()
.iter()
.map(|(_, pk)| nssa::AccountId::from(pk))
.collect(),
NSSATransaction::PrivacyPreserving(pt) => pt
LeeTransaction::PrivacyPreserving(pt) => pt
.witness_set()
.signatures_and_public_keys()
.iter()
.map(|(_, pk)| nssa::AccountId::from(pk))
.collect(),
NSSATransaction::ProgramDeployment(_) => vec![],
LeeTransaction::ProgramDeployment(_) => vec![],
}
}
@ -74,7 +74,7 @@ pub fn arbitrary_fuzz_state(u: &mut Unstructured<'_>) -> arbitrary::Result<Vec<F
.collect()
}
/// Generate a native-transfer [`NSSATransaction`] between two accounts chosen
/// Generate a native-transfer [`LeeTransaction`] between two accounts chosen
/// from `accounts`.
///
/// Because every account in the slice has a known private key, the resulting
@ -87,7 +87,7 @@ pub fn arbitrary_fuzz_state(u: &mut Unstructured<'_>) -> arbitrary::Result<Vec<F
pub fn arb_fuzz_native_transfer(
u: &mut Unstructured<'_>,
accounts: &[FuzzAccount],
) -> arbitrary::Result<NSSATransaction> {
) -> arbitrary::Result<LeeTransaction> {
if accounts.is_empty() {
return Err(arbitrary::Error::IncorrectFormat);
}
@ -112,9 +112,9 @@ pub fn arb_fuzz_native_transfer(
// ── Arbitrary (for libFuzzer targets) ────────────────────────────────────────
/// Generate a structurally plausible `NSSATransaction` from unstructured bytes.
pub fn arbitrary_transaction(u: &mut Unstructured<'_>) -> arbitrary::Result<NSSATransaction> {
ArbNSSATransaction::arbitrary(u).map(|w| w.0)
/// Generate a structurally plausible `LeeTransaction` from unstructured bytes.
pub fn arbitrary_transaction(u: &mut Unstructured<'_>) -> arbitrary::Result<LeeTransaction> {
ArbLeeTransaction::arbitrary(u).map(|w| w.0)
}
// ── proptest strategies ───────────────────────────────────────────────────────
@ -128,7 +128,7 @@ prop_compose! {
to_idx in 0..accounts.len(),
nonce in 0_u128..1_000_u128,
amount in 0_u128..10_000_u128,
) -> NSSATransaction {
) -> LeeTransaction {
let (from_id, from_key) = &accounts[from_idx];
let (to_id, _) = &accounts[to_idx];
common::test_utils::create_transaction_native_token_transfer(
@ -146,11 +146,11 @@ pub fn test_accounts() -> Vec<(AccountId, PrivateKey)> {
.collect()
}
/// Strategy: raw bytes that are valid borsh encodings of `NSSATransaction`.
/// Strategy: raw bytes that are valid borsh encodings of `LeeTransaction`.
pub fn arb_borsh_transaction_bytes() -> impl Strategy<Value = Vec<u8>> {
any::<Vec<u8>>().prop_map(|bytes| {
// Either pass through raw bytes OR encode a known dummy transaction
if borsh::from_slice::<NSSATransaction>(&bytes).is_ok() {
if borsh::from_slice::<LeeTransaction>(&bytes).is_ok() {
bytes
} else {
borsh::to_vec(&common::test_utils::produce_dummy_empty_transaction()).unwrap()
@ -183,7 +183,7 @@ prop_compose! {
phantom_id_bytes in proptest::array::uniform32(0_u8..),
amount in (u128::MAX / 2)..u128::MAX, // overflow-inducing amount
nonce in 0_u128..10_u128,
) -> NSSATransaction {
) -> LeeTransaction {
let phantom_id = nssa::AccountId::new(phantom_id_bytes);
// Attempt to sign with a key that has no matching on-chain account
let signing_key = nssa::PrivateKey::try_new(phantom_id_bytes)
@ -204,11 +204,11 @@ prop_compose! {
/// attack candidates) and some are re-ordered permutations of a valid sequence.
/// Used in proptest-level tests and as a seed generator for the state-transition
/// fuzz target.
pub fn arb_duplicate_tx_sequence() -> impl Strategy<Value = Vec<NSSATransaction>> {
pub fn arb_duplicate_tx_sequence() -> impl Strategy<Value = Vec<LeeTransaction>> {
let accounts = test_accounts();
proptest::collection::vec(arb_native_transfer_tx(accounts), 1..5_usize).prop_flat_map(|txs| {
// Build a sequence that: original | duplicates | reversed
let duped: Vec<NSSATransaction> = txs
let duped: Vec<LeeTransaction> = txs
.iter()
.cloned()
.chain(txs.iter().cloned()) // append exact duplicates
@ -225,7 +225,7 @@ pub fn arb_duplicate_tx_sequence() -> impl Strategy<Value = Vec<NSSATransaction>
/// - self-transfers (sender == recipient),
/// - max-nonce wrapping,
/// - alternating valid / invalid transactions to test partial-batch isolation.
pub fn arb_pathological_sequence() -> impl Strategy<Value = Vec<NSSATransaction>> {
pub fn arb_pathological_sequence() -> impl Strategy<Value = Vec<LeeTransaction>> {
let accounts = test_accounts();
let n = accounts.len();
proptest::collection::vec((0..n, 0..n, 0_u128..5_u128, any::<bool>()), 1..8_usize).prop_map(

View File

@ -1,4 +1,4 @@
use common::transaction::NSSATransaction;
use common::transaction::LeeTransaction;
use nssa::V03State;
use nssa_core::account::Nonce;
@ -185,7 +185,7 @@ impl ProtocolInvariant for FailedTxNonceStability {
/// # Enforcement
///
/// This invariant **cannot** be enforced through [`InvariantCtx`] because the replay
/// check requires re-applying the `NSSATransaction` that `execute_check_on_state`
/// check requires re-applying the `LeeTransaction` that `execute_check_on_state`
/// consumes and returns on `Ok`. It is therefore **not registered** in
/// [`assert_invariants`]; calling `assert_invariants` alone does **not** cover
/// `ReplayRejection`.
@ -235,7 +235,7 @@ pub struct NonceIncrementCorrectness;
///
/// # Why a standalone function?
///
/// `execute_check_on_state` consumes the `NSSATransaction` and returns it on `Ok`,
/// `execute_check_on_state` consumes the `LeeTransaction` and returns it on `Ok`,
/// so the transaction is not available as a shared reference inside [`InvariantCtx`].
/// This function accepts ownership of the returned transaction and performs the
/// replay in-place.
@ -249,7 +249,7 @@ pub struct NonceIncrementCorrectness;
/// }
/// ```
pub fn assert_replay_rejection(
applied_tx: NSSATransaction,
applied_tx: LeeTransaction,
state: &mut V03State,
next_block_id: u64,
next_timestamp: u64,
@ -270,7 +270,7 @@ pub fn assert_replay_rejection(
/// passing the signer IDs derived from the transaction's witness set, the [`NonceSnapshot`]
/// captured **before** execution, and the post-execution state.
///
/// For a `NSSATransaction::Public(tx)`, derive signer IDs as:
/// For a `LeeTransaction::Public(tx)`, derive signer IDs as:
///
/// ```rust,ignore
/// let signer_ids: Vec<nssa::AccountId> = tx
@ -281,7 +281,7 @@ pub fn assert_replay_rejection(
/// .collect();
/// ```
///
/// For `NSSATransaction::ProgramDeployment`, there are no signers; pass an empty slice.
/// For `LeeTransaction::ProgramDeployment`, there are no signers; pass an empty slice.
///
/// # Why a standalone function?
///
@ -377,7 +377,7 @@ pub fn assert_tx_execution_invariants<E>(
state_after: &mut V03State,
balances_before: BalanceSnapshot,
nonces_before: NonceSnapshot,
execution_result: Result<NSSATransaction, E>,
execution_result: Result<LeeTransaction, E>,
replay_context: (u64, u64),
) {
let execution_succeeded = execution_result.is_ok();
@ -400,19 +400,19 @@ pub fn assert_tx_execution_invariants<E>(
if let Ok(applied_tx) = execution_result {
// Derive signer IDs from the witness set. ProgramDeployment has no signers.
let signer_ids: Vec<nssa::AccountId> = match &applied_tx {
NSSATransaction::Public(pt) => pt
LeeTransaction::Public(pt) => pt
.witness_set()
.signatures_and_public_keys()
.iter()
.map(|(_, pk)| nssa::AccountId::from(pk))
.collect(),
NSSATransaction::PrivacyPreserving(pt) => pt
LeeTransaction::PrivacyPreserving(pt) => pt
.witness_set()
.signatures_and_public_keys()
.iter()
.map(|(_, pk)| nssa::AccountId::from(pk))
.collect(),
NSSATransaction::ProgramDeployment(_) => vec![],
LeeTransaction::ProgramDeployment(_) => vec![],
};
assert_nonce_increment_correctness(&signer_ids, &nonces_for_nonce_check, state_after);
let (next_block_id, next_timestamp) = replay_context;

View File

@ -1,3 +1,5 @@
mod arbitrary_types_test;
mod generators_test;
mod invariants;
mod replay_proptest;
mod seed_gen;

View File

@ -0,0 +1,158 @@
//! Tests that detect mutations in `arbitrary_types.rs`.
//!
//! # Design rationale
//!
//! `arbitrary::Unstructured::fill_buffer` reads bytes from the **front** of the buffer
//! and pads with zeros when the buffer is exhausted — it never returns an error. As a
//! result, the total number of items generated by `take(n)` always equals `n` regardless
//! of buffer size. This makes count-based tests the most reliable mutation detectors.
//!
//! For types that expose their length through public APIs we check the count directly.
//! For `ArbPubTxMessage`, whose inner [`nssa::public_transaction::Message`] is opaque,
//! we use the borsh-serialised size of a wrapping [`LeeTransaction::Public`] as a proxy.
use crate::arbitrary_types::{
ArbHashableBlockData, ArbLeeTransaction, ArbPubTxMessage, ArbPublicTransaction, ArbWitnessSet,
};
use arbitrary::{Arbitrary, Unstructured};
use common::transaction::LeeTransaction;
#[test]
fn arb_lee_transaction_zero_byte_selects_public() {
// fill_buffer reads from the front, so the first byte consumed = 0.
let buf = vec![0_u8; 4096];
let mut u = Unstructured::new(&buf);
let arb = ArbLeeTransaction::arbitrary(&mut u).expect("should succeed");
assert!(
matches!(arb.0, LeeTransaction::Public(_)),
"expected Public variant: with first byte=0 and `% 2`, arm 0 (Public) is selected"
);
}
#[test]
fn arb_lee_transaction_byte4_selects_public() {
// Place 4 as the first byte (variant selector); rest are zeros.
let mut buf = vec![0_u8; 4096];
buf[0] = 4;
let mut u = Unstructured::new(&buf);
let arb = ArbLeeTransaction::arbitrary(&mut u).expect("should succeed");
assert!(
matches!(arb.0, LeeTransaction::Public(_)),
"expected Public variant: `4 % 2 = 0` \u{2192} arm 0; \
mutant `4 / 2 = 2` or `4 + 2 = 6` maps to `_` \u{2192} ProgramDeployment"
);
}
/// Generates from all-1 bytes: `1 % 2 = 1` -> `_` -> `ProgramDeployment`.
#[test]
fn arb_lee_transaction_one_byte_selects_program_deployment() {
let buf = vec![1_u8; 4096];
let mut u = Unstructured::new(&buf);
let arb = ArbLeeTransaction::arbitrary(&mut u).expect("should succeed");
assert!(
matches!(arb.0, LeeTransaction::ProgramDeployment(_)),
"expected ProgramDeployment variant with first byte=1"
);
}
/// Generates `ArbHashableBlockData` from all-255 bytes and asserts `transactions.len() <= 7`.
#[test]
fn arb_hashable_block_data_tx_count_bounded() {
let buf = vec![255_u8; 50_000];
let mut u = Unstructured::new(&buf);
let arb = ArbHashableBlockData::arbitrary(&mut u)
.expect("ArbHashableBlockData::arbitrary should succeed with a large all-255 buffer");
assert!(
arb.0.transactions.len() <= 7,
"expected at most 7 transactions (% 8), got {} \
(mutation: % replaced by / or + on line 248 of arbitrary_types.rs)",
arb.0.transactions.len()
);
}
/// Generates `ArbWitnessSet` from all-255 bytes and asserts pair count <= 3.
#[test]
fn arb_witness_set_pair_count_bounded() {
let buf = vec![255_u8; 50_000];
let mut u = Unstructured::new(&buf);
let arb = ArbWitnessSet::arbitrary(&mut u)
.expect("ArbWitnessSet::arbitrary should succeed with a large all-255 buffer");
let pair_count = arb.0.signatures_and_public_keys().len();
assert!(
pair_count <= 3,
"expected at most 3 witness pairs (% 4), got {pair_count} \
(mutation: % replaced by / or + on line 173 of arbitrary_types.rs)"
);
}
/// Checks the borsh-encoded size of a `LeeTransaction::Public` wrapping an
/// `ArbPubTxMessage` generated from a buffer where the len-selector byte = 255.
#[test]
fn arb_pub_tx_message_account_count_bounded_via_borsh() {
// Bytes 0-31: zeros for program_id ([u32; 8] via fill_buffer reads 32 bytes).
// Byte 32: 255 — this is the len-selector byte. 255 % 8 = 7 (correct) vs 255 / 8 = 31 (mutant).
// Bytes 33+: zeros so Vec<u32> (instruction_data) produces 0 elements (last byte = 0).
let mut buf = vec![0_u8; 2000];
buf[32] = 255;
let mut u = Unstructured::new(&buf);
let msg =
ArbPubTxMessage::arbitrary(&mut u).expect("ArbPubTxMessage::arbitrary should succeed");
// Wrap in a real PublicTransaction to enable borsh serialisation.
let mut u_witness = Unstructured::new(&[0_u8; 10]);
let witness = ArbWitnessSet::arbitrary(&mut u_witness)
.expect("ArbWitnessSet::arbitrary should succeed with zero bytes (n=0)");
let tx = LeeTransaction::Public(nssa::public_transaction::PublicTransaction::new(
msg.0, witness.0,
));
let borsh_bytes = borsh::to_vec(&tx).expect("borsh serialization should succeed");
// With 7 accounts the message borsh-encodes to ~380 bytes; the whole transaction to ~400 bytes.
// With 31 accounts the message encodes to ~1540 bytes.
// Using 800 as a conservative threshold clearly separates the two cases.
assert!(
borsh_bytes.len() < 800,
"borsh-encoded size {} bytes exceeds threshold: too many accounts in message \
(% 8 may have been replaced with / 8 or + 8 on line 144 of arbitrary_types.rs)",
borsh_bytes.len()
);
}
/// Additional check: with an all-zero buffer the `ArbPubTxMessage` generates a
/// message with 0 accounts (`0 % 8 = 0`). This verifies the zero case.
#[test]
fn arb_pub_tx_message_zero_accounts_with_zero_selector() {
// All zeros: program_id = all 0, len selector byte = 0.
// 0 % 8 = 0 (correct), 0 + 8 = 8 (+ mutant).
let buf = vec![0_u8; 500];
let mut u = Unstructured::new(&buf);
let msg =
ArbPubTxMessage::arbitrary(&mut u).expect("ArbPubTxMessage::arbitrary should succeed");
let mut u_witness = Unstructured::new(&[0_u8; 10]);
let witness = ArbWitnessSet::arbitrary(&mut u_witness).expect("witness should succeed");
let tx = LeeTransaction::Public(nssa::public_transaction::PublicTransaction::new(
msg.0, witness.0,
));
let borsh_bytes = borsh::to_vec(&tx).expect("borsh serialization should succeed");
// With 0 accounts borsh size is minimal (~50 bytes for empty message + envelope).
// With 8 accounts (+ mutant) borsh size > 400 bytes.
assert!(
borsh_bytes.len() < 300,
"borsh-encoded size {} bytes too large for zero-account message \
(% 8 may have been replaced with + 8 on line 144 of arbitrary_types.rs)",
borsh_bytes.len()
);
}
/// Verifies that `ArbPublicTransaction::arbitrary` completes without error.
#[test]
fn arb_public_transaction_smoke() {
let buf = vec![0_u8; 4096];
let mut u = Unstructured::new(&buf);
let _ = ArbPublicTransaction::arbitrary(&mut u).expect("should succeed");
}

View File

@ -0,0 +1,135 @@
//! Tests that detect mutations in `generators.rs`.
use arbitrary::Unstructured;
use nssa::{AccountId, PrivateKey};
use crate::generators::{
FuzzAccount, arb_fuzz_native_transfer, arbitrary_fuzz_state, signer_account_ids, test_accounts,
};
/// Verifies that `signer_account_ids` returns a **non-empty** list for a properly signed
/// public transaction.
#[test]
fn signer_ids_nonempty_for_signed_public_tx() {
let accounts = test_accounts();
let (from_id, from_key) = &accounts[0];
let (to_id, _) = &accounts[1];
let tx = common::test_utils::create_transaction_native_token_transfer(
*from_id, 0, // nonce 0 — genesis nonce for the account
*to_id, 100, from_key,
);
let ids = signer_account_ids(&tx);
assert!(
!ids.is_empty(),
"signer_account_ids must return at least one ID for a signed public transaction \
(mutation: function body replaced with vec![])"
);
}
/// Verifies that the returned signer ID matches the account that actually signed the
/// transaction — not a default/zeroed account ID.
#[test]
fn signer_ids_contains_the_signing_account() {
let accounts = test_accounts();
let (from_id, from_key) = &accounts[0];
let (to_id, _) = &accounts[1];
let tx = common::test_utils::create_transaction_native_token_transfer(
*from_id, 0, *to_id, 100, from_key,
);
let ids = signer_account_ids(&tx);
assert!(
ids.contains(from_id),
"signer_account_ids must contain the account ID of the private key that signed \
the transaction; got {ids:?} but expected it to contain {from_id:?}"
);
}
#[test]
fn fuzz_state_never_empty() {
let buf = vec![0_u8; 1000];
let mut u = Unstructured::new(&buf);
let accounts = arbitrary_fuzz_state(&mut u).expect("should succeed");
assert!(
!accounts.is_empty(),
"arbitrary_fuzz_state must return at least 1 account (n = 1..=8); \
returned 0 \u{2014} mutation: `+ 1` replaced by `* 1` or `Ok(vec![])`"
);
}
#[test]
fn fuzz_state_count_uses_modulo_not_div_or_add() {
// fill_buffer reads from the front; the first byte is the n-selector.
let mut buf = vec![0_u8; 1000];
buf[0] = 8; // selector byte: 8 % 8 = 0, +1 -> n=1 | 8 / 8 = 1, +1 -> n=2 | 8 + 8 = 16, +1 -> n=17
let mut u = Unstructured::new(&buf);
let accounts = arbitrary_fuzz_state(&mut u).expect("should succeed");
assert_eq!(
accounts.len(),
1,
"with selector byte=8: (8 % 8) + 1 = 1 account; \
mutation `% \u{2192} /` gives (8/8)+1=2; mutation `% \u{2192} +` gives (8+8)+1=17"
);
}
/// Verifies that each account's balance is <= `u128::MAX / 8`.
#[test]
fn fuzz_state_balances_bounded_by_max_div_8() {
let buf = vec![255_u8; 10_000];
let mut u = Unstructured::new(&buf);
// With correct division, this must NOT overflow (no panic).
let accounts = arbitrary_fuzz_state(&mut u)
.expect("should succeed \u{2014} no overflow with correct / 8 implementation");
let max_balance = u128::MAX / 8;
for acc in &accounts {
assert!(
acc.balance <= max_balance,
"account balance {} exceeds u128::MAX/8={} \u{2014} \
mutation: `/ 8` replaced by `* 8` (overflow) or `% 8`",
acc.balance,
max_balance
);
}
// Ensures the `% 8` mutation is caught: with u128::MAX bytes, correct `/` gives a
// large balance (u128::MAX/8 ~= 3.4e37), while `%` gives only 0-7.
let has_large_balance = accounts.iter().any(|a| a.balance > 7);
assert!(
has_large_balance,
"expected at least one account with balance > 7 \u{2014} \
mutation: `/ 8` replaced by `% 8` (balance capped at 7)"
);
}
#[test]
fn native_transfer_index_uses_modulo_not_div_add() {
let accounts = vec![
FuzzAccount {
account_id: AccountId::new([1_u8; 32]),
balance: 1_000_000,
private_key: PrivateKey::try_new([1_u8; 32]).expect("scalar 1 is a valid private key"),
},
FuzzAccount {
account_id: AccountId::new([2_u8; 32]),
balance: 1_000_000,
private_key: PrivateKey::try_new([2_u8; 32]).expect("scalar 2 is a valid private key"),
},
];
// All-0xFF bytes: the from_idx byte = 255, to_idx byte = 255.
// 255 % 2 = 1 (in-bounds), 255 / 2 = 127 (out-of-bounds), 255 + 2 = 257 (out-of-bounds).
let buf = vec![0xFF_u8; 500];
let mut u = Unstructured::new(&buf);
// With the mutated `/ 2` or `+ 2`, `accounts[127]` or `accounts[257]` panics.
let result = arb_fuzz_native_transfer(&mut u, &accounts);
assert!(
result.is_ok(),
"arb_fuzz_native_transfer should succeed with valid modulo-bounded indices; \
mutation: `% accounts.len()` replaced by `/ accounts.len()` or `+ accounts.len()`"
);
}

View File

@ -1,7 +1,10 @@
use crate::generators::test_accounts;
use crate::invariants::{
BalanceSnapshot, InvariantCtx, NonceSnapshot, assert_invariants,
assert_nonce_increment_correctness,
BalanceConservation, BalanceSnapshot, FailedTxNonceStability, InvariantCtx, NonceSnapshot,
ProtocolInvariant, StateIsolationOnFailure, assert_invariants,
assert_nonce_increment_correctness, assert_replay_rejection, assert_tx_execution_invariants,
};
use common::transaction::LeeTransaction;
use nssa::V03State;
use nssa_core::account::Nonce;
@ -117,3 +120,185 @@ fn failed_tx_nonce_stability_catches_nonce_mutation() {
"expected panic for nonce mutation on failure"
);
}
/// Verifies that `BalanceSnapshot::total` returns the correct arithmetical sum.
#[test]
fn balance_snapshot_total_is_correct_sum() {
let mut map = std::collections::HashMap::new();
map.insert(nssa::AccountId::new([1_u8; 32]), 100_u128);
map.insert(nssa::AccountId::new([2_u8; 32]), 200_u128);
map.insert(nssa::AccountId::new([3_u8; 32]), 700_u128);
let snap = BalanceSnapshot(map);
assert_eq!(
snap.total(),
1000,
"BalanceSnapshot::total must sum all balances"
);
}
/// Ensures `total()` is non-zero when accounts have positive balances.
///
/// Together with `balance_snapshot_total_is_correct_sum`, this forms a pair that
/// catches the `replace total with 0` mutation even when the expected sum is zero
/// in other tests.
#[test]
fn balance_snapshot_total_nonzero_for_positive_balances() {
let mut map = std::collections::HashMap::new();
map.insert(nssa::AccountId::new([42_u8; 32]), 1_u128);
let snap = BalanceSnapshot(map);
assert_ne!(
snap.total(),
0,
"BalanceSnapshot::total must not return 0 when accounts have positive balances \
(mutation: replaced with literal 0)"
);
}
/// Verifies that `StateIsolationOnFailure::name` returns a non-empty, non-"xyzzy" string.
#[test]
fn state_isolation_name_is_nonempty_and_not_placeholder() {
let inv = StateIsolationOnFailure;
let name = inv.name();
assert!(
!name.is_empty(),
"StateIsolationOnFailure::name must not be empty"
);
assert_ne!(
name, "xyzzy",
"StateIsolationOnFailure::name must not be 'xyzzy'"
);
assert_eq!(name, "StateIsolationOnFailure");
}
/// Verifies that `BalanceConservation::name` returns a non-empty, non-"xyzzy" string.
#[test]
fn balance_conservation_name_is_nonempty_and_not_placeholder() {
let inv = BalanceConservation;
let name = inv.name();
assert!(
!name.is_empty(),
"BalanceConservation::name must not be empty"
);
assert_ne!(
name, "xyzzy",
"BalanceConservation::name must not be 'xyzzy'"
);
assert_eq!(name, "BalanceConservation");
}
/// Verifies that `FailedTxNonceStability::name` returns a non-empty, non-"xyzzy" string.
#[test]
fn failed_tx_nonce_stability_name_is_nonempty_and_not_placeholder() {
let inv = FailedTxNonceStability;
let name = inv.name();
assert!(
!name.is_empty(),
"FailedTxNonceStability::name must not be empty"
);
assert_ne!(
name, "xyzzy",
"FailedTxNonceStability::name must not be 'xyzzy'"
);
assert_eq!(name, "FailedTxNonceStability");
}
/// Verifies that `StateIsolationOnFailure::check` returns `Some` when execution failed and
/// the balance in `state_after` differs from `balances_before`.
#[test]
fn state_isolation_check_detects_balance_change_on_failure() {
let acc_id = nssa::AccountId::new([1_u8; 32]);
// State has balance 100 for acc_id.
let state = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0);
// balances_before claims balance was 50, but state_after (== state) has 100.
let mut balances = std::collections::HashMap::new();
balances.insert(acc_id, 50_u128);
let ctx = InvariantCtx {
state_before: &state,
state_after: &state,
execution_succeeded: false, // failure → isolation invariant is active
balances_before: BalanceSnapshot(balances),
nonces_before: make_empty_nonce_snapshot(),
};
let inv = StateIsolationOnFailure;
let result = inv.check(&ctx);
assert!(
result.is_some(),
"StateIsolationOnFailure::check must return Some violation when \
state_after balance (100) differs from balances_before (50) on a failed tx \
(mutations: replace with None; delete !; replace != with ==)"
);
}
/// Verifies that `assert_replay_rejection` panics when the replayed transaction is
/// accepted (i.e. NOT rejected — a genuine invariant violation).
#[test]
fn assert_replay_rejection_panics_when_replay_not_rejected() {
let accounts = test_accounts();
let (from_id, from_key) = &accounts[0];
let (to_id, _) = &accounts[1];
let genesis: Vec<(nssa::AccountId, u128)> = accounts
.iter()
.map(|(id, _)| (*id, 10_000_000_u128))
.collect();
let tx = common::test_utils::create_transaction_native_token_transfer(
*from_id, 0, *to_id, 100, from_key,
);
let validated = tx
.transaction_stateless_check()
.expect("test setup: transaction must pass stateless validation");
let mut scratch_state = V03State::new_with_genesis_accounts(&genesis, vec![], 0);
let applied_tx = validated
.execute_check_on_state(&mut scratch_state, 1, 1)
.expect("test setup: first execution must succeed (block_id=1, timestamp=1)");
// Replay `applied_tx` (nonce 0) against a FRESH state still at nonce 0.
// The nonce matches → execute_check_on_state ACCEPTS the replay — a protocol
// violation that assert_replay_rejection must detect and panic on.
let mut fresh_state = V03State::new_with_genesis_accounts(&genesis, vec![], 0);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
assert_replay_rejection(applied_tx, &mut fresh_state, 1, 1);
}));
assert!(
result.is_err(),
"assert_replay_rejection must panic when the replayed tx is accepted \
(mutation: replace function body with () \u{2014} no-op skips the check)"
);
}
/// Verifies that `assert_tx_execution_invariants` is NOT a no-op by providing a
/// context that violates `StateIsolationOnFailure` and expecting a panic.
#[test]
fn assert_tx_execution_invariants_is_not_noop() {
let acc_id = nssa::AccountId::new([5_u8; 32]);
// Both state_before and state_after have the account at balance 100.
let state_before = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0);
let mut state_after = V03State::new_with_genesis_accounts(&[(acc_id, 100)], vec![], 0);
// Lie: claim balance was 50 before. State_after shows 100.
// With execution_succeeded=false, StateIsolationOnFailure detects the discrepancy.
let mut balances = std::collections::HashMap::new();
balances.insert(acc_id, 50_u128);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
assert_tx_execution_invariants(
&state_before,
&mut state_after,
BalanceSnapshot(balances),
make_empty_nonce_snapshot(),
Err::<LeeTransaction, &str>("simulated failure"),
(1, 1),
);
}));
assert!(
result.is_err(),
"assert_tx_execution_invariants must panic on a StateIsolationOnFailure violation \
(mutation: replace entire function body with () \u{2014} no-op skips all invariant checks)"
);
}

BIN
logos.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

65
scripts/mutants-corpus-test.sh Executable file
View File

@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Plane-B mutation-testing oracle.
#
# Called by `cargo mutants --test-command` from *inside* the logos-execution-zone
# workspace directory after each source mutation. Replays the committed
# libFuzzer corpus against every fuzz target (cargo fuzz run -runs=0).
#
# Exit behaviour (used by cargo-mutants to classify each mutant):
# exit 0 → all corpus replays passed → mutant SURVIVED (corpus gap)
# exit ≠0 → at least one replay panicked → mutant CAUGHT (corpus covers it)
#
# Environment variables:
# FUZZ_REPO absolute path to the lez-fuzzing repository root.
# Defaults to the directory one level above this script.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FUZZ_REPO="${FUZZ_REPO:-"$(cd "${SCRIPT_DIR}/.." && pwd)"}"
CORPUS_ROOT="${FUZZ_REPO}/corpus/libfuzz"
FUZZ_DIR="${FUZZ_REPO}/fuzz"
targets=(
fuzz_transaction_decoding
fuzz_stateless_verification
fuzz_state_transition
fuzz_block_verification
fuzz_encoding_roundtrip
fuzz_signature_verification
fuzz_replay_prevention
fuzz_state_diff_computation
fuzz_validate_execute_consistency
fuzz_state_serialization
fuzz_witness_set_verification
fuzz_program_deployment_lifecycle
fuzz_apply_state_diff_split_path
fuzz_multi_block_state_sequence
fuzz_sequencer_vs_replayer
fuzz_merkle_tree
fuzz_transaction_properties
fuzz_privacy_preserving_witness
fuzz_encoding_privacy_preserving
fuzz_nullifier_set_roundtrip
)
# cargo-fuzz requires the nightly toolchain (-Zsanitizer=address etc.).
# When this script is called by `cargo-mutants` the working directory is the
# LEZ workspace (logos-execution-zone/), whose rust-toolchain.toml pins the
# stable 1.x compiler. Change to the fuzzing repo so that rustup resolves
# the nightly toolchain from lez-fuzzing/rust-toolchain.toml instead.
cd "${FUZZ_REPO}"
for target in "${targets[@]}"; do
corpus="${CORPUS_ROOT}/${target}"
mkdir -p "${corpus}"
# -runs=0 → replay every file in the corpus directory exactly once, then exit.
# A panic (invariant violation) causes cargo fuzz to exit non-zero, which
# propagates through this script and causes cargo-mutants to mark the mutant
# as CAUGHT.
cargo fuzz run "${target}" \
--fuzz-dir "${FUZZ_DIR}" \
"${corpus}" \
-- -runs=0
done