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