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

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

Thank you for your time and feedback.

1 Like

@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.

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.

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

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

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).

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

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.

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

Repo: 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, 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.

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.

Hi all — Milestone 3, the last milestone of the batch-verification equivalence grant (#332), is delivered.
Repo: GitHub - robustfengbin/zebra-batch-equivalence · GitHub — 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). 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.

    Separately, Zebra itself was enrolled in Google’s OSS-Fuzz on 2026-09-18 (google/oss-fuzz#15900), on the zebra-fuzz/ harnesses contributed upstream during this grant (#11221). The equivalence targets are proposed for zebra-fuzz/ as well in ZcashFoundation/zebra#11492 (opened 2026-09-23), starting with two of them so they do not dilute the compute OSS-Fuzz shares across Zebra’s targets.

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.

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 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.