mirror of
https://github.com/logos-blockchain/lez-fuzzing.git
synced 2026-07-02 15:59:38 +00:00
Merge pull request #5 from logos-blockchain/test-privacy-features
test: Privacy preserving features
This commit is contained in:
commit
352edcc127
20
.github/actions/checkout-lez/action.yml
vendored
20
.github/actions/checkout-lez/action.yml
vendored
@ -4,6 +4,18 @@ description: >
|
||||
symlinks it to the expected sibling path (../logos-execution-zone) so that
|
||||
Cargo path dependencies resolve correctly.
|
||||
|
||||
inputs:
|
||||
ref:
|
||||
description: >
|
||||
Git ref (SHA, tag, or branch) of logos-execution-zone to check out.
|
||||
Defaults to the pinned, reviewed revision below — this single line is the
|
||||
source of truth for the LEZ version every PR-gating workflow builds against.
|
||||
To bump it: update ../logos-execution-zone to a tested commit, run
|
||||
`just update-lez`, replace this SHA, and open a PR. The scheduled
|
||||
lez-compat workflow overrides this with `main` to detect upstream drift.
|
||||
required: false
|
||||
default: dac429a94af932b0c827544fff8b9de85b83e6f3
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
@ -11,8 +23,16 @@ runs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: logos-blockchain/logos-execution-zone
|
||||
ref: ${{ inputs.ref }}
|
||||
path: logos-execution-zone
|
||||
|
||||
- name: Symlink logos-execution-zone to sibling directory
|
||||
run: ln -s "$GITHUB_WORKSPACE/logos-execution-zone" "$GITHUB_WORKSPACE/../logos-execution-zone"
|
||||
shell: bash
|
||||
|
||||
- name: Report pinned LEZ revision
|
||||
run: |
|
||||
echo "LEZ ref: ${{ inputs.ref }}"
|
||||
echo "LEZ SHA: $(git -C "$GITHUB_WORKSPACE/logos-execution-zone" rev-parse HEAD)" \
|
||||
| tee -a "$GITHUB_STEP_SUMMARY"
|
||||
shell: bash
|
||||
|
||||
1
.github/workflows/fuzz-afl.yml
vendored
1
.github/workflows/fuzz-afl.yml
vendored
@ -46,6 +46,7 @@ jobs:
|
||||
fuzz_privacy_preserving_witness
|
||||
fuzz_encoding_privacy_preserving
|
||||
fuzz_nullifier_set_roundtrip
|
||||
fuzz_privacy_preserving_state_transition
|
||||
EOF
|
||||
)
|
||||
echo "targets=$targets" >> "$GITHUB_OUTPUT"
|
||||
|
||||
15
.github/workflows/fuzz.yml
vendored
15
.github/workflows/fuzz.yml
vendored
@ -12,6 +12,14 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
# ── Target-inventory gate: fail if any workflow/script/doc omits a target ────
|
||||
target-inventory:
|
||||
name: Target inventory in sync
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: python3 scripts/check_target_inventory.py
|
||||
|
||||
# ── Smoke fuzz: 60 s per target ─────────────────────────────────────────────
|
||||
smoke-fuzz:
|
||||
name: Smoke fuzz (${{ matrix.target }})
|
||||
@ -40,6 +48,7 @@ jobs:
|
||||
- fuzz_privacy_preserving_witness
|
||||
- fuzz_encoding_privacy_preserving
|
||||
- fuzz_nullifier_set_roundtrip
|
||||
- fuzz_privacy_preserving_state_transition
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@ -228,6 +237,7 @@ jobs:
|
||||
- fuzz_privacy_preserving_witness
|
||||
- fuzz_encoding_privacy_preserving
|
||||
- fuzz_nullifier_set_roundtrip
|
||||
- fuzz_privacy_preserving_state_transition
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Checkout logos-execution-zone
|
||||
@ -260,6 +270,8 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: cargo test -p fuzz_props --release
|
||||
- name: Run tests which require RISC0_DEV_MODE off.
|
||||
run: env -u RISC0_DEV_MODE cargo test -p fuzz_props --release synthesized_proof_is_rejected_without_dev_mode
|
||||
|
||||
# ── Performance baseline (nightly only) ─────────────────────────────────────
|
||||
perf-baseline:
|
||||
@ -300,7 +312,8 @@ jobs:
|
||||
fuzz_transaction_properties \
|
||||
fuzz_privacy_preserving_witness \
|
||||
fuzz_encoding_privacy_preserving \
|
||||
fuzz_nullifier_set_roundtrip; do
|
||||
fuzz_nullifier_set_roundtrip \
|
||||
fuzz_privacy_preserving_state_transition; 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
|
||||
|
||||
51
.github/workflows/lez-compat.yml
vendored
Normal file
51
.github/workflows/lez-compat.yml
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
name: LEZ upstream compatibility
|
||||
|
||||
# Detects drift between this fuzzing repo and the *latest* logos-execution-zone
|
||||
# main, independently of PR gating (which builds against the pinned SHA in
|
||||
# .github/actions/checkout-lez). A failure here means upstream main moved in a
|
||||
# way that breaks the harness — bump the pinned ref once the break is resolved.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
RISC0_DEV_MODE: "1"
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build-against-upstream-main:
|
||||
name: Build + test against LEZ main
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout logos-execution-zone (main, not the pinned SHA)
|
||||
uses: ./.github/actions/checkout-lez
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Install Rust nightly
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
components: llvm-tools-preview
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: lez-compat-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Install logos-blockchain-circuits
|
||||
uses: ./logos-execution-zone/.github/actions/install-logos-blockchain-circuits
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build shared fuzz harness against upstream main
|
||||
run: cargo build -p fuzz_props --release
|
||||
|
||||
- name: Run fuzz_props tests against upstream main
|
||||
run: cargo test -p fuzz_props --release
|
||||
6
.github/workflows/lint.yml
vendored
6
.github/workflows/lint.yml
vendored
@ -58,8 +58,8 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install stable toolchain with clippy
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install nightly toolchain with clippy
|
||||
uses: dtolnay/rust-toolchain@nightly
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
@ -72,4 +72,4 @@ jobs:
|
||||
- name: Lint workspace
|
||||
env:
|
||||
RISC0_DEV_MODE: "1"
|
||||
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
run: cargo +nightly clippy --workspace --all-targets --all-features -- -D warnings
|
||||
|
||||
3
.github/workflows/mutants.yml
vendored
3
.github/workflows/mutants.yml
vendored
@ -164,7 +164,8 @@ 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; do
|
||||
fuzz_encoding_privacy_preserving fuzz_nullifier_set_roundtrip \
|
||||
fuzz_privacy_preserving_state_transition; do
|
||||
cargo fuzz build "${target}"
|
||||
done
|
||||
|
||||
|
||||
50
Cargo.lock
generated
50
Cargo.lock
generated
@ -933,6 +933,19 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bonsai-sdk"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a381a5f681e536070483826412fcfcd6f6637921717c6aa0a3759926899ee9c2"
|
||||
dependencies = [
|
||||
"duplicate",
|
||||
"maybe-async",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.6.1"
|
||||
@ -1734,6 +1747,17 @@ version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
|
||||
|
||||
[[package]]
|
||||
name = "duplicate"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e92f10a49176cbffacaedabfaa11d51db1ea0f80a83c26e1873b43cd1742c24"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"proc-macro2-diagnostics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
@ -2227,6 +2251,7 @@ dependencies = [
|
||||
"lee",
|
||||
"lee_core",
|
||||
"proptest",
|
||||
"risc0-zkvm",
|
||||
"testnet_initial_state",
|
||||
]
|
||||
|
||||
@ -4545,6 +4570,17 @@ dependencies = [
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maybe-async"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@ -5482,6 +5518,18 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2-diagnostics"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prometheus-client"
|
||||
version = "0.22.3"
|
||||
@ -5894,6 +5942,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
@ -6245,6 +6294,7 @@ dependencies = [
|
||||
"addr2line",
|
||||
"anyhow",
|
||||
"bincode",
|
||||
"bonsai-sdk",
|
||||
"borsh",
|
||||
"bytemuck",
|
||||
"bytes",
|
||||
|
||||
11
Justfile
11
Justfile
@ -299,7 +299,9 @@ fuzz-afl TARGET="" TIME="30":
|
||||
echo "Binary not found — building $t first…"
|
||||
just afl-build-target "$t"
|
||||
fi
|
||||
timeout "$TIME" afl-fuzz -i "$CORPUS" -o "$OUTPUT" -- "$BINARY" || true
|
||||
# Use afl-fuzz's own -V (run for N seconds then exit) instead of GNU
|
||||
# `timeout`, which is not installed by default on macOS.
|
||||
afl-fuzz -V "$TIME" -i "$CORPUS" -o "$OUTPUT" -- "$BINARY" || true
|
||||
}
|
||||
for t in "${TARGETS[@]}"; do
|
||||
echo "=== afl++ $t for ${TIME}s ==="
|
||||
@ -427,7 +429,9 @@ fuzz-afl-parallel TIME="30" JOBS="4":
|
||||
echo "Binary not found — building $t first…"
|
||||
just afl-build-target "$t"
|
||||
fi
|
||||
timeout {{TIME}} afl-fuzz -i "$CORPUS" -o "$OUTPUT" -- "$BINARY" || true
|
||||
# Use afl-fuzz's own -V (run for N seconds then exit) instead of GNU
|
||||
# `timeout`, which is not installed by default on macOS.
|
||||
afl-fuzz -V {{TIME}} -i "$CORPUS" -o "$OUTPUT" -- "$BINARY" || true
|
||||
}
|
||||
echo "Targets: ${#TARGETS[@]} | max parallel: {{JOBS}} | time per target: {{TIME}}s"
|
||||
for t in "${TARGETS[@]}"; do
|
||||
@ -462,7 +466,8 @@ afl-corpus-sync:
|
||||
[ -d "$QUEUE" ] || continue
|
||||
for f in "$QUEUE"/id:*; do
|
||||
[ -f "$f" ] || continue
|
||||
HASH=$(sha1sum "$f" | cut -d' ' -f1)
|
||||
# sha1sum (GNU/Linux) is absent on stock macOS; fall back to shasum.
|
||||
HASH=$( { sha1sum "$f" 2>/dev/null || shasum -a 1 "$f"; } | cut -d' ' -f1)
|
||||
DEST_FILE="${DEST}/${HASH}"
|
||||
if [ ! -f "$DEST_FILE" ]; then
|
||||
cp "$f" "$DEST_FILE"
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
[Logos Execution Zone (LEZ)](https://github.com/logos-blockchain/logos-execution-zone) protocol.**
|
||||
|
||||
[](rust-toolchain.toml)
|
||||
[](#-fuzz-targets)
|
||||
[](#-fuzz-targets)
|
||||
[](.github/workflows/mutants.yml)
|
||||
[](LICENSE-MIT)
|
||||
|
||||
@ -31,7 +31,7 @@ lez-fuzzing/
|
||||
│ └── generators.rs # Arbitrary / proptest strategies
|
||||
├── fuzz/ # cargo-fuzz crate (own [workspace] sentinel)
|
||||
│ ├── Cargo.toml
|
||||
│ ├── fuzz_targets/ # 20 targets total — see table below
|
||||
│ ├── fuzz_targets/ # 21 targets total — see table below
|
||||
│ │ ├── _template.rs # Template for `just new-target`
|
||||
│ │ └── fuzz_*.rs
|
||||
│ └── corpus/ # Curated seed inputs (one dir per target)
|
||||
@ -130,6 +130,7 @@ just fuzz-props
|
||||
| 18 | `fuzz_privacy_preserving_witness` | `privacy_preserving_transaction::WitnessSet`: CorrectVerification (witness for msg A passes `signatures_are_valid_for(A)`) + MessageIsolation + SignerIdsMatchWitnessKeys |
|
||||
| 19 | `fuzz_encoding_privacy_preserving` | Privacy-preserving encoding: MessageEncodingRoundtrip + TxEncodingDeterministic/NonEmpty |
|
||||
| 20 | `fuzz_nullifier_set_roundtrip` | `NullifierSet` Borsh serialisation: NullifierSetRoundtrip (decode→encode identity for the hand-written impl) |
|
||||
| 21 | `fuzz_privacy_preserving_state_transition` | Path B — `NSSATransaction::PrivacyPreserving` through `execute_check_on_state` with a dev-mode passing proof: reaches commitment/nullifier checks 5–6 + `apply_state_diff`. Asserts no-panic, StateIsolationOnFailure, PrivateStateIsolationOnFailure, CommitmentInsertion, NonceIncrementCorrectness, PostStateApplied, ReplayRejection (balance conservation intentionally not asserted — the fake proof bypasses the circuit guarantee) |
|
||||
|
||||
Each target lives at `fuzz/fuzz_targets/<name>.rs`.
|
||||
|
||||
|
||||
@ -129,6 +129,7 @@ just fuzz-regression
|
||||
| `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` |
|
||||
| `fuzz_privacy_preserving_state_transition` | Path B — `NSSATransaction::PrivacyPreserving` through `execute_check_on_state` with a dev-mode passing proof, reaching checks 5–6 (`check_commitments_are_new` / `check_nullifiers_are_valid`) and `apply_state_diff`: **No panic**, **StateIsolationOnFailure**, **PrivateStateIsolationOnFailure**, **CommitmentInsertion**, **NonceIncrementCorrectness**, **PostStateApplied**, **ReplayRejection**. Balance conservation is intentionally *not* asserted — the synthesised fake receipt bypasses the circuit guarantee. Requires `RISC0_DEV_MODE=1` | `fuzz/fuzz_targets/fuzz_privacy_preserving_state_transition.rs` |
|
||||
|
||||
---
|
||||
|
||||
@ -376,8 +377,8 @@ The nightly AFL++ CI workflow has two jobs:
|
||||
|
||||
| Job | Triggers | Matrix |
|
||||
|-----|----------|--------|
|
||||
| `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 |
|
||||
| `afl-smoke` | nightly + `workflow_dispatch` | all 21 targets, 60 s each |
|
||||
| `afl-coverage-aggregate` | nightly, `needs: afl-smoke` | all 21 targets merged into one LLVM HTML report |
|
||||
|
||||
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`
|
||||
@ -387,7 +388,7 @@ The smoke job (one matrix leg per target, on `ubuntu-latest`):
|
||||
|
||||
The coverage-aggregate job:
|
||||
1. Downloads every smoke leg's findings
|
||||
2. Rebuilds all 20 targets with `RUSTFLAGS="-C instrument-coverage"`
|
||||
2. Rebuilds all 21 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`
|
||||
|
||||
@ -620,6 +621,7 @@ Measured on a 4-core x86_64 Linux runner with `RISC0_DEV_MODE=1`:
|
||||
| `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)* |
|
||||
| `fuzz_privacy_preserving_state_transition` | slow — dev-mode proof synthesis + verification per exec dominates runtime *(estimate)* |
|
||||
|
||||
> [!NOTE]
|
||||
> Throughput figures for the five new targets are rough estimates; run `just perf-baseline`
|
||||
@ -691,6 +693,7 @@ from `data`; if a check doesn't depend on the input, write it as a unit test in
|
||||
|
||||
| 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 |
|
||||
| `PrivacyPreservingTransaction` encoding | Still excluded from `fuzz_encoding_roundtrip` because a real ZK receipt cannot be reconstructed in a fast fuzzing loop. Encoding is instead covered by the dedicated `fuzz_encoding_privacy_preserving` target |
|
||||
| `PrivacyPreservingTransaction` state transition (fake-receipt caveat) | Covered by `fuzz_privacy_preserving_state_transition`, which drives `execute_check_on_state` under `RISC0_DEV_MODE=1` using a synthesised *passing* proof (a dev-mode fake receipt). Because the proof is forced to pass, the circuit's balance-conservation guarantee is bypassed, so this target intentionally does **not** assert balance conservation — see `fuzz_props::privacy::synthesize_passing_proof` for the binding caveat |
|
||||
| `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 |
|
||||
| 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 |
|
||||
| LEZ version tracking | CI pins the upstream revision in a single place — the `ref` input default of `.github/actions/checkout-lez`. PR-gating workflows build against that pinned SHA for reproducibility; the scheduled `lez-compat.yml` workflow overrides it with `main` to flag upstream drift. Locally, `lez-fuzzing` still reads `../logos-execution-zone` as checked out. To bump: update that repo to a tested commit, run `just update-lez` (which does `git pull --ff-only`), replace the SHA in the action, and open a PR |
|
||||
|
||||
50
fuzz/Cargo.lock
generated
50
fuzz/Cargo.lock
generated
@ -897,6 +897,19 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bonsai-sdk"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a381a5f681e536070483826412fcfcd6f6637921717c6aa0a3759926899ee9c2"
|
||||
dependencies = [
|
||||
"duplicate",
|
||||
"maybe-async",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.6.1"
|
||||
@ -1650,6 +1663,17 @@ version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
|
||||
|
||||
[[package]]
|
||||
name = "duplicate"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e92f10a49176cbffacaedabfaa11d51db1ea0f80a83c26e1873b43cd1742c24"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"proc-macro2-diagnostics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
@ -2097,6 +2121,7 @@ dependencies = [
|
||||
"lee",
|
||||
"lee_core",
|
||||
"proptest",
|
||||
"risc0-zkvm",
|
||||
"testnet_initial_state",
|
||||
]
|
||||
|
||||
@ -4258,6 +4283,17 @@ version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
|
||||
|
||||
[[package]]
|
||||
name = "maybe-async"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
@ -5097,6 +5133,18 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2-diagnostics"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prometheus-client"
|
||||
version = "0.22.3"
|
||||
@ -5463,6 +5511,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
@ -5697,6 +5746,7 @@ checksum = "22b7eafb5d85be59cbd9da83f662cf47d834f1b836e14f675d1530b12c666867"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bincode",
|
||||
"bonsai-sdk",
|
||||
"borsh",
|
||||
"bytemuck",
|
||||
"bytes",
|
||||
|
||||
@ -149,3 +149,9 @@ name = "fuzz_nullifier_set_roundtrip"
|
||||
path = "fuzz_targets/fuzz_nullifier_set_roundtrip.rs"
|
||||
test = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_privacy_preserving_state_transition"
|
||||
path = "fuzz_targets/fuzz_privacy_preserving_state_transition.rs"
|
||||
test = false
|
||||
bench = false
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
//!
|
||||
//! 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.
|
||||
//! maps each circuit-output field onto the resulting `Message` unchanged.
|
||||
//!
|
||||
//! `PrivacyPreservingTransaction` is also tested for serialisation stability
|
||||
//! (non-empty, deterministic bytes) without requiring a real ZK receipt.
|
||||
@ -18,8 +18,8 @@ use nssa::{
|
||||
},
|
||||
};
|
||||
use nssa_core::{
|
||||
PrivacyPreservingCircuitOutput,
|
||||
account::Nonce,
|
||||
Commitment, PrivacyPreservingCircuitOutput,
|
||||
account::{Account, Nonce},
|
||||
program::{BlockValidityWindow, TimestampValidityWindow},
|
||||
};
|
||||
|
||||
@ -107,31 +107,56 @@ fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
);
|
||||
}
|
||||
|
||||
// ── INVARIANT [CircuitOutputAccepted] ─────────────────────────────────────
|
||||
// `try_from_circuit_output` must succeed for a well-formed (empty) circuit
|
||||
// output, mapping the output fields onto the resulting `Message`.
|
||||
// ── INVARIANT [CircuitOutputMapping] ──────────────────────────────────────
|
||||
// `try_from_circuit_output` carries each circuit-output field onto the resulting
|
||||
// `Message` unchanged, and threads through the caller-supplied public_account_ids /
|
||||
// nonces. The function is infallible (it performs no validation of its own), so a
|
||||
// bare `is_ok()` would be a tautology; instead assert the field mapping, which catches
|
||||
// a mutation that drops, swaps, or defaults any carried field.
|
||||
{
|
||||
let empty_output = PrivacyPreservingCircuitOutput {
|
||||
let addr = AccountId::from(
|
||||
&PublicKey::new_from_private_key(
|
||||
&PrivateKey::try_new([1_u8; 32]).expect("known-good"),
|
||||
),
|
||||
);
|
||||
let account_ids = vec![addr];
|
||||
let nonces = vec![Nonce::from(7_u128)];
|
||||
let post_states = vec![Account::default()];
|
||||
let commitments =
|
||||
vec![Commitment::new(&AccountId::new([9_u8; 32]), &Account::default())];
|
||||
|
||||
let output = PrivacyPreservingCircuitOutput {
|
||||
public_pre_states: vec![],
|
||||
public_post_states: vec![],
|
||||
new_commitments: vec![],
|
||||
public_post_states: post_states.clone(),
|
||||
new_commitments: commitments.clone(),
|
||||
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,
|
||||
let msg = PPMessage::try_from_circuit_output(account_ids.clone(), nonces.clone(), output)
|
||||
.expect("INVARIANT VIOLATION [CircuitOutputMapping]: \
|
||||
try_from_circuit_output is infallible and must accept any output");
|
||||
|
||||
assert_eq!(
|
||||
msg.public_account_ids, account_ids,
|
||||
"INVARIANT VIOLATION [CircuitOutputMapping]: \
|
||||
public_account_ids not threaded through unchanged",
|
||||
);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"INVARIANT VIOLATION [CircuitOutputAccepted]: \
|
||||
try_from_circuit_output must accept a well-formed empty output, \
|
||||
got: {:?}",
|
||||
result.err(),
|
||||
assert_eq!(
|
||||
msg.nonces, nonces,
|
||||
"INVARIANT VIOLATION [CircuitOutputMapping]: nonces not threaded through unchanged",
|
||||
);
|
||||
assert_eq!(
|
||||
msg.public_post_states, post_states,
|
||||
"INVARIANT VIOLATION [CircuitOutputMapping]: \
|
||||
public_post_states not carried from the circuit output",
|
||||
);
|
||||
assert_eq!(
|
||||
msg.new_commitments, commitments,
|
||||
"INVARIANT VIOLATION [CircuitOutputMapping]: \
|
||||
new_commitments not carried from the circuit output",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
179
fuzz/fuzz_targets/fuzz_privacy_preserving_state_transition.rs
Normal file
179
fuzz/fuzz_targets/fuzz_privacy_preserving_state_transition.rs
Normal file
@ -0,0 +1,179 @@
|
||||
#![cfg_attr(feature = "fuzzer-libfuzzer", no_main)]
|
||||
//! Path B — full state-transition coverage for the privacy-preserving executor.
|
||||
//!
|
||||
//! This is the only target that drives `NSSATransaction::PrivacyPreserving` through
|
||||
//! `execute_check_on_state` with a proof that *passes* `Proof::is_valid_for`, reaching the
|
||||
//! previously-0%-covered checks 5 (`check_commitments_are_new`) and 6
|
||||
//! (`check_nullifiers_are_valid`) and the `apply_state_diff` state mutation. The passing
|
||||
//! proof is a dev-mode fake receipt synthesised per message+state by
|
||||
//! [`fuzz_props::privacy::synthesize_passing_proof`] — see that module for the binding
|
||||
//! caveat. Requires `RISC0_DEV_MODE=1` (set by every `just fuzz` recipe).
|
||||
//!
|
||||
//! # Invariants asserted
|
||||
//!
|
||||
//! Because the proof is *forced* to pass, balance conservation is intentionally **not**
|
||||
//! asserted (under a real proof the circuit enforces it, and forcing a pass bypasses exactly
|
||||
//! that). The properties below all hold regardless of the proof being synthesised:
|
||||
//!
|
||||
//! * **No panic** — the executor never crashes on any generated transaction.
|
||||
//! * **StateIsolationOnFailure / FailedTxNonceStability** — a rejected transaction leaves
|
||||
//! public balances and nonces untouched (shared, mutation-tested invariants).
|
||||
//! * **PrivateStateIsolationOnFailure** — a rejected transaction inserts no commitments.
|
||||
//! * **CommitmentInsertion** — every commitment in an accepted transaction is a member of
|
||||
//! the commitment set afterwards (check 5 reached and applied).
|
||||
//! * **NonceIncrementCorrectness** — an accepted transaction increments each signer's public
|
||||
//! account nonce by exactly one (bug class #5: nonce-increment asymmetry); asserted on
|
||||
//! signers not also overwritten as a public post-state.
|
||||
//! * **PostStateApplied** — each non-signer public account is set to its declared
|
||||
//! post-state.
|
||||
//! * **ReplayRejection** — re-applying an accepted transaction is rejected.
|
||||
|
||||
use arbitrary::{Arbitrary, Unstructured};
|
||||
use common::transaction::LeeTransaction;
|
||||
use fuzz_props::generators::arbitrary_fuzz_state;
|
||||
use fuzz_props::invariants::{
|
||||
BalanceSnapshot, FailedTxNonceStability, InvariantCtx, NonceSnapshot, ProtocolInvariant,
|
||||
StateIsolationOnFailure, assert_nonce_increment_correctness, assert_replay_rejection,
|
||||
};
|
||||
use fuzz_props::privacy::arb_privacy_preserving_tx;
|
||||
use nssa::{AccountId, V03State};
|
||||
|
||||
fuzz_props::fuzz_entry!(|data: &[u8]| {
|
||||
let mut u = Unstructured::new(data);
|
||||
|
||||
// Fuzz-driven genesis accounts (with keys) — same approach as fuzz_state_transition.
|
||||
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 mut state = V03State::new_with_genesis_accounts(&init_accs, vec![], 0);
|
||||
|
||||
// Apply a short sequence so multi-transaction state evolution (commitment growth,
|
||||
// signer-nonce advance) is exercised. Each transaction's proof is synthesised against
|
||||
// the *current* (already-mutated) state.
|
||||
let n_txs: u8 = u8::arbitrary(&mut u).unwrap_or(0) % 6;
|
||||
for i in 0..n_txs {
|
||||
let Ok(tx) = arb_privacy_preserving_tx(&mut u, &state, &fuzz_accs) else {
|
||||
break;
|
||||
};
|
||||
|
||||
// Capture everything needed for the success-path invariants *before* the
|
||||
// transaction is consumed by execution.
|
||||
let signer_ids: Vec<AccountId> = tx
|
||||
.witness_set()
|
||||
.signatures_and_public_keys()
|
||||
.iter()
|
||||
.map(|(_, pk)| AccountId::from(pk))
|
||||
.collect();
|
||||
let public_account_ids = tx.message().public_account_ids.clone();
|
||||
let public_post_states = tx.message().public_post_states.clone();
|
||||
let new_commitments = tx.message().new_commitments.clone();
|
||||
|
||||
let lee_tx = LeeTransaction::PrivacyPreserving(tx);
|
||||
|
||||
// Stateless gate — `WitnessSet::for_message` signs correctly so this passes, but we
|
||||
// keep the same gate the production path applies before state transitions.
|
||||
let Ok(lee_tx) = lee_tx.transaction_stateless_check() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Track the genesis accounts plus this transaction's signers and public accounts so
|
||||
// the isolation snapshots cover every account the transaction could touch.
|
||||
let mut tracked: Vec<AccountId> = init_accs.iter().map(|&(id, _)| id).collect();
|
||||
for &id in signer_ids.iter().chain(public_account_ids.iter()) {
|
||||
if !tracked.contains(&id) {
|
||||
tracked.push(id);
|
||||
}
|
||||
}
|
||||
let balances_before = BalanceSnapshot(
|
||||
tracked
|
||||
.iter()
|
||||
.map(|&id| (id, state.get_account_by_id(id).balance))
|
||||
.collect(),
|
||||
);
|
||||
let nonces_before = NonceSnapshot(
|
||||
tracked
|
||||
.iter()
|
||||
.map(|&id| (id, state.get_account_by_id(id).nonce))
|
||||
.collect(),
|
||||
);
|
||||
let digest_before = state.commitment_set_digest();
|
||||
|
||||
let block_id: u64 = 1 + u64::from(i);
|
||||
let timestamp: u64 = u64::from(i);
|
||||
let state_before = state.clone();
|
||||
|
||||
let result = lee_tx.execute_check_on_state(&mut state, block_id, timestamp);
|
||||
let succeeded = result.is_ok();
|
||||
|
||||
// ── Failure-path isolation (shared, mutation-tested invariants) ──────────────
|
||||
let ctx = InvariantCtx {
|
||||
state_before: &state_before,
|
||||
state_after: &state,
|
||||
execution_succeeded: succeeded,
|
||||
balances_before: balances_before.clone(),
|
||||
nonces_before: nonces_before.clone(),
|
||||
};
|
||||
if let Some(v) = StateIsolationOnFailure.check(&ctx) {
|
||||
panic!("INVARIANT VIOLATION [{}]: {}", v.invariant, v.message);
|
||||
}
|
||||
if let Some(v) = FailedTxNonceStability.check(&ctx) {
|
||||
panic!("INVARIANT VIOLATION [{}]: {}", v.invariant, v.message);
|
||||
}
|
||||
if !succeeded {
|
||||
// A rejected privacy-preserving transaction must not touch private state.
|
||||
assert_eq!(
|
||||
state.commitment_set_digest(),
|
||||
digest_before,
|
||||
"INVARIANT VIOLATION [PrivateStateIsolationOnFailure]: commitment set changed \
|
||||
despite privacy-preserving transaction rejection",
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(applied_tx) = result {
|
||||
// Check 5 reached and applied: every accepted commitment is now a member.
|
||||
for commitment in &new_commitments {
|
||||
assert!(
|
||||
state.get_proof_for_commitment(commitment).is_some(),
|
||||
"INVARIANT VIOLATION [CommitmentInsertion]: accepted commitment was not \
|
||||
inserted into the commitment set",
|
||||
);
|
||||
}
|
||||
|
||||
// Bug class #5 — the privacy path increments the nonce on the signer's *public*
|
||||
// account. Assert it for signers that are not also overwritten verbatim by a
|
||||
// public post-state (those are set then incremented, so nonce_before+1 need not
|
||||
// hold).
|
||||
let isolated_signers: Vec<AccountId> = signer_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| !public_account_ids.contains(id))
|
||||
.collect();
|
||||
assert_nonce_increment_correctness(&isolated_signers, &nonces_before, &state);
|
||||
|
||||
// Non-signer public accounts at applied indices are set to their post-state.
|
||||
for (idx, id) in public_account_ids.iter().enumerate() {
|
||||
if idx >= public_post_states.len() {
|
||||
break; // public_diff zips ids with post_states, truncating to the shorter vec
|
||||
}
|
||||
if signer_ids.contains(id) {
|
||||
continue; // signer accounts also get a nonce increment afterwards
|
||||
}
|
||||
assert_eq!(
|
||||
state.get_account_by_id(*id),
|
||||
public_post_states[idx],
|
||||
"INVARIANT VIOLATION [PostStateApplied]: public account was not set to its \
|
||||
declared post-state",
|
||||
);
|
||||
}
|
||||
|
||||
// An accepted transaction must be rejected on replay (spent nullifier / reused
|
||||
// commitment / advanced nonce / diverged proof journal).
|
||||
assert_replay_rejection(applied_tx, &mut state, block_id + 1, timestamp + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -15,6 +15,9 @@ nssa = { workspace = true }
|
||||
nssa_core = { workspace = true }
|
||||
common = { workspace = true }
|
||||
borsh = { workspace = true }
|
||||
# Needed by `privacy.rs` to synthesise a *passing* dev-mode fake receipt (Path B):
|
||||
# a privacy-preserving proof is a borsh-encoded `risc0_zkvm::InnerReceipt`.
|
||||
risc0-zkvm = { workspace = true }
|
||||
proptest = "1.4"
|
||||
arbitrary = { version = "1", features = ["derive"] }
|
||||
testnet_initial_state = { workspace = true }
|
||||
|
||||
@ -211,9 +211,11 @@ impl<'a> Arbitrary<'a> for ArbProgramDeploymentTransaction {
|
||||
}
|
||||
|
||||
// ── 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`.
|
||||
// `PrivacyPreservingTransaction` is intentionally excluded *here*: a passing proof
|
||||
// binds to the live chain state, so it cannot be produced by a state-independent
|
||||
// `Arbitrary` impl. Privacy-preserving state-transition coverage (Path B) lives in
|
||||
// [`crate::privacy`], which synthesises a per-message dev-mode fake receipt against the
|
||||
// current state and is driven by the `fuzz_privacy_preserving_state_transition` target.
|
||||
|
||||
/// Newtype wrapper providing [`Arbitrary`] for [`LeeTransaction`].
|
||||
///
|
||||
|
||||
@ -74,6 +74,21 @@ pub fn arbitrary_fuzz_state(u: &mut Unstructured<'_>) -> arbitrary::Result<Vec<F
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reduce raw fuzzer draws into a *biased-valid* `(nonce, amount)` pair.
|
||||
///
|
||||
/// The nonce is mapped into `0..=3` (near the genesis value) and the amount into
|
||||
/// `0..=balance`, so the success path is actually reached. Extracted as a pure
|
||||
/// function so the reduction arithmetic is unit-testable.
|
||||
pub(crate) fn biased_valid_nonce_amount(
|
||||
nonce_byte: u8,
|
||||
amount_raw: u128,
|
||||
balance: u128,
|
||||
) -> (u128, u128) {
|
||||
let nonce = u128::from(nonce_byte) % 4; // 0..=3
|
||||
let amount = amount_raw % balance.saturating_add(1); // 0..=balance
|
||||
(nonce, amount)
|
||||
}
|
||||
|
||||
/// Generate a native-transfer [`LeeTransaction`] between two accounts chosen
|
||||
/// from `accounts`.
|
||||
///
|
||||
@ -84,6 +99,9 @@ pub fn arbitrary_fuzz_state(u: &mut Unstructured<'_>) -> arbitrary::Result<Vec<F
|
||||
///
|
||||
/// Self-transfers (`from_idx == to_idx`) are allowed since they are a useful
|
||||
/// edge case (balance should remain unchanged).
|
||||
///
|
||||
/// The `nonce`/`amount` draw is biased toward valid inputs so the success path
|
||||
/// is actually reached, with a minority branch for the rejection paths.
|
||||
pub fn arb_fuzz_native_transfer(
|
||||
u: &mut Unstructured<'_>,
|
||||
accounts: &[FuzzAccount],
|
||||
@ -93,12 +111,18 @@ pub fn arb_fuzz_native_transfer(
|
||||
}
|
||||
let from_idx = (u8::arbitrary(u)? as usize) % accounts.len();
|
||||
let to_idx = (u8::arbitrary(u)? as usize) % accounts.len();
|
||||
let nonce = u128::arbitrary(u)?;
|
||||
let amount = u128::arbitrary(u)?;
|
||||
|
||||
let from = &accounts[from_idx];
|
||||
let to = &accounts[to_idx];
|
||||
|
||||
let (nonce, amount) = if bool::arbitrary(u)? {
|
||||
// Biased valid: nonce near the genesis value, amount within balance.
|
||||
biased_valid_nonce_amount(u8::arbitrary(u)?, u128::arbitrary(u)?, from.balance)
|
||||
} else {
|
||||
// Adversarial: full range drives the rejection paths.
|
||||
(u128::arbitrary(u)?, u128::arbitrary(u)?)
|
||||
};
|
||||
|
||||
Ok(
|
||||
common::test_utils::create_transaction_native_token_transfer(
|
||||
from.account_id,
|
||||
|
||||
@ -60,10 +60,16 @@
|
||||
clippy::let_underscore_untyped,
|
||||
reason = "seed-generation IO errors are intentionally ignored in tests"
|
||||
)]
|
||||
#![allow(
|
||||
clippy::pub_with_shorthand,
|
||||
reason = "`pub(crate)` shorthand exposes generators to the test module; the \
|
||||
contradictory `pub_without_shorthand` restriction lint stays active"
|
||||
)]
|
||||
|
||||
pub mod arbitrary_types;
|
||||
pub mod generators;
|
||||
pub mod invariants;
|
||||
pub mod privacy;
|
||||
|
||||
/// Generates the fuzzer entry point for whichever engine this crate is
|
||||
/// compiled with, selected via Cargo features:
|
||||
|
||||
323
fuzz_props/src/privacy.rs
Normal file
323
fuzz_props/src/privacy.rs
Normal file
@ -0,0 +1,323 @@
|
||||
//! Privacy-preserving state-transition fuzzing support — **Path B**.
|
||||
//!
|
||||
//! Path A (`fuzz_encoding_privacy_preserving`, `fuzz_privacy_preserving_witness`) covers
|
||||
//! the *encoding* of privacy-preserving transactions. It does not reach the
|
||||
//! privacy-preserving *executor*:
|
||||
//! [`ValidatedStateDiff::from_privacy_preserving_transaction`] performs ten distinct
|
||||
//! checks, of which checks 5 and 6 (`check_commitments_are_new`,
|
||||
//! `check_nullifiers_are_valid`) and the subsequent `apply_state_diff` were **0% covered**
|
||||
//! because they are only reachable behind a proof that *passes* `Proof::is_valid_for`.
|
||||
//!
|
||||
//! # How a passing proof is obtained without a prover
|
||||
//!
|
||||
//! `Proof::is_valid_for` borsh-decodes the proof bytes into a `risc0_zkvm::InnerReceipt`,
|
||||
//! wraps it in a `Receipt` whose journal is `circuit_output.to_bytes()`, and calls
|
||||
//! `Receipt::verify(PRIVACY_PRESERVING_CIRCUIT_ID)`. Under `RISC0_DEV_MODE=1` (exported by
|
||||
//! every `just fuzz` recipe) a [`FakeReceipt`] passes the integrity step without any ZK
|
||||
//! computation — **but** `Receipt::verify` still checks that the receipt's *claim digest*
|
||||
//! equals `ReceiptClaim::ok(image_id, journal_digest).digest()`. A fake receipt is therefore
|
||||
//! bound to one exact journal and circuit id; it cannot be precomputed once and reused
|
||||
//! across fuzz-varied messages (the "binding caveat" in
|
||||
//! `../privacy_preserving_coverage_gap.md`).
|
||||
//!
|
||||
//! [`synthesize_passing_proof`] takes the per-message route: it reconstructs the exact
|
||||
//! [`PrivacyPreservingCircuitOutput`] the validator will build — including
|
||||
//! `public_pre_states`, which the validator reads from live chain state — then builds a
|
||||
//! [`FakeReceipt`] whose `ReceiptClaim::ok` matches that journal. Check 4 then passes for
|
||||
//! that specific (message, state) pair, and execution proceeds into checks 5–6 and state
|
||||
//! application.
|
||||
//!
|
||||
//! # Soundness note for callers
|
||||
//!
|
||||
//! Because the proof is *forced* to pass, this harness deliberately does **not** assert
|
||||
//! balance conservation: under a real proof the circuit is what guarantees the
|
||||
//! `public_post_states` conserve value, and that guarantee is exactly what a synthesised
|
||||
//! pass bypasses. Asserting conservation here would only re-test the fake. The sound
|
||||
//! invariants for this path — no panic, state isolation on rejection, commitment insertion,
|
||||
//! signer-nonce increment, post-state application, and replay rejection — are checked by the
|
||||
//! `fuzz_privacy_preserving_state_transition` target.
|
||||
|
||||
use arbitrary::{Arbitrary, Result as ArbResult, Unstructured};
|
||||
use borsh::to_vec as borsh_to_vec;
|
||||
use nssa::{
|
||||
AccountId, PRIVACY_PRESERVING_CIRCUIT_ID, PrivacyPreservingTransaction, PrivateKey, PublicKey,
|
||||
V03State,
|
||||
privacy_preserving_transaction::{
|
||||
Message as PPMessage, WitnessSet as PPWitnessSet, circuit::Proof,
|
||||
},
|
||||
};
|
||||
use nssa_core::{
|
||||
Commitment, CommitmentSetDigest, EncryptedAccountData, EncryptionScheme, EphemeralPublicKey,
|
||||
Nullifier, PrivacyPreservingCircuitOutput, PrivateAccountKind, SharedSecretKey,
|
||||
account::{Account, AccountWithMetadata, Nonce},
|
||||
program::ValidityWindow,
|
||||
};
|
||||
use risc0_zkvm::{FakeReceipt, InnerReceipt, ReceiptClaim};
|
||||
|
||||
use crate::generators::FuzzAccount;
|
||||
|
||||
/// Synthesise a [`Proof`] that **passes** `Proof::is_valid_for` for `message` against
|
||||
/// `state`, under `RISC0_DEV_MODE`.
|
||||
///
|
||||
/// `signer_account_ids` must be the ids the validator will derive from the witness set —
|
||||
/// i.e. `AccountId::from(public_key)` for every key the message is signed with. They drive
|
||||
/// the `is_authorized` flag of each reconstructed `public_pre_state`, so they must match the
|
||||
/// witness set exactly or the journal digest diverges and the proof is rejected at check 4.
|
||||
///
|
||||
/// The returned proof is valid **only** for this exact `(message, state, signers)` triple;
|
||||
/// it must be regenerated whenever any of them changes (notably after a prior transaction
|
||||
/// has mutated `state`).
|
||||
#[must_use]
|
||||
pub fn synthesize_passing_proof(
|
||||
message: &PPMessage,
|
||||
state: &V03State,
|
||||
signer_account_ids: &[AccountId],
|
||||
) -> Proof {
|
||||
// Reconstruct `public_pre_states` byte-for-byte as
|
||||
// `ValidatedStateDiff::from_privacy_preserving_transaction` does: read each public
|
||||
// account from live chain state, marking it authorised iff it signed.
|
||||
let public_pre_states: Vec<AccountWithMetadata> = message
|
||||
.public_account_ids
|
||||
.iter()
|
||||
.map(|account_id| {
|
||||
AccountWithMetadata::new(
|
||||
state.get_account_by_id(*account_id),
|
||||
signer_account_ids.contains(account_id),
|
||||
*account_id,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let output = PrivacyPreservingCircuitOutput {
|
||||
public_pre_states,
|
||||
public_post_states: message.public_post_states.clone(),
|
||||
encrypted_private_post_states: message.encrypted_private_post_states.clone(),
|
||||
new_commitments: message.new_commitments.clone(),
|
||||
new_nullifiers: message.new_nullifiers.clone(),
|
||||
block_validity_window: message.block_validity_window,
|
||||
timestamp_validity_window: message.timestamp_validity_window,
|
||||
};
|
||||
|
||||
// `ReceiptClaim::ok` fixes exit code Halted(0) and binds (image_id, journal_digest);
|
||||
// `Receipt::verify` reconstructs exactly this claim, so the digests match. In dev mode
|
||||
// the fake integrity check is a pass-through, so the whole receipt verifies.
|
||||
let journal = output.to_bytes();
|
||||
let claim = ReceiptClaim::ok(PRIVACY_PRESERVING_CIRCUIT_ID, journal);
|
||||
let inner = InnerReceipt::Fake(FakeReceipt::new(claim));
|
||||
let proof_bytes = borsh_to_vec(&inner).expect("InnerReceipt is borsh-serialisable");
|
||||
Proof::from_inner(proof_bytes)
|
||||
}
|
||||
|
||||
/// Build a fuzz-driven [`Account`] for use as a private commitment pre-image or a
|
||||
/// `public_post_state`.
|
||||
///
|
||||
/// The nonce is intentionally capped well below `u128::MAX`: a `public_post_state` is
|
||||
/// applied verbatim and a signer's nonce is then incremented, and the protocol's
|
||||
/// `public_account_nonce_increment` panics on overflow. An uncapped nonce would let the
|
||||
/// fuzzer drive a signer to `u128::MAX` via a forced-pass post-state and then trip that
|
||||
/// panic — a self-inflicted artefact, not a protocol bug.
|
||||
pub(crate) fn arb_account(u: &mut Unstructured<'_>) -> ArbResult<Account> {
|
||||
Ok(Account {
|
||||
program_owner: <[u32; 8]>::arbitrary(u)?,
|
||||
balance: u128::arbitrary(u)?,
|
||||
nonce: Nonce(u128::arbitrary(u)? % 1024),
|
||||
..Account::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a fuzz-driven block/timestamp [`ValidityWindow`].
|
||||
///
|
||||
/// `from_privacy_preserving_transaction` checks `block_validity_window.is_valid_for(block_id)` and
|
||||
/// `timestamp_validity_window.is_valid_for(timestamp)` (returning `LeeError::OutOfValidityWindow`)
|
||||
/// *before* proof verification. The window is reconstructed byte-for-byte into the synthesised
|
||||
/// proof's journal, so a bounded window still passes check 4 and is then rejected at the window
|
||||
/// check — exercising that rejection path and its state-isolation guarantee.
|
||||
///
|
||||
/// Windows are left **unbounded most of the time** so the success path (checks 5-6 + apply) stays
|
||||
/// frequently reachable. When bounded, the half-open `[from, to)` bounds are kept in `0..8` so they
|
||||
/// straddle the harness's `block_id` / `timestamp` range (both `< 6`), landing on both sides of the
|
||||
/// check. `try_from` rejects `from >= to`; that falls back to unbounded rather than biasing toward
|
||||
/// always-valid windows.
|
||||
pub(crate) fn arb_validity_window(u: &mut Unstructured<'_>) -> ArbResult<ValidityWindow<u64>> {
|
||||
if (u8::arbitrary(u)? % 4) != 0 {
|
||||
return Ok(ValidityWindow::new_unbounded());
|
||||
}
|
||||
let from = bool::arbitrary(u)?.then(|| u64::from(u8::arbitrary(u).unwrap_or(0) % 8));
|
||||
let to = bool::arbitrary(u)?.then(|| u64::from(u8::arbitrary(u).unwrap_or(0) % 8));
|
||||
Ok(ValidityWindow::try_from((from, to)).unwrap_or_else(|_| ValidityWindow::new_unbounded()))
|
||||
}
|
||||
|
||||
/// Build one fuzz-driven [`EncryptedAccountData`] for `message.encrypted_private_post_states`.
|
||||
///
|
||||
/// The executor does not validate the encrypted notes directly — they are only bound into the proof
|
||||
/// journal — so this needs no real recipient keys: the three fields are public, and the only one
|
||||
/// that cannot be built outside `lee_core` is the [`Ciphertext`](nssa_core), whose inner `Vec` is
|
||||
/// `pub(crate)`. We therefore obtain it through `EncryptionScheme::encrypt` (a cheap
|
||||
/// `ChaCha20` + SHA256 transform, no ML-KEM keygen) and fuzz the `epk` / `view_tag` directly. The
|
||||
/// synthesised proof binds whatever we produce, so checks 5-6 + apply stay reachable.
|
||||
fn arb_encrypted_account_data(u: &mut Unstructured<'_>) -> ArbResult<EncryptedAccountData> {
|
||||
let account = arb_account(u)?;
|
||||
let kind = PrivateAccountKind::Regular(u128::arbitrary(u)?);
|
||||
let shared_secret = SharedSecretKey(<[u8; 32]>::arbitrary(u)?);
|
||||
let commitment = Commitment::new(&AccountId::new(<[u8; 32]>::arbitrary(u)?), &account);
|
||||
let ciphertext = EncryptionScheme::encrypt(
|
||||
&account,
|
||||
&kind,
|
||||
&shared_secret,
|
||||
&commitment,
|
||||
u32::arbitrary(u)?,
|
||||
);
|
||||
Ok(EncryptedAccountData {
|
||||
ciphertext,
|
||||
epk: EphemeralPublicKey(<Vec<u8>>::arbitrary(u)?),
|
||||
view_tag: u8::arbitrary(u)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate a privacy-preserving transaction aimed at the **state-transition executor**.
|
||||
///
|
||||
/// The transaction is built to *frequently* pass every validation check up to and including
|
||||
/// proof verification (check 4) so that the previously-uncovered checks 5–6 and
|
||||
/// `apply_state_diff` are exercised, while fuzz-driven choices (mismatched nullifier digest,
|
||||
/// occasional garbage proof, duplicated/oversized field shapes, bounded validity windows that
|
||||
/// exclude the block/timestamp) still drive the rejection and isolation paths.
|
||||
///
|
||||
/// `state` must be the *current* state the transaction will be validated against — the
|
||||
/// synthesised proof binds to it. `accounts` supplies signing keys (each [`FuzzAccount`]
|
||||
/// carries a usable [`PrivateKey`]); their key-derived public-account ids become the
|
||||
/// transaction's signers.
|
||||
pub fn arb_privacy_preserving_tx(
|
||||
u: &mut Unstructured<'_>,
|
||||
state: &V03State,
|
||||
accounts: &[FuzzAccount],
|
||||
) -> ArbResult<PrivacyPreservingTransaction> {
|
||||
// ── Signers ──────────────────────────────────────────────────────────────────────
|
||||
// 0..=3 distinct signers drawn from the keyed fuzz accounts. A signer's public-account
|
||||
// id is `AccountId::from(&its_public_key)` — exactly what the validator derives from the
|
||||
// witness set — and is independent of `FuzzAccount.account_id`.
|
||||
let max_signers = accounts.len().min(3);
|
||||
let n_signers = if max_signers == 0 {
|
||||
0
|
||||
} else {
|
||||
(u8::arbitrary(u)? as usize) % (max_signers + 1)
|
||||
};
|
||||
let mut keys: Vec<&PrivateKey> = Vec::with_capacity(n_signers);
|
||||
let mut signer_ids: Vec<AccountId> = Vec::with_capacity(n_signers);
|
||||
for _ in 0..n_signers {
|
||||
let key = &accounts[(u8::arbitrary(u)? as usize) % accounts.len()].private_key;
|
||||
let id = AccountId::from(&PublicKey::new_from_private_key(key));
|
||||
if signer_ids.contains(&id) {
|
||||
continue; // keep signer ids distinct so `nonces` stays 1:1 with `keys`
|
||||
}
|
||||
keys.push(key);
|
||||
signer_ids.push(id);
|
||||
}
|
||||
|
||||
// Nonces read live from state → check 3c (nonce match) passes by construction. After a
|
||||
// successful apply the signer nonce advances, which makes a replay fail check 3c.
|
||||
let nonces: Vec<Nonce> = signer_ids
|
||||
.iter()
|
||||
.map(|id| state.get_account_by_id(*id).nonce)
|
||||
.collect();
|
||||
|
||||
// ── public_account_ids (must be unique — validator check 2) ──────────────────────
|
||||
let mut public_account_ids: Vec<AccountId> = Vec::new();
|
||||
// Sometimes treat the signers themselves as updated public accounts (the common shape);
|
||||
// otherwise leave them out so the signer-nonce-increment invariant is exercised on an
|
||||
// account that is *not* also overwritten by a post-state.
|
||||
if bool::arbitrary(u)? {
|
||||
public_account_ids.extend_from_slice(&signer_ids);
|
||||
}
|
||||
let n_extra = (u8::arbitrary(u)? as usize) % 4;
|
||||
for _ in 0..n_extra {
|
||||
let id = if bool::arbitrary(u)? {
|
||||
// a known fuzz account — its post-state change is observable in the snapshot
|
||||
accounts[(u8::arbitrary(u)? as usize) % accounts.len()].account_id
|
||||
} else {
|
||||
AccountId::new(<[u8; 32]>::arbitrary(u)?)
|
||||
};
|
||||
if !public_account_ids.contains(&id) {
|
||||
public_account_ids.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── public_post_states ──
|
||||
// Range 0..=len+3 so lengths can exceed the public-account count, exercising
|
||||
// both the truncation path and the oversized/length-mismatch path.
|
||||
let n_post = (u8::arbitrary(u)? as usize) % (public_account_ids.len() + 4);
|
||||
let public_post_states = std::iter::repeat_with(|| arb_account(u))
|
||||
.take(n_post)
|
||||
.collect::<ArbResult<Vec<_>>>()?;
|
||||
|
||||
// ── new_commitments (unique — validator check 2c; fresh against a genesis state) ──
|
||||
let n_comm = (u8::arbitrary(u)? as usize) % 4;
|
||||
let mut new_commitments: Vec<Commitment> = Vec::new();
|
||||
for _ in 0..n_comm {
|
||||
let aid = AccountId::new(<[u8; 32]>::arbitrary(u)?);
|
||||
let acc = arb_account(u)?;
|
||||
let commitment = Commitment::new(&aid, &acc);
|
||||
if !new_commitments.contains(&commitment) {
|
||||
new_commitments.push(commitment);
|
||||
}
|
||||
}
|
||||
|
||||
// ── new_nullifiers (unique — validator check 2b) ─────────────────────────────────
|
||||
// Check 6 additionally requires each digest to be in the commitment set's `root_history`.
|
||||
// `root_history` starts *empty* on a fresh genesis state and is only seeded once a
|
||||
// commitment-bearing transaction applies (`CommitmentSet::extend` inserts the post-insert
|
||||
// root). So a nullifier digest set to the live root only passes check 6 on a *later*
|
||||
// transaction in the sequence — after an earlier tx grew the commitment set; against the
|
||||
// first tx (empty history) even the live root is rejected. We still use the live root half
|
||||
// the time so the success path becomes reachable once seeded; a random digest always drives
|
||||
// the check-6 rejection path.
|
||||
let n_null = (u8::arbitrary(u)? as usize) % 3;
|
||||
let live_root = state.commitment_set_digest();
|
||||
let mut new_nullifiers: Vec<(Nullifier, CommitmentSetDigest)> = Vec::new();
|
||||
for _ in 0..n_null {
|
||||
let aid = AccountId::new(<[u8; 32]>::arbitrary(u)?);
|
||||
let nullifier = Nullifier::for_account_initialization(&aid);
|
||||
let digest: CommitmentSetDigest = if bool::arbitrary(u)? {
|
||||
live_root
|
||||
} else {
|
||||
<[u8; 32]>::arbitrary(u)?
|
||||
};
|
||||
if !new_nullifiers.iter().any(|(n, _)| n == &nullifier) {
|
||||
new_nullifiers.push((nullifier, digest));
|
||||
}
|
||||
}
|
||||
|
||||
// Validator check 1: commitments OR nullifiers must be non-empty.
|
||||
if new_commitments.is_empty() && new_nullifiers.is_empty() {
|
||||
let aid = AccountId::new(<[u8; 32]>::arbitrary(u)?);
|
||||
let acc = arb_account(u)?;
|
||||
new_commitments.push(Commitment::new(&aid, &acc));
|
||||
}
|
||||
|
||||
// ── encrypted_private_post_states (carried into the proof journal, not validated) ──
|
||||
let n_enc = (u8::arbitrary(u)? as usize) % 3;
|
||||
let encrypted_private_post_states = std::iter::repeat_with(|| arb_encrypted_account_data(u))
|
||||
.take(n_enc)
|
||||
.collect::<ArbResult<Vec<_>>>()?;
|
||||
|
||||
let message = PPMessage {
|
||||
public_account_ids,
|
||||
nonces,
|
||||
public_post_states,
|
||||
encrypted_private_post_states,
|
||||
new_commitments,
|
||||
new_nullifiers,
|
||||
block_validity_window: arb_validity_window(u)?,
|
||||
timestamp_validity_window: arb_validity_window(u)?,
|
||||
};
|
||||
|
||||
// Mostly a passing proof (so checks 5–6 + apply are reached); occasionally garbage so
|
||||
// the check-4 rejection path is hit from the executor side too.
|
||||
let proof = if (u8::arbitrary(u)? % 8) == 0 {
|
||||
Proof::from_inner(<Vec<u8>>::arbitrary(u)?)
|
||||
} else {
|
||||
synthesize_passing_proof(&message, state, &signer_ids)
|
||||
};
|
||||
|
||||
let witness_set = PPWitnessSet::for_message(&message, proof, &keys);
|
||||
Ok(PrivacyPreservingTransaction::new(message, witness_set))
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
mod arbitrary_types_test;
|
||||
mod generators_test;
|
||||
mod arbitrary_types;
|
||||
mod generators;
|
||||
mod invariants;
|
||||
mod privacy;
|
||||
mod replay_proptest;
|
||||
mod seed_gen;
|
||||
|
||||
@ -4,7 +4,8 @@ use arbitrary::Unstructured;
|
||||
use nssa::{AccountId, PrivateKey};
|
||||
|
||||
use crate::generators::{
|
||||
FuzzAccount, arb_fuzz_native_transfer, arbitrary_fuzz_state, signer_account_ids, test_accounts,
|
||||
FuzzAccount, arb_fuzz_native_transfer, arbitrary_fuzz_state, biased_valid_nonce_amount,
|
||||
signer_account_ids, test_accounts,
|
||||
};
|
||||
|
||||
/// Verifies that `signer_account_ids` returns a **non-empty** list for a properly signed
|
||||
@ -133,3 +134,45 @@ fn native_transfer_index_uses_modulo_not_div_add() {
|
||||
mutation: `% accounts.len()` replaced by `/ accounts.len()` or `+ accounts.len()`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biased_nonce_is_always_in_genesis_range() {
|
||||
// Every possible nonce byte must reduce into 0..=3. This rules out the
|
||||
// `/` and `+` variants of the `% 4` reduction, which escape that range.
|
||||
for byte in 0..=u8::MAX {
|
||||
let (nonce, _) = biased_valid_nonce_amount(byte, 0, 0);
|
||||
assert!(
|
||||
nonce <= 3,
|
||||
"byte {byte} produced out-of-range nonce {nonce}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biased_nonce_wraps_modulo_four() {
|
||||
// Pin specific residues so `/ 4` (→1, →63) and `+ 4` (→8, →259) both fail.
|
||||
assert_eq!(biased_valid_nonce_amount(4, 0, 0).0, 0);
|
||||
assert_eq!(biased_valid_nonce_amount(255, 0, 0).0, 3);
|
||||
assert_eq!(biased_valid_nonce_amount(7, 0, 0).0, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biased_amount_never_exceeds_balance() {
|
||||
for balance in [0_u128, 1, 100, u128::MAX] {
|
||||
for amount_raw in [0_u128, 1, balance, balance.wrapping_add(1), u128::MAX] {
|
||||
let (_, amount) = biased_valid_nonce_amount(0, amount_raw, balance);
|
||||
assert!(
|
||||
amount <= balance,
|
||||
"amount {amount} exceeded balance {balance} (raw {amount_raw})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn biased_amount_wraps_modulo_balance_plus_one() {
|
||||
// `10 % 101 == 10` but `10 / 101 == 0`, so this kills the `/` variant.
|
||||
assert_eq!(biased_valid_nonce_amount(0, 10, 100).1, 10);
|
||||
// balance 0 → modulus 1 → amount always 0.
|
||||
assert_eq!(biased_valid_nonce_amount(0, u128::MAX, 0).1, 0);
|
||||
}
|
||||
429
fuzz_props/src/tests/privacy.rs
Normal file
429
fuzz_props/src/tests/privacy.rs
Normal file
@ -0,0 +1,429 @@
|
||||
use arbitrary::Unstructured;
|
||||
|
||||
use crate::generators::FuzzAccount;
|
||||
use crate::privacy::{
|
||||
arb_account, arb_privacy_preserving_tx, arb_validity_window, synthesize_passing_proof,
|
||||
};
|
||||
use nssa::privacy_preserving_transaction::{Message as PPMessage, WitnessSet as PPWitnessSet};
|
||||
use nssa::{AccountId, PrivacyPreservingTransaction, PrivateKey, V03State};
|
||||
use nssa_core::Commitment;
|
||||
use nssa_core::account::Account;
|
||||
use nssa_core::program::{BlockValidityWindow, TimestampValidityWindow};
|
||||
|
||||
/// `synthesize_passing_proof` must drive the executor *past* proof verification (check 4)
|
||||
/// into checks 5–6 and `apply_state_diff`. If the reconstructed journal were even one
|
||||
/// byte off, `is_valid_for` would return `false` and the executor would stop at check 4 —
|
||||
/// silently degrading Path B back to Path A.5. This test fails loudly in that case.
|
||||
///
|
||||
/// Fake-receipt verification is a pass-through only under `RISC0_DEV_MODE`; the test is a
|
||||
/// no-op when the variable is unset (e.g. a bare `cargo test`). `just fuzz-props` exports
|
||||
/// it, as does running with `RISC0_DEV_MODE=1 cargo test`.
|
||||
#[test]
|
||||
fn synthesized_proof_reaches_checks_5_6_and_applies() {
|
||||
let dev_mode = std::env::var("RISC0_DEV_MODE").is_ok_and(|v| v == "1" || v == "true");
|
||||
if !dev_mode {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
|
||||
|
||||
// No signers and a single fresh commitment: checks 1–3 are vacuous/trivially met, so
|
||||
// the only way to reach checks 5–6 is for the synthesised proof to pass check 4.
|
||||
let aid = AccountId::new([7_u8; 32]);
|
||||
let commitment = Commitment::new(&aid, &Account::default());
|
||||
let message = PPMessage {
|
||||
public_account_ids: vec![],
|
||||
nonces: vec![],
|
||||
public_post_states: vec![],
|
||||
encrypted_private_post_states: vec![],
|
||||
new_commitments: vec![commitment.clone()],
|
||||
new_nullifiers: vec![],
|
||||
block_validity_window: BlockValidityWindow::new_unbounded(),
|
||||
timestamp_validity_window: TimestampValidityWindow::new_unbounded(),
|
||||
};
|
||||
|
||||
let proof = synthesize_passing_proof(&message, &state, &[]);
|
||||
let witness_set = PPWitnessSet::for_message(&message, proof, &[]);
|
||||
let tx = PrivacyPreservingTransaction::new(message, witness_set);
|
||||
|
||||
state
|
||||
.transition_from_privacy_preserving_transaction(&tx, 1, 0)
|
||||
.expect(
|
||||
"a synthesised passing proof must drive the executor to success (checks 5-6 + apply)",
|
||||
);
|
||||
|
||||
// Check 5 reached and applied: the commitment is now a member of the set.
|
||||
assert!(
|
||||
state.get_proof_for_commitment(&commitment).is_some(),
|
||||
"accepted commitment must be inserted into the commitment set",
|
||||
);
|
||||
|
||||
// Replaying the same transaction must now be rejected (commitment already seen).
|
||||
assert!(
|
||||
state
|
||||
.transition_from_privacy_preserving_transaction(&tx, 2, 1)
|
||||
.is_err(),
|
||||
"replayed transaction must be rejected after its commitment was inserted",
|
||||
);
|
||||
}
|
||||
|
||||
/// Negative counterpart to the test above: the synthesised `FakeReceipt` is a forgery that
|
||||
/// must pass **only** under `RISC0_DEV_MODE`. With dev mode off, `Receipt::verify` runs the
|
||||
/// real integrity check, the fake fails it, and the executor must reject the transaction at
|
||||
/// check 4 — never reaching checks 5–6 or `apply_state_diff`.
|
||||
///
|
||||
/// This locks the dev-mode boundary in CI: it asserts the forgery is genuinely inert in a
|
||||
/// production-mode verifier, so `synthesize_passing_proof` can never be mistaken for a
|
||||
/// real-proof generator. It is the mirror of `synthesized_proof_reaches_checks_5_6_and_applies`
|
||||
/// — exactly one of the two runs in any given environment (a bare `cargo test` runs this one;
|
||||
/// `RISC0_DEV_MODE=1 cargo test` runs the other), so both directions are covered across CI.
|
||||
#[test]
|
||||
fn synthesized_proof_is_rejected_without_dev_mode() {
|
||||
let dev_mode = std::env::var("RISC0_DEV_MODE").is_ok_and(|v| v == "1" || v == "true");
|
||||
if dev_mode {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
|
||||
|
||||
// Same well-formed message as the positive test: checks 1–3 are vacuous/trivially met, so a
|
||||
// rejection can only come from check 4 (proof verification) failing on the fake receipt.
|
||||
let aid = AccountId::new([7_u8; 32]);
|
||||
let commitment = Commitment::new(&aid, &Account::default());
|
||||
let message = PPMessage {
|
||||
public_account_ids: vec![],
|
||||
nonces: vec![],
|
||||
public_post_states: vec![],
|
||||
encrypted_private_post_states: vec![],
|
||||
new_commitments: vec![commitment.clone()],
|
||||
new_nullifiers: vec![],
|
||||
block_validity_window: BlockValidityWindow::new_unbounded(),
|
||||
timestamp_validity_window: TimestampValidityWindow::new_unbounded(),
|
||||
};
|
||||
|
||||
let proof = synthesize_passing_proof(&message, &state, &[]);
|
||||
let witness_set = PPWitnessSet::for_message(&message, proof, &[]);
|
||||
let tx = PrivacyPreservingTransaction::new(message, witness_set);
|
||||
|
||||
assert!(
|
||||
state
|
||||
.transition_from_privacy_preserving_transaction(&tx, 1, 0)
|
||||
.is_err(),
|
||||
"a synthesised fake receipt must be rejected at check 4 when RISC0_DEV_MODE is off - \
|
||||
the forgery must never verify in a production-mode verifier",
|
||||
);
|
||||
|
||||
// The rejection must also leave private state untouched (no commitment inserted).
|
||||
assert!(
|
||||
state.get_proof_for_commitment(&commitment).is_none(),
|
||||
"a rejected transaction must not insert its commitment into the set",
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Generator contract tests
|
||||
//
|
||||
// The `arb_*` helpers in `privacy.rs` shape the fuzz input. Their bounding
|
||||
// arithmetic, dedup guards, and branch conditions decide the *shape* of every
|
||||
// generated transaction — how many signers/commitments/nullifiers it carries,
|
||||
// which accounts it touches, whether its proof is a passing one or garbage — but
|
||||
// none of that is visible in the encoded bytes, so the encoding/executor tests
|
||||
// cannot observe it. The tests below assert those shape guarantees directly.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Tiny deterministic xorshift64 PRNG so the distributional generator test below
|
||||
/// is reproducible (no `rand`, no clock seeding) yet samples a wide spread of inputs.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
const fn new() -> Self {
|
||||
Self(0x9E37_79B9_7F4A_7C15)
|
||||
}
|
||||
|
||||
fn next_u64(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x << 13_u32;
|
||||
x ^= x >> 7_u32;
|
||||
x ^= x << 17_u32;
|
||||
self.0 = x;
|
||||
x
|
||||
}
|
||||
|
||||
fn fill(&mut self, buf: &mut [u8]) {
|
||||
for chunk in buf.chunks_mut(8) {
|
||||
let bytes = self.next_u64().to_le_bytes();
|
||||
for (dst, src) in chunk.iter_mut().zip(bytes.iter()) {
|
||||
*dst = *src;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `arb_account` caps the nonce at `u128 % 1024` to keep a forced-pass post-state
|
||||
/// from driving a signer's nonce to `u128::MAX` (and tripping the protocol's
|
||||
/// overflow panic on the subsequent increment). The cap must hold for every
|
||||
/// generated account regardless of the fuzz bytes.
|
||||
#[test]
|
||||
fn arb_account_nonce_capped_below_1024() {
|
||||
let buf = vec![0xAB_u8; 1024];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
for _ in 0_u32..8 {
|
||||
let acc = arb_account(&mut u).expect("arb_account never errors on fill_buffer primitives");
|
||||
assert!(
|
||||
acc.nonce.0 < 1024,
|
||||
"nonce {} must stay within the [0, 1024) cap for any input",
|
||||
acc.nonce.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each of `arb_account`'s three explicit fields must be sourced from the fuzz
|
||||
/// bytes, not left at `Account::default()` — deleting any field assignment leaves
|
||||
/// the corresponding field at its (zero) default.
|
||||
#[test]
|
||||
fn arb_account_fields_are_populated_from_fuzz_bytes() {
|
||||
let buf = vec![0xAB_u8; 256];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let acc = arb_account(&mut u).expect("arb_account never errors on fill_buffer primitives");
|
||||
let default = Account::default();
|
||||
|
||||
assert_ne!(
|
||||
acc.program_owner, default.program_owner,
|
||||
"program_owner must be drawn from the fuzz bytes, not left at its default"
|
||||
);
|
||||
assert_ne!(
|
||||
acc.balance, default.balance,
|
||||
"balance must be drawn from the fuzz bytes, not left at its default"
|
||||
);
|
||||
assert_ne!(
|
||||
acc.nonce, default.nonce,
|
||||
"nonce must be drawn from the fuzz bytes, not left at its default"
|
||||
);
|
||||
}
|
||||
|
||||
/// `arb_validity_window` leaves the window unbounded for ~3 of every 4 selector
|
||||
/// bytes (`u8 % 4 != 0`) so the success path stays frequently reachable. A selector
|
||||
/// of `1` satisfies `1 % 4 != 0`, so the window must come back fully unbounded —
|
||||
/// the function returns before it ever reads the follow-on bound bytes.
|
||||
#[test]
|
||||
fn arb_validity_window_selector_nonzero_is_unbounded() {
|
||||
// [selector=1, from_bool=1, from_val=2, to_bool=1, to_val=5]
|
||||
let buf = vec![1_u8, 1, 2, 1, 5];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let w = arb_validity_window(&mut u).expect("arb_validity_window never errors");
|
||||
assert_eq!(
|
||||
w.start(),
|
||||
None,
|
||||
"selector 1 (1 % 4 != 0) must yield an unbounded window"
|
||||
);
|
||||
assert_eq!(w.end(), None, "selector 1 must yield an unbounded window");
|
||||
}
|
||||
|
||||
/// The remaining ~1 in 4 selectors (`u8 % 4 == 0`) take the bounded path, where the
|
||||
/// follow-on bytes set actual `[from, to)` bounds. A selector of `0` must therefore
|
||||
/// produce a window with at least one finite bound.
|
||||
#[test]
|
||||
fn arb_validity_window_selector_zero_is_bounded() {
|
||||
// [selector=0, from_bool=1, from_val=2, to_bool=1, to_val=5]
|
||||
let buf = vec![0_u8, 1, 2, 1, 5];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let w = arb_validity_window(&mut u).expect("arb_validity_window never errors");
|
||||
assert!(
|
||||
w.start().is_some() || w.end().is_some(),
|
||||
"selector 0 (0 % 4 == 0) must yield a bounded window"
|
||||
);
|
||||
}
|
||||
|
||||
/// On the bounded path both bounds are kept in `0..8` via `u8 % 8` so they straddle
|
||||
/// the harness's block/timestamp range. With `from_val = 8` (→ `8 % 8 = 0`) and
|
||||
/// `to_val = 5` (→ `5`) the resulting window must be exactly `[0, 5)`.
|
||||
#[test]
|
||||
fn arb_validity_window_bounds_use_modulo_8() {
|
||||
// [selector=0, from_bool=1, from_val=8, to_bool=1, to_val=5] → window [0, 5)
|
||||
let buf = vec![0_u8, 1, 8, 1, 5];
|
||||
let mut u = Unstructured::new(&buf);
|
||||
let w = arb_validity_window(&mut u).expect("arb_validity_window never errors");
|
||||
assert_eq!(w.start(), Some(0_u64), "from must be 8 % 8 = 0");
|
||||
assert_eq!(w.end(), Some(5_u64), "to must be 5 % 8 = 5");
|
||||
}
|
||||
|
||||
/// Drive `arb_privacy_preserving_tx` over many pseudo-random inputs and assert the
|
||||
/// structural guarantees of the transactions it builds: the bounded counts, the
|
||||
/// in-range account indexing, the deduplicated/non-empty field sets, and the
|
||||
/// passing-vs-garbage proof mix. Six distinct keyed accounts give the signer count
|
||||
/// headroom above its cap of 3 (so any over-counting shows up) and provide several
|
||||
/// valid indices (so off-by-one indexing would read past the slice and panic).
|
||||
///
|
||||
/// Two flavours of check run here: per-iteration upper bounds that must hold for
|
||||
/// *every* generated transaction, and end-of-run reachability checks that confirm
|
||||
/// the interesting shapes actually occur across the sampled inputs.
|
||||
#[test]
|
||||
fn arb_privacy_preserving_tx_generator_invariants() {
|
||||
let accounts: Vec<FuzzAccount> = (1..=6_u8)
|
||||
.map(|i| FuzzAccount {
|
||||
account_id: AccountId::new([i; 32]),
|
||||
balance: 1_000_000,
|
||||
private_key: PrivateKey::try_new([i; 32]).expect("nonzero scalar is a valid key"),
|
||||
})
|
||||
.collect();
|
||||
let genesis: Vec<(AccountId, u128)> =
|
||||
accounts.iter().map(|a| (a.account_id, a.balance)).collect();
|
||||
let state = V03State::new_with_genesis_accounts(&genesis, vec![], 0);
|
||||
|
||||
let mut rng = Rng::new();
|
||||
let mut buf = vec![0_u8; 8192];
|
||||
|
||||
let mut oks = 0_usize;
|
||||
let mut max_signers = 0_usize;
|
||||
let mut saw_signer = false;
|
||||
let mut saw_extra = false;
|
||||
let mut max_commitments = 0_usize;
|
||||
let mut max_nullifiers = 0_usize;
|
||||
let mut saw_empty_comm_nonempty_null = false;
|
||||
let mut saw_oversize_post_states = false;
|
||||
let mut garbage = 0_usize;
|
||||
let mut saw_garbage = false;
|
||||
|
||||
for _ in 0..2000_usize {
|
||||
rng.fill(&mut buf);
|
||||
let mut u = Unstructured::new(&buf);
|
||||
// Never returns Err: every leaf is a `fill_buffer`-backed primitive that
|
||||
// zero-pads rather than failing. (Indexing an account slice out of range
|
||||
// would instead panic — also a failure this test would surface.)
|
||||
let tx = arb_privacy_preserving_tx(&mut u, &state, &accounts)
|
||||
.expect("generator never returns Err for fill_buffer-backed primitives");
|
||||
oks += 1;
|
||||
let msg = tx.message();
|
||||
|
||||
let signer_ids: Vec<AccountId> = tx
|
||||
.witness_set()
|
||||
.signatures_and_public_keys()
|
||||
.iter()
|
||||
.map(|(_, pk)| AccountId::from(pk))
|
||||
.collect();
|
||||
let n_signers = signer_ids.len();
|
||||
max_signers = max_signers.max(n_signers);
|
||||
saw_signer |= n_signers >= 1;
|
||||
|
||||
// ── per-transaction upper bounds ──
|
||||
// The signer count is drawn modulo `max_signers + 1`, so it can never exceed
|
||||
// the cap of 3 distinct signers.
|
||||
assert!(n_signers <= 3, "n_signers {n_signers} exceeds the cap of 3");
|
||||
// Post-states are drawn modulo `public_account_ids.len() + 4` (0..=len+3).
|
||||
assert!(
|
||||
msg.public_post_states.len() <= msg.public_account_ids.len() + 3,
|
||||
"public_post_states {} exceeds public_account_ids + 3 ({})",
|
||||
msg.public_post_states.len(),
|
||||
msg.public_account_ids.len() + 3
|
||||
);
|
||||
if msg.public_post_states.len() > msg.public_account_ids.len() {
|
||||
saw_oversize_post_states = true;
|
||||
}
|
||||
// At most 3 signers plus at most 3 extra ids (both deduplicated).
|
||||
assert!(
|
||||
msg.public_account_ids.len() <= 6,
|
||||
"public_account_ids {} exceeds signers (<=3) + extras (<=3)",
|
||||
msg.public_account_ids.len()
|
||||
);
|
||||
// `new_commitments` count is drawn modulo 4 (0..=3).
|
||||
assert!(
|
||||
msg.new_commitments.len() <= 3,
|
||||
"new_commitments {} exceeds 3",
|
||||
msg.new_commitments.len()
|
||||
);
|
||||
// `new_nullifiers` count is drawn modulo 3 (0..=2).
|
||||
assert!(
|
||||
msg.new_nullifiers.len() <= 2,
|
||||
"new_nullifiers {} exceeds 2",
|
||||
msg.new_nullifiers.len()
|
||||
);
|
||||
// `encrypted_private_post_states` count is drawn modulo 3 (0..=2).
|
||||
assert!(
|
||||
msg.encrypted_private_post_states.len() <= 2,
|
||||
"encrypted_private_post_states {} exceeds 2",
|
||||
msg.encrypted_private_post_states.len()
|
||||
);
|
||||
|
||||
// An id that is not a signer can only be present because an extra was appended.
|
||||
if msg
|
||||
.public_account_ids
|
||||
.iter()
|
||||
.any(|id| !signer_ids.contains(id))
|
||||
{
|
||||
saw_extra = true;
|
||||
}
|
||||
|
||||
max_commitments = max_commitments.max(msg.new_commitments.len());
|
||||
max_nullifiers = max_nullifiers.max(msg.new_nullifiers.len());
|
||||
|
||||
// The fallback that guarantees "commitments or nullifiers non-empty" must fire
|
||||
// only when *both* are empty. So a message with empty commitments but non-empty
|
||||
// nullifiers is a valid, reachable shape — the fallback must leave it alone.
|
||||
if msg.new_commitments.is_empty() && !msg.new_nullifiers.is_empty() {
|
||||
saw_empty_comm_nonempty_null = true;
|
||||
}
|
||||
|
||||
// Which proof branch ran? A synthesized passing proof is a deterministic
|
||||
// function of (message, state, signers); re-synthesizing reproduces it
|
||||
// byte-for-byte, so anything else is the garbage-bytes branch.
|
||||
let synth = synthesize_passing_proof(msg, &state, &signer_ids);
|
||||
if tx.witness_set().proof() == &synth {
|
||||
// synthesized passing proof
|
||||
} else {
|
||||
garbage += 1;
|
||||
saw_garbage = true;
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
oks > 1000,
|
||||
"expected many successful generations, got {oks}"
|
||||
);
|
||||
|
||||
// ── reachability across the sampled inputs ──
|
||||
// With accounts present, transactions must sometimes carry signers.
|
||||
assert!(saw_signer, "no transaction ever carried a signer");
|
||||
// The full signer range up to the cap of 3 distinct signers must be reachable.
|
||||
assert_eq!(
|
||||
max_signers, 3,
|
||||
"the generator never reached 3 distinct signers"
|
||||
);
|
||||
// Extra public account ids must actually get appended.
|
||||
assert!(
|
||||
saw_extra,
|
||||
"the generator never appended an extra public account id"
|
||||
);
|
||||
// Multiple distinct commitments must be reachable (the dedup must keep, not drop).
|
||||
assert!(
|
||||
max_commitments >= 2,
|
||||
"the generator never produced >= 2 commitments"
|
||||
);
|
||||
// Multiple distinct nullifiers must be reachable (the dedup must keep, not drop).
|
||||
assert!(
|
||||
max_nullifiers >= 2,
|
||||
"the generator never produced >= 2 nullifiers"
|
||||
);
|
||||
// The empty-commitments + non-empty-nullifiers shape must be reachable, proving the
|
||||
// fallback does not over-fire.
|
||||
assert!(
|
||||
saw_empty_comm_nonempty_null,
|
||||
"the generator never produced empty commitments with non-empty nullifiers"
|
||||
);
|
||||
// The oversized shape (more post-states than public account ids) must be reachable.
|
||||
assert!(
|
||||
saw_oversize_post_states,
|
||||
"the generator never produced more post-states than public account ids"
|
||||
);
|
||||
// The garbage-proof branch (~1 in 8) must be reachable at all.
|
||||
assert!(saw_garbage, "the generator never produced a garbage proof");
|
||||
// The garbage-proof rate must sit near the intended 1/8. Integer bands avoid float
|
||||
// arithmetic: it must fall within [1/16, 1/4].
|
||||
assert!(
|
||||
garbage * 4 <= oks,
|
||||
"garbage-proof rate {garbage}/{oks} is above 1/4 (expected ~1/8)"
|
||||
);
|
||||
assert!(
|
||||
garbage * 16 >= oks,
|
||||
"garbage-proof rate {garbage}/{oks} is below 1/16 (expected ~1/8)"
|
||||
);
|
||||
}
|
||||
@ -196,6 +196,15 @@ def main() -> None:
|
||||
print()
|
||||
print(" 4. Run with libFuzzer: just fuzz-one", target)
|
||||
print(" Run with AFL++: just fuzz-afl", target)
|
||||
print()
|
||||
print(" 5. This script only edits .github/workflows/fuzz.yml. Add the")
|
||||
print(" target to the other enumeration sites too, then verify with:")
|
||||
print(" python3 scripts/check_target_inventory.py")
|
||||
print(" (the same check runs in CI and will fail the build on drift):")
|
||||
print(" .github/workflows/fuzz-afl.yml")
|
||||
print(" .github/workflows/mutants.yml")
|
||||
print(" scripts/mutants-corpus-test.sh")
|
||||
print(" README.md, docs/fuzzing.md")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
92
scripts/check_target_inventory.py
Executable file
92
scripts/check_target_inventory.py
Executable file
@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail if any fuzz target registered in fuzz/Cargo.toml is missing from a
|
||||
workflow / script / doc that enumerates the target list.
|
||||
|
||||
`fuzz/Cargo.toml` is the single source of truth: every `[[bin]] name = "fuzz_*"`
|
||||
must be mentioned by name in each of the consumer files below. This guards
|
||||
against the drift that `scripts/add_fuzz_target.py` cannot prevent on its own
|
||||
(it only edits `.github/workflows/fuzz.yml`).
|
||||
|
||||
Usage:
|
||||
python3 scripts/check_target_inventory.py
|
||||
|
||||
Exit code 0 = all consumers list every target; 1 = drift detected (prints the
|
||||
missing target/file pairs). Run from anywhere; paths are resolved relative to
|
||||
the repository root.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Files that enumerate the full target list and must stay in sync with Cargo.toml.
|
||||
# Paths are relative to the repository root.
|
||||
CONSUMERS = [
|
||||
".github/workflows/fuzz.yml",
|
||||
".github/workflows/fuzz-afl.yml",
|
||||
".github/workflows/mutants.yml",
|
||||
"scripts/mutants-corpus-test.sh",
|
||||
"README.md",
|
||||
"docs/fuzzing.md",
|
||||
]
|
||||
|
||||
_BIN_NAME_RE = re.compile(r'name\s*=\s*"(fuzz_[a-z0-9_]+)"')
|
||||
|
||||
|
||||
def registered_targets(cargo_toml: Path) -> list[str]:
|
||||
"""Every `[[bin]] name = "fuzz_*"` in fuzz/Cargo.toml, in file order."""
|
||||
names = _BIN_NAME_RE.findall(cargo_toml.read_text())
|
||||
# Preserve order, drop duplicates defensively.
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for n in names:
|
||||
if n not in seen:
|
||||
seen.add(n)
|
||||
ordered.append(n)
|
||||
return ordered
|
||||
|
||||
|
||||
def main() -> None:
|
||||
root = Path(__file__).parent.parent # repository root
|
||||
cargo_toml = root / "fuzz" / "Cargo.toml"
|
||||
if not cargo_toml.exists():
|
||||
print(f"ERROR: {cargo_toml} not found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
targets = registered_targets(cargo_toml)
|
||||
if not targets:
|
||||
print(f"ERROR: no [[bin]] targets found in {cargo_toml}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
missing: list[tuple[str, str]] = []
|
||||
for rel in CONSUMERS:
|
||||
path = root / rel
|
||||
if not path.exists():
|
||||
print(f"ERROR: consumer file not found: {rel}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
text = path.read_text()
|
||||
for target in targets:
|
||||
if target not in text:
|
||||
missing.append((rel, target))
|
||||
|
||||
if missing:
|
||||
print(
|
||||
f"Target-inventory drift: {len(targets)} targets registered in "
|
||||
f"fuzz/Cargo.toml, but some consumers are missing entries:\n",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for rel, target in missing:
|
||||
print(f" MISSING {rel} -> {target}", file=sys.stderr)
|
||||
print(
|
||||
"\nAdd the target(s) above to each listed file "
|
||||
"(see scripts/add_fuzz_target.py for the canonical insertion points).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"OK: all {len(CONSUMERS)} consumers list every one of the "
|
||||
f"{len(targets)} registered targets.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -41,6 +41,7 @@ targets=(
|
||||
fuzz_privacy_preserving_witness
|
||||
fuzz_encoding_privacy_preserving
|
||||
fuzz_nullifier_set_roundtrip
|
||||
fuzz_privacy_preserving_state_transition
|
||||
)
|
||||
|
||||
# cargo-fuzz requires the nightly toolchain (-Zsanitizer=address etc.).
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user