perf(consensus): cache shielded bundle verification - #11380
Open
evan-forbes wants to merge 1 commit into
Open
Conversation
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
force-pushed
the
perf/cache-shielded-verification
branch
from
September 2, 2026 20:22
3e3a5c7 to
31432a0
Compare
evan-forbes
marked this pull request as ready for review
September 2, 2026 23:43
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/ |
Contributor
|
Thank you for this PR, we'll take a look shortly |
9 tasks
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 oneoneshotinsideverify_sapling_bundleorqueue_orchard_bundleis 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:
Only
Okresults are recorded. A batch error is not per-item evidence, sinceFallbackresolves 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 reasonCached::poll_readydoes 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 insidecall, which also stops hits from holdingBatch'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 sameverifiernames aszebra.consensus.batch.duration_seconds.Tests
Specifications & References
Follow-up Work
AI Disclosure
PR Checklist
type(scope): description