Skip to content

Commit 3e3a5c7

Browse files
committed
perf(consensus): cache shielded bundle verification
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 #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.
1 parent 0a43e0e commit 3e3a5c7

9 files changed

Lines changed: 2257 additions & 53 deletions

File tree

zebra-consensus/src/primitives.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use tokio::sync::oneshot::error::RecvError;
44

55
use crate::BoxError;
66

7+
mod cache;
78
pub mod ed25519;
89
pub mod groth16;
910
pub mod halo2;

zebra-consensus/src/primitives/cache.rs

Lines changed: 483 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
//! Tests for the shared verification cache key and the bounded store behind it.
2+
//!
3+
//! The verifiers pin their own key construction over real bundles. These cover the parts that are
4+
//! the same for all of them: which of the key's three components separate two entries, and the
5+
//! store's capacity and eviction.
6+
7+
use zebra_chain::{
8+
serialization::BytesInDisplayOrder,
9+
transaction::{AuthDigest, Hash, UnminedTxId, WtxId},
10+
};
11+
12+
use super::{CacheKey, ShieldedPool, VerifiedBundles};
13+
14+
/// Returns a transaction ID with no witness, as a v1-v4 transaction has.
15+
fn legacy_tx_id(tag: u8) -> UnminedTxId {
16+
UnminedTxId::Legacy(Hash::from_bytes_in_display_order(&[tag; 32]))
17+
}
18+
19+
/// Returns a witnessed transaction ID, as a v5 or v6 transaction has.
20+
fn witnessed_tx_id(txid_tag: u8, auth_digest_tag: u8) -> UnminedTxId {
21+
UnminedTxId::Witnessed(WtxId {
22+
id: Hash::from_bytes_in_display_order(&[txid_tag; 32]),
23+
auth_digest: AuthDigest::from_bytes_in_display_order(&[auth_digest_tag; 32]),
24+
})
25+
}
26+
27+
/// The pool separates the bundles a v6 transaction carries under one ID and one sighash.
28+
///
29+
/// The Orchard and Ironwood caches are one cache from NU6.3 onward, so this is what keeps their
30+
/// entries apart there.
31+
#[test]
32+
fn keys_for_the_same_transaction_differ_by_pool() {
33+
let tx_id = witnessed_tx_id(1, 2);
34+
let sighash = [3; 32];
35+
36+
let keys = [
37+
CacheKey::new(tx_id, sighash, ShieldedPool::Sapling),
38+
CacheKey::new(tx_id, sighash, ShieldedPool::Orchard),
39+
CacheKey::new(tx_id, sighash, ShieldedPool::Ironwood),
40+
];
41+
42+
let unique: std::collections::HashSet<_> = keys.iter().collect();
43+
assert_eq!(
44+
unique.len(),
45+
keys.len(),
46+
"one transaction's three shielded bundles must not share a cache key"
47+
);
48+
}
49+
50+
/// The authorizing-data digest separates two witnessed IDs that share a txid.
51+
///
52+
/// Under ZIP 244 a v5 transaction's txid excludes its proofs and signatures, so a key that named
53+
/// only the txid would answer both of these with one verification. That is CVE-2026-34377.
54+
#[test]
55+
fn witnessed_keys_differ_by_authorizing_data() {
56+
let sighash = [3; 32];
57+
58+
assert_ne!(
59+
CacheKey::new(witnessed_tx_id(1, 2), sighash, ShieldedPool::Orchard),
60+
CacheKey::new(witnessed_tx_id(1, 4), sighash, ShieldedPool::Orchard),
61+
"the same txid with different authorizing data must not share a cache key"
62+
);
63+
}
64+
65+
/// The sighash separates two verifications of one transaction's bundle.
66+
///
67+
/// The sighash is not a function of the transaction alone: the amounts and scripts of the spent
68+
/// transparent outputs enter it, and those come from the verification context.
69+
#[test]
70+
fn keys_differ_by_sighash() {
71+
let tx_id = witnessed_tx_id(1, 2);
72+
73+
assert_ne!(
74+
CacheKey::new(tx_id, [3; 32], ShieldedPool::Sapling),
75+
CacheKey::new(tx_id, [4; 32], ShieldedPool::Sapling),
76+
"the sighash is an input to verification, so it must be an input to the key"
77+
);
78+
}
79+
80+
/// A legacy ID and a witnessed ID never collide, whatever they contain.
81+
#[test]
82+
fn legacy_and_witnessed_keys_differ() {
83+
let sighash = [3; 32];
84+
85+
assert_ne!(
86+
CacheKey::new(legacy_tx_id(1), sighash, ShieldedPool::Sapling),
87+
CacheKey::new(witnessed_tx_id(1, 1), sighash, ShieldedPool::Sapling),
88+
"a v4 transaction ID must not collide with a witnessed one"
89+
);
90+
}
91+
92+
/// Every Orchard value pool has a distinct tag.
93+
#[test]
94+
fn orchard_value_pools_map_to_distinct_tags() {
95+
assert_eq!(
96+
ShieldedPool::from(orchard::ValuePool::Orchard),
97+
ShieldedPool::Orchard
98+
);
99+
assert_eq!(
100+
ShieldedPool::from(orchard::ValuePool::Ironwood),
101+
ShieldedPool::Ironwood
102+
);
103+
}
104+
105+
/// The lookup set and the eviction queue always hold the same keys.
106+
///
107+
/// They are two representations of one fact. A path that updated one without the other would
108+
/// either drop a key that `contains` still answers — remembering a bundle for the rest of the
109+
/// process — or grow the queue past the capacity it was built with.
110+
#[test]
111+
fn the_lookup_set_and_the_eviction_queue_hold_the_same_keys() {
112+
let mut verified = VerifiedBundles::new(2);
113+
let keys = [
114+
CacheKey::new(legacy_tx_id(1), [0; 32], ShieldedPool::Sapling),
115+
CacheKey::new(legacy_tx_id(2), [0; 32], ShieldedPool::Sapling),
116+
CacheKey::new(legacy_tx_id(3), [0; 32], ShieldedPool::Sapling),
117+
];
118+
119+
for key in keys {
120+
verified.insert(key);
121+
assert_eq!(
122+
verified.keys.len(),
123+
verified.insertion_order.len(),
124+
"the lookup set and the eviction queue must hold the same keys"
125+
);
126+
assert!(verified.keys.len() <= 2, "the capacity bounds the cache");
127+
}
128+
assert!(
129+
!verified.contains(&keys[0]),
130+
"the oldest key must be evicted"
131+
);
132+
133+
let repeated = verified.insert(keys[2]);
134+
assert!(!repeated.inserted, "a concurrent duplicate is not recorded");
135+
assert_eq!(repeated.evicted, 0, "a duplicate must not evict anything");
136+
assert_eq!(verified.keys.len(), verified.insertion_order.len());
137+
138+
verified.clear();
139+
assert!(verified.keys.is_empty() && verified.insertion_order.is_empty());
140+
}

