Skip to content

perf(consensus): cache shielded bundle verification - #11380

Open
evan-forbes wants to merge 1 commit into
ZcashFoundation:mainfrom
valargroup:perf/cache-shielded-verification
Open

perf(consensus): cache shielded bundle verification#11380
evan-forbes wants to merge 1 commit into
ZcashFoundation:mainfrom
valargroup:perf/cache-shielded-verification

Conversation

@evan-forbes

@evan-forbes evan-forbes commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Motivation

Every shielded proof and signature is verified twice: once when the transaction arrives over mempool gossip, and again when it arrives inside a block.

Zebra used to avoid the second pass by skipping whole-transaction verification for block transactions already accepted into the mempool, and #10494 removed that as a security fix without replacing it.

The reason that bypass kept breaking is structural: it cached valid(tx, height, block time, spent outputs) under a key that did not determine that proposition. Expiry, lock time, the consensus branch id, the Orchard soft-fork gates and the proof-size rule all move with height, and the network upgrade even selects which Orchard circuit verifying key applies, so a stale verdict could answer a different question than the one being asked.

Solution

Cache the bundle verification itself instead. verify(bundle, sighash, vk) is a pure function, so a hit is bit-identical to the computation it replaces. On a hit the transaction verifier still runs end to end at the block's height, with the block's time and the block's spent outputs; only one oneshot inside verify_sapling_bundle or queue_orchard_bundle is short-circuited.

That makes key completeness the whole of the safety argument. An entry is keyed by the transaction's unmined ID, the sighash it was verified against, and the shielded pool the bundle sits in:

  • the ID determines the bundle. A witnessed ID's ZIP 244 authorizing-data digest commits to the proofs and signatures, which the txid alone does not — that was CVE-2026-34377. A v4 transaction's legacy ID is the hash of the whole serialization, which carries the same authorizing data;
  • the sighash is named separately because it is not always a function of the transaction alone: a v5 or v6 sighash also commits to the amounts and scripts of the spent transparent outputs, and a v4 shielded sighash commits to the block's consensus branch id, which a v4 ID does not;
  • the pool separates the Orchard and Ironwood bundles of one v6 transaction, which share an ID and a sighash;
  • the verifying key is committed to structurally: each Orchard circuit version already has its own verifier, so it now also has its own cache, and an entry can only be read back under the key it was written against. Sapling has one key pair for all of history.

Only Ok results are recorded. A batch error is not per-item evidence, since Fallback resolves batch failures by re-verifying each item singly, and an error out of the service need not be a verdict at all — it can report that the batch worker shut down. Recording that as "invalid" would make the node reject a valid block. For the same reason Cached::poll_ready does not delegate to the inner service: callers poll before they call, so delegating would surface a dead batch worker's error for an item whose result the cache already holds. The miss path acquires inner readiness inside call, which also stops hits from holding Batch's semaphore permits.

Items built without a witnessed transaction ID are verified every time.

Each cache holds 20,000 keys — several blocks of history plus a full mempool — and reports hits, misses, inserts, evictions and size under zebra.consensus.cache.*, labelled by the same verifier names as zebra.consensus.batch.duration_seconds.

Tests

  • cache-key completeness for both verifiers over real mainnet bundles;
  • the cache's own behaviour (hit, miss, eviction, no reuse across items, no memory of failures, cancellation, readiness) against a stub verifier;
  • a real Sapling bundle rejected when replayed under another branch id;
  • two end-to-end tests through the transaction verifiers showing that a mempool verification is reused by the block that mines the transaction, that the block one height past expiry is still rejected, and that an authorizing-data twin with an identical txid is fully re-verified and rejected.

Specifications & References

Follow-up Work

  • Cache capacity is a constant; a config knob may be worth adding if operators want to trade memory for hit rate.

AI Disclosure

  • No AI tools were used in this PR
  • AI tools were used: development assistance (implementation, tests, and review) — @evan-forbes to confirm the exact wording before this leaves draft

PR Checklist

  • The PR title follows conventional commits format: type(scope): description
  • The PR follows the contribution guidelines.
  • This change was discussed in an issue or with the team beforehand.
  • The solution is tested.
  • The documentation and changelogs are up to date.

