diff --git a/.github/actions/checkout-lez/action.yml b/.github/actions/checkout-lez/action.yml index 435920a4..1f949cf9 100644 --- a/.github/actions/checkout-lez/action.yml +++ b/.github/actions/checkout-lez/action.yml @@ -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 diff --git a/.github/workflows/fuzz-afl.yml b/.github/workflows/fuzz-afl.yml index d419242f..dde2a9e7 100644 --- a/.github/workflows/fuzz-afl.yml +++ b/.github/workflows/fuzz-afl.yml @@ -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" diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 4970f7b0..b6b096cd 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -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 diff --git a/.github/workflows/lez-compat.yml b/.github/workflows/lez-compat.yml new file mode 100644 index 00000000..f5265833 --- /dev/null +++ b/.github/workflows/lez-compat.yml @@ -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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a486d6c4..9be35000 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 diff --git a/.github/workflows/mutants.yml b/.github/workflows/mutants.yml index 44bdd251..8ac9f7ef 100644 --- a/.github/workflows/mutants.yml +++ b/.github/workflows/mutants.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 899caad6..d822ce5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Justfile b/Justfile index 0d84bf98..4dd33316 100644 --- a/Justfile +++ b/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" diff --git a/README.md b/README.md index ef852904..d2544640 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [Logos Execution Zone (LEZ)](https://github.com/logos-blockchain/logos-execution-zone) 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) +[![Fuzzing](https://img.shields.io/badge/libFuzzer%20%C2%B7%20AFL%2B%2B-21%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) @@ -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/.rs`. diff --git a/docs/fuzzing.md b/docs/fuzzing.md index 56943248..b1f95e7d 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -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 | diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 413470e0..6983fa36 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -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", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index d5219a56..632b844f 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -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 diff --git a/fuzz/fuzz_targets/fuzz_encoding_privacy_preserving.rs b/fuzz/fuzz_targets/fuzz_encoding_privacy_preserving.rs index 485d5a35..10683ff1 100644 --- a/fuzz/fuzz_targets/fuzz_encoding_privacy_preserving.rs +++ b/fuzz/fuzz_targets/fuzz_encoding_privacy_preserving.rs @@ -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", ); } diff --git a/fuzz/fuzz_targets/fuzz_privacy_preserving_state_transition.rs b/fuzz/fuzz_targets/fuzz_privacy_preserving_state_transition.rs new file mode 100644 index 00000000..264aedcf --- /dev/null +++ b/fuzz/fuzz_targets/fuzz_privacy_preserving_state_transition.rs @@ -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 = 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 = 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 = 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); + } + } +}); diff --git a/fuzz_props/Cargo.toml b/fuzz_props/Cargo.toml index 7e6ccc78..c9803ac1 100644 --- a/fuzz_props/Cargo.toml +++ b/fuzz_props/Cargo.toml @@ -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 } diff --git a/fuzz_props/src/arbitrary_types.rs b/fuzz_props/src/arbitrary_types.rs index 20ea8430..1939b643 100644 --- a/fuzz_props/src/arbitrary_types.rs +++ b/fuzz_props/src/arbitrary_types.rs @@ -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`]. /// diff --git a/fuzz_props/src/generators.rs b/fuzz_props/src/generators.rs index d11df5a9..ca79628c 100644 --- a/fuzz_props/src/generators.rs +++ b/fuzz_props/src/generators.rs @@ -74,6 +74,21 @@ pub fn arbitrary_fuzz_state(u: &mut Unstructured<'_>) -> arbitrary::Result (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, 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, diff --git a/fuzz_props/src/lib.rs b/fuzz_props/src/lib.rs index 6c45ffde..d85740a5 100644 --- a/fuzz_props/src/lib.rs +++ b/fuzz_props/src/lib.rs @@ -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: diff --git a/fuzz_props/src/privacy.rs b/fuzz_props/src/privacy.rs new file mode 100644 index 00000000..180700d7 --- /dev/null +++ b/fuzz_props/src/privacy.rs @@ -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 = 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 { + 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> { + 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 { + 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(>::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 { + // ── 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 = 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 = 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 = 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::>>()?; + + // ── 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 = 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::>>()?; + + 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(>::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)) +} diff --git a/fuzz_props/src/tests.rs b/fuzz_props/src/tests.rs index af7a7e6c..38f98451 100644 --- a/fuzz_props/src/tests.rs +++ b/fuzz_props/src/tests.rs @@ -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; diff --git a/fuzz_props/src/tests/arbitrary_types_test.rs b/fuzz_props/src/tests/arbitrary_types.rs similarity index 100% rename from fuzz_props/src/tests/arbitrary_types_test.rs rename to fuzz_props/src/tests/arbitrary_types.rs diff --git a/fuzz_props/src/tests/generators_test.rs b/fuzz_props/src/tests/generators.rs similarity index 75% rename from fuzz_props/src/tests/generators_test.rs rename to fuzz_props/src/tests/generators.rs index 44c7b139..ec5584b5 100644 --- a/fuzz_props/src/tests/generators_test.rs +++ b/fuzz_props/src/tests/generators.rs @@ -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); +} diff --git a/fuzz_props/src/tests/privacy.rs b/fuzz_props/src/tests/privacy.rs new file mode 100644 index 00000000..ad4a0d08 --- /dev/null +++ b/fuzz_props/src/tests/privacy.rs @@ -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 = (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 = 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)" + ); +} diff --git a/scripts/add_fuzz_target.py b/scripts/add_fuzz_target.py index 3dde8838..97a75318 100644 --- a/scripts/add_fuzz_target.py +++ b/scripts/add_fuzz_target.py @@ -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__": diff --git a/scripts/check_target_inventory.py b/scripts/check_target_inventory.py new file mode 100755 index 00000000..7c53ff02 --- /dev/null +++ b/scripts/check_target_inventory.py @@ -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() diff --git a/scripts/mutants-corpus-test.sh b/scripts/mutants-corpus-test.sh index b1409bae..663c8b6f 100755 --- a/scripts/mutants-corpus-test.sh +++ b/scripts/mutants-corpus-test.sh @@ -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.).