zebra-consensus/src/primitives/halo2.rs

Lines changed: 98 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ use orchard::{
1717
};
1818
use rand::thread_rng;
1919
use zcash_protocol::value::ZatBalance;
20-
use zebra_chain::{parameters::NetworkUpgrade, transaction::SigHash};
20+
use zebra_chain::{
21+
parameters::NetworkUpgrade,
22+
transaction::{SigHash, UnminedTxId, WtxId},
23+
};
2124

2225
use crate::{error::TransactionError, BoxError};
2326
use thiserror::Error;
@@ -26,7 +29,10 @@ use tower::Service;
2629
use tower_batch_control::{Batch, BatchControl, RequestWeight};
2730
use tower_fallback::Fallback;
2831

29-
use super::spawn_fifo;
32+
use super::{
33+
cache::{CacheKey, Cached, CachedItem, ShieldedPool, CACHE_CAPACITY},
34+
spawn_fifo,
35+
};
3036

3137
#[cfg(test)]
3238
mod tests;
@@ -110,16 +116,19 @@ lazy_static::lazy_static! {
110116

111117
/// A Halo2 verification item, used as the request type of the service.
112118
///
113-
/// An [`Item`] is key-agnostic: it carries only the bundle and sighash. The circuit era's verifying
114-
/// key is supplied by whichever [`Verifier`] processes the item, so an item is always validated
115-
/// against exactly one key and eras are never mixed within a batch.
119+
/// An [`Item`] is key-agnostic: the circuit era's verifying key is supplied by whichever
120+
/// [`Verifier`] processes the item, so an item is always validated against exactly one key and
121+
/// eras are never mixed within a batch. Items built by the transaction verifier also carry a
122+
/// cache key derived from their transaction's [`WtxId`], sighash, and bundle pool, so a bundle
123+
/// verified from the mempool is not verified again when the block that mines it arrives.
116124
#[derive(Clone, Debug)]
117125
pub struct Item {
118126
// `Arc`-wrapped so cloning an `Item` — which `tower-fallback` does eagerly for every request —
119127
// shares the bundle instead of deep-copying its actions and multi-KB proof. `add_bundle` only
120128
// needs `&Bundle`.
121129
bundle: Arc<orchard::bundle::Bundle<orchard::bundle::Authorized, ZatBalance>>,
122130
sighash: SigHash,
131+
cache_key: Option<CacheKey>,
123132
}
124133

125134
impl RequestWeight for Item {
@@ -130,13 +139,40 @@ impl RequestWeight for Item {
130139

131140
impl Item {
132141
/// Creates a new [`Item`] from a bundle and sighash.
142+
///
143+
/// Items constructed without their transaction's [`WtxId`] are verified normally but are not
144+
/// cached. The transaction verifier supplies the witnessed transaction ID through a
145+
/// crate-private constructor so its items can reuse successful results.
133146
pub fn new(
134147
bundle: orchard::bundle::Bundle<orchard::bundle::Authorized, ZatBalance>,
135148
sighash: SigHash,
136149
) -> Self {
137150
Self {
138151
bundle: Arc::new(bundle),
139152
sighash,
153+
cache_key: None,
154+
}
155+
}
156+
157+
/// Creates a cacheable item using its already-computed witnessed transaction ID.
158+
///
159+
/// `wtx_id` must identify the transaction containing `bundle`. The transaction verifier
160+
/// passes the ID it derived from its own request, whose caller must preserve this invariant.
161+
pub(crate) fn new_with_wtx_id(
162+
bundle: orchard::bundle::Bundle<orchard::bundle::Authorized, ZatBalance>,
163+
sighash: SigHash,
164+
wtx_id: WtxId,
165+
) -> Self {
166+
let pool = ShieldedPool::from(bundle.bundle_version().value_pool());
167+
168+
Self {
169+
bundle: Arc::new(bundle),
170+
sighash,
171+
cache_key: Some(CacheKey::new(
172+
UnminedTxId::Witnessed(wtx_id),
173+
sighash.0,
174+
pool,
175+
)),
140176
}
141177
}
142178

@@ -156,12 +192,35 @@ impl Item {
156192
}
157193
}
158194

195+
impl CachedItem for Item {
196+
/// Returns this item's cache key, if it was constructed with a witnessed transaction ID.
197+
///
198+
/// [`WtxId`] commits to the transaction's effecting and authorizing data. The sighash
199+
/// additionally commits to the amounts and scripts of spent transparent outputs, which are
200+
/// supplied by the verification context and are not part of the `WtxId`. The pool selects one
201+
/// of the two Orchard-shaped bundles a v6 transaction can carry. The verifying key is absent
202+
/// on purpose: each Orchard circuit era has its own cache, so an entry is only read back
203+
/// under the key it was written against (see [`orchard_v5_verifier_for`]).
204+
///
205+
/// The txid alone is insufficient because it excludes authorizing data under ZIP 244
206+
/// (CVE-2026-34377). The pool is also required because both bundles in a v6 transaction share
207+
/// the same [`WtxId`].
208+
fn cache_key(&self) -> Option<CacheKey> {
209+
self.cache_key
210+
}
211+
}
212+
159213
trait QueueBatchVerify {
160214
fn queue(&mut self, item: Item) -> Result<(), orchard::bundle::BatchError>;
161215
}
162216

163217
impl QueueBatchVerify for BatchValidator<'_> {
164-
fn queue(&mut self, Item { bundle, sighash }: Item) -> Result<(), orchard::bundle::BatchError> {
218+
fn queue(
219+
&mut self,
220+
Item {
221+
bundle, sighash, ..
222+
}: Item,
223+
) -> Result<(), orchard::bundle::BatchError> {
165224
self.add_bundle(bundle.as_ref(), sighash.0)
166225
}
167226
}
@@ -221,13 +280,17 @@ impl Service<Item> for OrchardFallback {
221280
}
222281
}
223282

283+
/// The batching-and-fallback stack for one Orchard circuit version, before caching.
284+
type BatchFallbackService = Fallback<Batch<Verifier, Item>, OrchardFallback>;
285+
224286
/// The concrete type of a global Halo2 verification service.
225287
///
226288
/// Each Orchard circuit version gets its own instance — see [`VERIFIER_PRE_NU6_2`],
227-
/// [`VERIFIER_NU6_2`], and [`VERIFIER_NU6_3_ONWARD`] — so that batches, fallbacks, and verifying
228-
/// keys are fully separated per circuit version. The Orchard verifier routing functions
229-
/// ([`orchard_v5_verifier_for`] / [`orchard_v6_verifier`]) return a borrow of the matching one.
230-
pub type VerifierService = Fallback<Batch<Verifier, Item>, OrchardFallback>;
289+
/// [`VERIFIER_NU6_2`], and [`VERIFIER_NU6_3_ONWARD`] — so that batches, fallbacks, verifying
290+
/// keys, and caches are fully separated per circuit version. The Orchard verifier routing
291+
/// functions ([`orchard_v5_verifier_for`] / [`orchard_v6_verifier`]) return a borrow of the
292+
/// matching one.
293+
pub type VerifierService = Cached<BatchFallbackService>;
231294

232295
/// Builds a global Halo2 verifier that validates every item against `vk`.
233296
///
@@ -236,7 +299,19 @@ pub type VerifierService = Fallback<Batch<Verifier, Item>, OrchardFallback>;
236299
/// passed here, so an item built by this verifier is always checked against exactly one era's key.
237300
/// Callers select the correct era's key by which `VERIFYING_KEY_*` they pass (see the two statics
238301
/// below); there is no runtime key resolution.
239-
fn batch_verifier(vk: &'static ItemVerifyingKey) -> VerifierService {
302+
///
303+
/// The stack is wrapped in a [`Cached`] so that a proof verified when its transaction was
304+
/// gossiped into the mempool does not have to be verified again when the block that mines it
305+
/// arrives. Because each circuit version builds its own verifier here, each also gets its own
306+
/// cache, which is what binds a remembered result to the `vk` it was produced under.
307+
/// `verifier_name` is the era's `verifier` metrics label, so each era's cache reports its own hit
308+
/// rate.
309+
fn batch_verifier(vk: &'static ItemVerifyingKey, verifier_name: &'static str) -> VerifierService {
310+
Cached::new(batch_fallback_verifier(vk), CACHE_CAPACITY, verifier_name)
311+
}
312+
313+
/// Builds the uncached batching-and-fallback stack for `vk`.
314+
fn batch_fallback_verifier(vk: &'static ItemVerifyingKey) -> BatchFallbackService {
240315
Fallback::new(
241316
Batch::new(
242317
Verifier::new(vk),
@@ -257,7 +332,7 @@ fn batch_verifier(vk: &'static ItemVerifyingKey) -> VerifierService {
257332
/// Note that making a `Service` call requires mutable access to the service, so you should call
258333
/// `.clone()` on the global handle to create a local, mutable handle.
259334
pub static VERIFIER_PRE_NU6_2: Lazy<VerifierService> =
260-
Lazy::new(|| batch_verifier(&VERIFYING_KEY_PRE_NU6_2));
335+
Lazy::new(|| batch_verifier(&VERIFYING_KEY_PRE_NU6_2, "halo2_pre_nu6_2"));
261336

262337
/// Global batch verification context for **NU6.2-until-NU6.3** Halo2 Action proofs.
263338
///
@@ -268,7 +343,7 @@ pub static VERIFIER_PRE_NU6_2: Lazy<VerifierService> =
268343
/// Note that making a `Service` call requires mutable access to the service, so you should call
269344
/// `.clone()` on the global handle to create a local, mutable handle.
270345
pub static VERIFIER_NU6_2: Lazy<VerifierService> =
271-
Lazy::new(|| batch_verifier(&VERIFYING_KEY_NU6_2));
346+
Lazy::new(|| batch_verifier(&VERIFYING_KEY_NU6_2, "halo2_nu6_2"));
272347

273348
/// Global batch verification context for **NU6.3-onward** Halo2 Action proofs.
274349
///
@@ -281,7 +356,7 @@ pub static VERIFIER_NU6_2: Lazy<VerifierService> =
281356
/// Note that making a `Service` call requires mutable access to the service, so you should call
282357
/// `.clone()` on the global handle to create a local, mutable handle.
283358
pub static VERIFIER_NU6_3_ONWARD: Lazy<VerifierService> =
284-
Lazy::new(|| batch_verifier(&VERIFYING_KEY_NU6_3_ONWARD));
359+
Lazy::new(|| batch_verifier(&VERIFYING_KEY_NU6_3_ONWARD, "halo2_nu6_3_onward"));
285360

286361
/// Returns the global Halo2 verifier for the **Orchard-pool** bundle of a **v5** transaction in a
287362
/// block at `network_upgrade`.
@@ -337,6 +412,15 @@ pub fn orchard_v5_verifier_for(network_upgrade: NetworkUpgrade) -> &'static Veri
337412
}
338413
}
339414

415+
/// Returns how many times `item` has reached the inner Halo2 verifier of the circuit version
416+
/// `network_upgrade` routes v5 Orchard bundles to.
417+
///
418+
/// Test-only. See [`Cached::inner_calls_for`].
419+
#[cfg(test)]
420+
pub(crate) fn inner_calls_for(network_upgrade: NetworkUpgrade, item: &Item) -> usize {
421+
orchard_v5_verifier_for(network_upgrade).inner_calls_for(item)
422+
}
423+
340424
/// Returns the global Halo2 verifier for **v6** Orchard-pool and Ironwood-pool bundles.
341425
///
342426
/// v6 Orchard and Ironwood bundles only exist from NU6.3 onward, so they always use the NU6.3

0 commit comments

Comments
 (0)