Every shielded proof and signature is verified twice: once when the
transaction arrives over mempool gossip, and again when it arrives inside
a block. Zebra used to avoid the second pass by skipping whole-transaction
verification for block transactions already accepted into the mempool, and
removed that in ZcashFoundation#10494 as a security fix without replacing it.

The reason that bypass kept breaking is structural: it cached
`valid(tx, height, block time, spent outputs)` under a key that did not
determine that proposition. Expiry, lock time, the consensus branch id,
the Orchard soft-fork gates and the proof-size rule all move with height,
and the network upgrade even selects which Orchard circuit verifying key
applies, so a stale verdict could answer a different question than the one
being asked.

Cache the bundle verification itself instead. `verify(bundle, sighash, vk)`
is a pure function, so a hit is bit-identical to the computation it
replaces. On a hit the transaction verifier still runs end to end at the
block's height, with the block's time and the block's spent outputs; only
one `oneshot` inside `verify_sapling_bundle` or `queue_orchard_bundle` is
short-circuited.

That makes key completeness the whole of the safety argument. An entry is
keyed by the transaction's unmined ID, the sighash it was verified against,
and the shielded pool the bundle sits in:

  * the ID determines the bundle. A witnessed ID's ZIP 244 authorizing-data
    digest commits to the proofs and signatures, which the txid alone does
    not - that was CVE-2026-34377. A v4 transaction's legacy ID is the hash
    of the whole serialization, which carries the same authorizing data;
  * the sighash is named separately because it is not always a function of
    the transaction alone: a v5 or v6 sighash also commits to the amounts
    and scripts of the spent transparent outputs, and a v4 shielded sighash
    commits to the block's consensus branch id, which a v4 ID does not;
  * the pool separates the Orchard and Ironwood bundles of one v6
    transaction, which share an ID and a sighash;
  * the verifying key is committed to structurally: each Orchard circuit
    version already has its own verifier, so it now also has its own cache,
    and an entry can only be read back under the key it was written
    against. Sapling has one key pair for all of history.

Only `Ok` results are recorded. A batch error is not per-item evidence,
since `Fallback` resolves batch failures by re-verifying each item singly,
and an error out of the service need not be a verdict at all - it can
report that the batch worker shut down. Recording that as "invalid" would
make the node reject a valid block. For the same reason `Cached::poll_ready`
does not delegate to the inner service: callers poll before they call, so
delegating would surface a dead batch worker's error for an item whose
result the cache already holds. The miss path acquires inner readiness
inside `call`, which also stops hits from holding `Batch`'s semaphore
permits.

Items built without a witnessed transaction ID are verified every time.

Each cache holds 20,000 keys - several blocks of history plus a full
mempool - and reports hits, misses, inserts, evictions and size under
`zebra.consensus.cache.*`, labelled by the same `verifier` names as
`zebra.consensus.batch.duration_seconds`.

Tests: cache-key completeness for both verifiers over real mainnet bundles;
the cache's own behaviour (hit, miss, eviction, no reuse across items, no
memory of failures, cancellation, readiness) against a stub verifier; a
real Sapling bundle rejected when replayed under another branch id; and two
end-to-end tests through the transaction verifiers showing that a mempool
verification is reused by the block that mines the transaction, that the
block one height past expiry is still rejected, and that an
authorizing-data twin with an identical txid is fully re-verified and
rejected.
@evan-forbes
evan-forbes force-pushed the perf/cache-shielded-verification branch from 3e3a5c7 to 31432a0 Compare September 2, 2026 20:22
@evan-forbes
evan-forbes marked this pull request as ready for review September 2, 2026 23:43
@evan-forbes

Copy link
Copy Markdown
Contributor Author

w/o this the orphan rate is quite a lot higher see https://x.com/zmanian/status/2094847171720065157 and https://zakura.com/engineering/25-second-block-time-update/

@mpguerra

mpguerra commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Thank you for this PR, we'll take a look shortly

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feature: reuse shielded bundle verification between mempool and block validation

2 participants