# Grant Application - Batch-vs-Single Verification Equivalence: Continuous Soundness Fuzzing for Zcash Shielded Verification (incl. Ironwood)

**URL:** <https://forum.zcashcommunity.com/t/grant-application-batch-vs-single-verification-equivalence-continuous-soundness-fuzzing-for-zcash-shielded-verification-incl-ironwood/56308>\
**Category:** Community Grants\
**Created:** [June 21, 2026, 2:51pm UTC](https://forum.zcashcommunity.com/t/grant-application-batch-vs-single-verification-equivalence-continuous-soundness-fuzzing-for-zcash-shielded-verification-incl-ironwood/56308 "2026-06-21T14:51:56Z")\
**Posts on this page:** 7\
**Page:** 1

<div class="post-metadata">

**Author:** ![robustfengbin](https://sea2.discourse-cdn.com/zcash/user_avatar/forum.zcashcommunity.com/robustfengbin/32/42095_2.png) [@robustfengbin](https://forum.zcashcommunity.com/u/robustfengbin)\
**Post date:** [June 21, 2026, 2:51pm UTC](https://forum.zcashcommunity.com/t/grant-application-batch-vs-single-verification-equivalence-continuous-soundness-fuzzing-for-zcash-shielded-verification-incl-ironwood/56308/1 "2026-06-21T14:51:56Z")

</div>

Hello everyone, I am submitting a proposal to Zcash Community Grants (ZCG) to build a **continuous batch-verification soundness oracle for Zebra** — a differential fuzzing harness that asserts Zebra’s shielded-proof and signature **batch** verification accepts a set of items **if and only if** every item also verifies individually. This is a direct continuation of **ZCG #234 (Zebra Coverage-Guided Fuzzing Infrastructure)** , reusing its corpus and harness scaffolding.

## Why This Matters

Zebra verifies shielded proofs and signatures in **batches** for performance. A batching-layer bug that **accepts a batch containing one invalid item** — a _false-accept_ — is a silent soundness failure, the same threat class that underlies counterfeiting. Today it’s asserted by neither place you’d expect: production uses a batch-first, single-fallback pattern (`tower-fallback` ) that only retries items _after_ a batch fails — self-correcting false- **rejects** but blind to false- **accepts** ; and the existing fuzz harness checks only for panics, not soundness . Upstream Zebra ships **no batch-vs-single equivalence test**. The June 5 Orchard incident showed a shielded-verification soundness break can hide for years — that bug was in the _circuit_ ; the batch-verification glue is a distinct, equally-unguarded surface (this oracle is complementary to circuit analysis, not a substitute). With NU6.2 just activated and Ironwood next, the area is actively changing.

## Project Overview

A continuous differential oracle that runs both real paths — batch (`BatchValidator` ) and single (`Item::verify_single` ) — and asserts they agree, across all four Zebra batch verifiers (Orchard halo2, Sapling/Sprout Groth16, Orchard RedPallas, Sapling RedJubjub), driven by adversarial corpora that hide one invalid item among valid ones, and extended to the new **Ironwood** pool + turnstile migration, integrated into continuous CI.

## Technical Approach

Built on the cargo-fuzz / libFuzzer + ASan scaffolding from #234. Adversarial corpus = real mainnet V5 valid bases + single-element tampering (corrupt a proof / swap a nullifier / perturb a signature scalar / replace a public input) + batch-composition fuzzing (size, ordering, duplicates, empty/singleton) — batching bugs live in the aggregation glue, not the per-item math.

## Deliverables and Milestones (3 milestones, ~3 months)

- M1 — Dual-path equivalence framework + Orchard
- M2 — Adversarial corpus + all four verifiers + coverage reporting
- M3 — Ironwood pool + turnstile soundness target + continuous CI (ClusterFuzzLite / OSS-Fuzz)

## Budget

- Startup Funding: $3,000
- Milestones 1–3: $42,000 ($15,000 / $15,000 / $12,000)
- Total: $45,000

## Design Principles

- **Standalone / non-invasive:** drives Zebra through public APIs; no consensus code changes.
- **Sustainable:** CI / OSS-Fuzz keeps the assertion running after the grant — even at zero findings, the machine is a durable ecosystem asset.
- **Community-oriented:** open repo + corpus, reusable by ZF, Zebra maintainers, and future auditors.

## Track Record

Continues ZCG #234, whose M1/M2 work surfaced several Zebra security issues credited to the project lead — including **CVE-2026-34202** (Critical, V5 transaction DoS), **GHSA-c8w6-x74f-vmg3** (Moderate, Sapling-receiver DoS), and issues **#10534** and **#10544**. Full proposal details: [Grant Application - Batch-vs-Single Verification Equivalence: Continuous Soundness Fuzzing for Zcash Shielded Verification (incl. Ironwood) · Issue #332 · ZcashCommunityGrants/zcashcommunitygrants · GitHub](https://github.com/ZcashCommunityGrants/zcashcommunitygrants/issues/332)

Thank you for your time and feedback.

---

<div class="post-metadata">

**Author:** ![ZCG](https://sea2.discourse-cdn.com/zcash/user_avatar/forum.zcashcommunity.com/zcg/32/33712_2.png) [@ZCG](https://forum.zcashcommunity.com/u/ZCG)\
**Post date:** [June 29, 2026, 1:34pm UTC](https://forum.zcashcommunity.com/t/grant-application-batch-vs-single-verification-equivalence-continuous-soundness-fuzzing-for-zcash-shielded-verification-incl-ironwood/56308/2 "2026-06-29T13:34:02Z")

</div>

@robustfengbin ZCG has voted async approve this proposal. Congratulations!

To keep the community informed, ZCG requests that you provide monthly updates via the forum in this thread.

Please check your forum inbox for a direct message from FPF with important next steps, including a link to the Milestone Payment Request Form and your unique validation code for submitting payment requests.

---

<div class="post-metadata">

**Author:** ![robustfengbin](https://sea2.discourse-cdn.com/zcash/user_avatar/forum.zcashcommunity.com/robustfengbin/32/42095_2.png) [@robustfengbin](https://forum.zcashcommunity.com/u/robustfengbin)\
**Post date:** [June 30, 2026, 1:34am UTC](https://forum.zcashcommunity.com/t/grant-application-batch-vs-single-verification-equivalence-continuous-soundness-fuzzing-for-zcash-shielded-verification-incl-ironwood/56308/3 "2026-06-30T01:34:51Z")

</div>

Thank you to the ZCG committee for the approval. I’ll get started on the harness as scoped in the proposal and post milestone updates here as work progresses.

---

<div class="post-metadata">

**Author:** ![robustfengbin](https://sea2.discourse-cdn.com/zcash/user_avatar/forum.zcashcommunity.com/robustfengbin/32/42095_2.png) [@robustfengbin](https://forum.zcashcommunity.com/u/robustfengbin)\
**Post date:** [July 22, 2026, 9:41am UTC](https://forum.zcashcommunity.com/t/grant-application-batch-vs-single-verification-equivalence-continuous-soundness-fuzzing-for-zcash-shielded-verification-incl-ironwood/56308/4 "2026-07-22T09:41:32Z")

</div>

# ZCG #332 — Batch-vs-Single Verification Equivalence: Milestone 1 delivered

Hi all — Milestone 1 of the batch-verification equivalence grant (#332) is delivered.

- **Repo:** [GitHub - robustfengbin/zebra-batch-equivalence · GitHub](https://github.com/robustfengbin/zebra-batch-equivalence) — CI is green, and a single `git clone` → `cargo test` reproduces every number below.
- **Report:** `reports/m1-delivery.md` in the repo.

## What it is (in plain terms)

Zcash shielded transactions prove they are valid without revealing their contents, and Zebra (the node) checks those proofs. For speed, Zebra checks many proofs together in one **batch** instead of one at a time. The danger: if the fast batch check ever _accepts_ a proof that a careful one-by-one check would _reject_, an invalid — potentially counterfeit — transaction slips through, and nothing in Zebra today re-checks that the two agree.

This project is that missing check. It is a differential oracle that runs every input down **both** paths — the production **batch** path and the **single** (batch-of-one) path — and asserts the two verdicts always agree, on accept **and** on reject, permanently, on every code change in CI. A batch accepting what single verification rejects is a counterfeiting-class soundness failure; that is the event this infrastructure exists to catch.

## What we measured

- **415 real mainnet Orchard proofs** (803 corpus seeds, pre-NU6.2 and NU6.2 eras, node-extracted), replayed through both paths: **zero divergence**.
- **Four coverage-guided fuzz targets** (batch equivalence, batch composition, era routing, single-bundle deep) extend the same assertions under input mutation; all seed-replayed in CI on every push.
- **Reject side** pinned by deterministic adversarial vectors: mechanically tampered real proofs (bit-flip / truncation / sighash mismatch / binding-signature damage) and add-time rejection over the NU6.3 cross-address gate — both paths fail closed, together.
- **Merged line coverage** of the components under test: halo2 batch verifier **100.0%** , Orchard `BatchValidator` **100.0%** , RedPallas batch **100.0%** , halo2 plonk verifier **98.4%** (the four remaining lines documented as unreachable through the public API — source-level reasoning, not padding).
- **Era routing** is asserted, not assumed: under every wrong circuit-era key a set must reject on both paths, and at most one era may accept it.

## Test suite

Every one of these tests asks the same question — _could the fast batch path ever wave through a proof the careful single path would catch?_ We come at it from ten angles: replay every real Orchard proof the chain has produced (all agree); deliberately corrupt, reorder, duplicate, and mis-route proofs and require **both** paths to reject together; and pin the exact key-selection boundaries where one slip would silently admit a forgery. Passing all ten turns _“the two paths agree”_ from an assumption into a guarantee that re-checks itself on every commit. 38 tests across 10 files, all green under `cargo test` and in CI:

| Suite | Tests | What it checks (plain) |
| --- | --- | --- |
| lib unit | 5 | the core accept/reject logic and that results are reproducible |
| `baseline_agreement` | 3 | real pre-NU6.2 proofs: both paths accept, and agree |
| `corpus_agreement` | 1 | the full 415-proof real corpus: both paths agree, zero divergence |
| `nu6_2_agreement` | 2 | 250 real NU6.2 proofs: agreement + correct key per era |
| `deep_invariants` | 5 | reordering, duplicating, or splitting a batch never changes the verdict |
| `strategy_equivalence` | 6 | the batch check agrees with a genuinely independent one-by-one verifier |
| `add_reject_equivalence` | 5 | the NU6.3 “no cross-address” rule is rejected the same way on both paths |
| `v6_pool_dimensions` | 7 | v6 / Ironwood two-pool transactions verify consistently |
| `mutation_smoke` | 2 | a tampered real proof is rejected by **both** paths, never silently accepted |
| `era_routing_anchor` | 2 | our copy of Zebra’s key-selection logic still matches upstream exactly |

## Ironwood (v6-ready)

M1 ships v6-ready ahead of the ~07-28 activation: extraction understands the v6 two-bundle shape (Orchard-pool + Ironwood-pool items from one transaction), cross-pool mixed batches are differentially tested, and the `enableCrossAddress` wire rules are pinned at the parse layer. Until activation there is no real NU6.3 traffic, so NU6.3-era accept-true vectors are builder-synthesized real proofs, labelled as such in the report. **The real NU6.3 corpus lands ~1 week post-activation as the first monthly update** , extracted at T+0 from our standing mainnet node.

## Context

With zcashd’s end-of-support (2026-07-18) Zcash now runs as a single-implementation network, so cross-implementation checking no longer happens for free; and April’s zcashd⟷Zebra consensus divergence (CVE-2026-34377) is a recent, concrete instance of the bug class a differential oracle catches. We state this as motivation, not alarm: everything measured so far agrees.

## How to reproduce

`git clone` → `cargo test` runs the full suite, including the corpus replay; `scripts/coverage.sh` regenerates the coverage table; `.github/workflows/ci.yml` is the exact recipe. Dependencies are pinned at Zebra v6.2.0, the release that activates Ironwood on mainnet.

## Next

M2 — systematic adversarial generators, four-verifier extension, and tower glue-layer mirroring — with monthly updates in this thread, starting with the real NU6.3 corpus (~1 week after the 07-28 activation).

---

<div class="post-metadata">

**Author:** ![robustfengbin](https://sea2.discourse-cdn.com/zcash/user_avatar/forum.zcashcommunity.com/robustfengbin/32/42095_2.png) [@robustfengbin](https://forum.zcashcommunity.com/u/robustfengbin)\
**Post date:** [August 4, 2026, 2:54am UTC](https://forum.zcashcommunity.com/t/grant-application-batch-vs-single-verification-equivalence-continuous-soundness-fuzzing-for-zcash-shielded-verification-incl-ironwood/56308/5 "2026-08-04T02:54:08Z")

</div>

**Monthly update — the real NU6.3 corpus, and the oracle run against it**

Covering 2026-07-28 (Ironwood activation) to 2026-08-04. M1 shipped with NU6.3 vectors  
that were builder-synthesized and labelled as such, because Ironwood had not activated and  
no such traffic existed. It activated on mainnet at height 3,428,143 on 2026-07-28, so the  
synthesized vectors can be replaced with the thing itself. This update reports that corpus  
and the first differential run against it.

**The corpus** (`seeds-real/nu6_3_activation/`)

- **172 transactions** , sampled across heights **3,428,150 - 3,433,400** — every 50th  
block, 106 blocks, zero misses
- **249 verification items** : **143 Orchard-pool bundles** and **106 Ironwood-pool  
bundles**. One bundle carries one halo2 proof, so the bundle is the unit the verifier  
actually consumes
- **77 dual-pool transactions** — a single transaction carrying both an Orchard-pool and  
an Ironwood-pool bundle. This is what cross-pool movement looks like when the chain  
produces it rather than when a test constructs it
- Those bundles hold **1,264 Orchard actions** and **216 Ironwood actions** (actions, not  
bundles — a bundle carries many)

The window opens 7 blocks past activation, so the activation boundary itself is  
represented. The extraction ran on 2026-08-02, which is stated rather than implied: when  
the chain produced a block and when we fetched it are different facts, and only the first  
is visible in the data.

**On the T+0 extraction**

The M1 post said the corpus would be extracted at T+0. Two facts, stated separately  
because they are different: the _window_ opens at the activation boundary — the first  
sampled block is 3,428,150, seven blocks past activation — but the _fetch_ ran on  
2026-08-02, not at that instant.

We did pull a denser slice on activation night: 11 blocks at stride 4 across heights  
3,428,149-3,428,189, spanning 50.8 minutes, carrying 698 Orchard actions, 211 Ironwood  
actions, and 96 dual-pool transactions. It is not in this directory. Forty heights is too  
narrow a base for an equivalence corpus, so the committed cut trades density for reach —  
stride 50 across 5,250 heights, 4.58 days.

Worth stating what that trade costs, because it is the more interesting number: the  
activation-night slice runs 8.7 dual-pool transactions per block against 0.73 in the  
committed window — twelve times denser in exactly the traffic this grant exists to test.  
Cross-pool movement peaks at the boundary and thins out fast. We hold those blocks and can  
commit them as a second directory if reviewers want the dense slice in-tree.

**The differential run**

Every one of the 249 items was verified twice: once through the batch path, with items  
grouped into batches of 16, and once through the single path, one item at a time. The  
assertion is that the two paths reach the same verdict for the same item — this is the  
equivalence property the grant exists to test, run for the first time against real  
third-era mainnet data rather than synthesized vectors.

- **249 items over 16 batches — zero disagreements, zero false accepts**
- All 249 accepted under the PostNu6\_3 verifying key, as real mainnet proofs must
- **Mixed-pool batches** : Orchard-pool and Ironwood-pool items interleaved into shared  
batches also agree item-for-item. Since NU6.3 the two pools share a circuit and a batch  
stack, so mixed batches are the ordinary shape of post-activation traffic, not an edge  
case

One property is asserted before any of the above is trusted: that both pools actually  
loaded. A transaction may carry two bundles, and a loader that takes only the first would  
yield 172 items instead of 249, verify zero Ironwood bundles, and still report agreement  
on everything it did check. The run fails if the Ironwood count is zero.

**Reproducing it**

```auto
git clone https://github.com/robustfengbin/zebra-batch-equivalence
cd zebra-batch-equivalence
cargo test --test nu6_3_agreement -- --nocapture

```

The corpus is committed as raw wire bytes, one transaction per file, with the block height  
in each filename — heights are load-bearing, since the sighash depends on the network  
upgrade in force at that height. The directory carries its own README recording the  
sampling stride, the extraction date as distinct from the block heights, and why a prefix  
of the directory is not a substitute for a random sample of it.

**Next**

The dual-pool transactions are the input M3’s turnstile work needs, and until activation  
they could only be reasoned about from constructed examples. There are 77 real ones in the  
repository now, and the window is sampled rather than exhausted.

---

<div class="post-metadata">

**Author:** ![robustfengbin](https://sea2.discourse-cdn.com/zcash/user_avatar/forum.zcashcommunity.com/robustfengbin/32/42095_2.png) [@robustfengbin](https://forum.zcashcommunity.com/u/robustfengbin)\
**Post date:** [August 27, 2026, 4:10am UTC](https://forum.zcashcommunity.com/t/grant-application-batch-vs-single-verification-equivalence-continuous-soundness-fuzzing-for-zcash-shielded-verification-incl-ironwood/56308/6 "2026-08-27T04:10:27Z")

</div>

Hi all — Milestone 2 of the batch-verification equivalence grant (#332) is delivered.

Repo: [github.com/robustfengbin/zebra-batch-equivalence](https://github.com/robustfengbin/zebra-batch-equivalence) — CI is green, and `git clone --branch m2` → `cargo test` reproduces every number below. `reports/m2-delivery.md` maps each deliverable to the grant text clause by clause and carries the coverage measurements and method. Base: Zebra **v6.3.0** (`f5c5277f`).

## What it is (in plain terms)

Zebra verifies shielded proofs in **batches** , because checking many at once is far faster than one at a time. The risk is asymmetric. A batch that wrongly _rejects_ is self-healing: Zebra falls back to verifying each item alone and the good ones pass. A batch that wrongly _accepts_ lets an invalid — potentially counterfeit — transaction through, the fallback never runs, and nothing in Zebra re-checks that the two paths agree.

This project is that missing check: every input runs down both paths, and the two verdicts must match — on accept and on reject, on every change, in CI. M1 delivered it for Orchard; **M2 delivers it for everything else Zebra verifies in batches.**

## What M2 changes

M1’s oracle was Orchard-specific and answered per batch. M2 changes both halves:

- **One interface, four verifiers** — Sapling and Sprout Groth16 and both signature batch verifiers behind the same trait, checked the same way.
- **Verdicts per item, not per batch** — an aggregation bug is one item’s verdict going wrong because of another item in the same batch, which a single yes/no for the batch cannot express.
- **Batches formed the way production forms them** — M2 drives `tower-batch-control`, Zebra’s real batching middleware, where membership is decided by arrival timing rather than chosen by the harness. It is the only path that reaches the scheduler’s own code.

## Results

**1. All four verifiers named in the grant are under the oracle.** Sapling Groth16, Sprout Groth16 and Sapling RedJubjub are new in M2; Orchard RedPallas shipped in M1 and now sits under the shared trait. Sprout is the one whose relationship to production differs: Zebra verifies JoinSplits one at a time and the issue proposing batch support, [#3127](https://github.com/ZcashFoundation/zebra/issues/3127), was closed as _not planned_ in 2022 — so the oracle covers `bellman`’s batch path under Sprout’s parameters, with the gate in place ahead of a change that may never be made. RedJubjub is broken out of Sapling because Sapling validates signatures first and returns on failure without reaching the proof batch — combined, a `false` is unattributable.

**2. Adversarial generators that check their own work.** A valid base from real mainnet items, single-element tampers, and batch compositions including mostly-valid-plus-one-invalid. A tamper that fails to actually invalidate its target is rejected rather than quietly used — such a batch passes every assertion while testing nothing, and looks identical to one that worked.

**3. Zero disagreements, zero false-accepts** across every real mainnet corpus, including the 249 NU6.3 items from the August update. The proposal names that outcome directly — _“Zero findings is an accepted, valuable outcome — the property is then under continuous machine-checked assertion, proven by corpus and CI.”_

**4. The batch glue layer is measured.** Merged region % / line %:

| Object | Surface | |
| --- | --- | --- |
| `BatchValidator` | orchard `bundle/batch.rs` | **100.00 / 100.00** |
| | sapling-crypto `verifier/batch.rs` | **89.08 / 86.96** |
| reddsa batch | reddsa `src/batch.rs` | **98.97 / 100.00** |
| | redjubjub `src/batch.rs` | **100.00 / 100.00** |
| `tower-batch-control` | `service.rs` | **76.36 / 81.20** |
| | `worker.rs` | **59.43 / 65.90** |

At M1 nothing here linked `tower-batch-control` at all. Elsewhere, bellman’s Groth16 batch verifier moved 56.31 → 94.59 when Sprout landed: measured one suite at a time, Sapling reaches 56.31 and Sprout 44.59, so the two consumers exercise largely different code and neither makes the other redundant. Re-measured at v6.3.0 on different hardware, all twelve rows in the full table came back identical to the digit.

Two of these figures mean something more specific than the number. Sapling’s 86.96 stays where it is deliberately: three of its four uncovered rejection points are consensus checks the batch and single paths _share_, so a tamper reaching them makes both reject and the oracle learns nothing — coverage bought there would be padding. `worker.rs` at 59.43 is an async scheduling loop whose uncovered regions are shutdown, channel-closure and error propagation, reached by faulting the service rather than by feeding it more batches — a different kind of test from anything in this milestone.

## Tests and reproduction

**82 passed · 0 failed · 1 ignored** , exit code 0 — 19 unit tests plus 64 entries across 15 integration binaries, run independently on two machines. M2’s new corpus is 2,032 mainnet transactions carrying Groth16 JoinSplits and Sapling spends; the repository now holds 3,007 corpus files.

```auto
git clone --branch m2 https://github.com/robustfengbin/zebra-batch-equivalence
cd zebra-batch-equivalence
cargo test
rustup toolchain install nightly-2026-07-03
NIGHTLY=nightly-2026-07-03 ./scripts/coverage.sh --with-tests

```

The nightly pin matters for the coverage tables only — the suite runs on any toolchain that builds the tree, but region counts depend on compiler inlining.

## Upstreaming

The harnesses land upstream at **M3**. M1 and M2 build the pieces; M3 is where they become one continuously-running contribution rather than three partial ones, which is the merge point the proposal names. Zebra contributes issue-first, so M3 opens with issues rather than PRs.

## Next

M3: the Ironwood verifier under the oracle, the turnstile and migration soundness target, the full suite in continuous CI via ClusterFuzzLite / OSS-Fuzz with a permanent regression corpus, and the final report. The continuous-fuzzing work is also what extends evolved input to M2’s four new surfaces — today all four fuzz targets are Orchard and the new surfaces carry deterministic-test coverage only. Monthly updates continue in this thread.

---

<div class="post-metadata">

**Author:** ![robustfengbin](https://sea2.discourse-cdn.com/zcash/user_avatar/forum.zcashcommunity.com/robustfengbin/32/42095_2.png) [@robustfengbin](https://forum.zcashcommunity.com/u/robustfengbin)\
**Post date:** [September 24, 2026, 4:34am UTC](https://forum.zcashcommunity.com/t/grant-application-batch-vs-single-verification-equivalence-continuous-soundness-fuzzing-for-zcash-shielded-verification-incl-ironwood/56308/7 "2026-09-24T04:34:15Z")

</div>

Hi all — Milestone 3, the last milestone of the batch-verification equivalence grant (#332), is delivered.  
Repo: [GitHub - robustfengbin/zebra-batch-equivalence · GitHub](https://github.com/robustfengbin/zebra-batch-equivalence) — `git clone --branch m3` → `cargo test` reproduces the test results, and the report’s _Reproducing this_ gives the command for every number. `reports/m3-final-security-report.md` is the final security report: each deliverable mapped to the grant text, the findings of all three milestones, the limitations, and what keeps running after the grant. Base: Zebra v6.3.0 (f5c5277f). This post is also the September update.

**What M3 adds**

1. **Ironwood under the oracle, on real mainnet traffic.** The NU6.3 corpus is 172 post-activation mainnet transactions (249 verification items), extracted from a standing node rather than built from the spec, so the spec-adapter fallback the grant allowed was not needed. Ironwood is covered on all five surfaces — base equivalence, deep invariants, adversarial batches, the turnstile and fuzzing — including mixed Orchard/Ironwood batches taken from a single v6 transaction, and adversarial batches that straddle the pool boundary. The two paths agreed throughout.

2. **The turnstile target.** The grant names three properties: conservation, no double-migration, no forged residual value crossing. The obvious reading of conservation — a migration’s two pool balances cancel — fails on all 77 real two-pool transactions, so the check is the fee identity instead, which holds on 172 of 172 with every fee a positive multiple of 5,000 zatoshi. Replaying the corpus is caught as double-migration, nullifier for nullifier. The third property cannot be read from a window in the middle of the chain, because what the pools held before it is unknown; it is checked in both directions on constructed runs from an empty chain, and the report says so rather than counting it as tested on mainnet.

3. **Continuous fuzzing on a schedule.** Nine fuzz targets — every batch verifier, Zebra’s batching middleware and the turnstile — run under ClusterFuzzLite in the repository: every day it prunes the corpus, fuzzes each target, and checks per target that it got past its stored corpus into new inputs; a weekly job publishes coverage ([https://robustfengbin.github.io/zebra-batch-equivalence-corpora/coverage/latest/report/linux/report.html](https://robustfengbin.github.io/zebra-batch-equivalence-corpora/coverage/latest/report/linux/report.html)). The regression corpus is kept in a dedicated repository, `zebra-batch-equivalence-corpora`.  
Since 2026-09-23 it has run on its schedule: one scheduled run so far at delivery, green, with all nine targets past their stored corpus. The runs before that were started by hand.

**Findings**

No disagreement between the two paths about Zcash, in any milestone: zero divergences in either direction across every real corpus. The behaviour reported in M2 — a rejected Sapling bundle leaving residue in a shared batch — stands as written there.

M3’s three findings are about the instrument. Two share a shape worth naming: each produced _no answer_, which looks exactly like a correct one. The fuzz seeds for the four core targets were chosen by filename, and for the pre-NU6.2 era — the circuit the June 5 incident lived in — the six seeds shipped for it reached the verifier zero times, while nine targets were green with healthy execution counts. And the turnstile’s fuzz assertion compared something no input order could change, so it could not fail. The third is the inverse: on its first day the continuous run raised a critical that was the harness misreading a mutated control byte, not a verifier accepting anything. All three are fixed, with tests that fail if they come back.

**Cost of the check**

Batch verification is 5.1–5.8× faster than single verification on the same machine (40 alternating runs across two build profiles) — which is why the batch path exists, and why its agreement with the single path is worth asserting continuously. M2’s 4.5–7.8× across two machines stands alongside it.

**Tests and reproduction**

`cargo test --workspace --locked` on the `m3` tree, 2026-09-23: 113 passed · 0 failed · 1 ignored, exit code 0 — 22 test harnesses, 19 of them carrying tests.

```auto
git clone --branch m3 https://github.com/robustfengbin/zebra-batch-equivalence
cd zebra-batch-equivalence
cargo test

```

**After the grant**

The daily ClusterFuzzLite run keeps going in this repository; its storage token is on the grantee’s account, so for now it is “self-running”, not yet “no further involvement from the grantee”. Moving the targets into `zebra-fuzz/` and OSS-Fuzz is the hand-off, and it waits on #11492. If a run ever shows the batch path accepting what the single path rejects, that is a possible soundness bug in shipping verification code: it should go privately to [security@zfnd.org](mailto:security@zfnd.org) with the crash file, not to a public issue.

Thanks to ZCG for funding this, and to the Zebra maintainers for reviewing and merging the upstream fuzzing work along the way.
