Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
project: zebra-consensus
kind: Changed
body: 'Orchard and Sapling bundle verification results are cached, so a proof or signature verified when its transaction entered the mempool is not verified again when the block that mines it arrives. `halo2::VerifierService` now names a caching wrapper around the batch-and-fallback stack, and each cache reports hits, misses, inserts, evictions and size under `zebra.consensus.cache.*` ([#11380](https://github.com/ZcashFoundation/zebra/pull/11380)).'
time: 2026-09-02T12:00:00.000000000Z
1 change: 1 addition & 0 deletions zebra-consensus/src/primitives.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use tokio::sync::oneshot::error::RecvError;

use crate::BoxError;

mod cache;
pub mod ed25519;
pub mod groth16;
pub mod halo2;
Expand Down
483 changes: 483 additions & 0 deletions zebra-consensus/src/primitives/cache.rs

Large diffs are not rendered by default.

140 changes: 140 additions & 0 deletions zebra-consensus/src/primitives/cache/tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
//! Tests for the shared verification cache key and the bounded store behind it.
//!
//! The verifiers pin their own key construction over real bundles. These cover the parts that are
//! the same for all of them: which of the key's three components separate two entries, and the
//! store's capacity and eviction.

use zebra_chain::{
serialization::BytesInDisplayOrder,
transaction::{AuthDigest, Hash, UnminedTxId, WtxId},
};

use super::{CacheKey, ShieldedPool, VerifiedBundles};

/// Returns a transaction ID with no witness, as a v1-v4 transaction has.
fn legacy_tx_id(tag: u8) -> UnminedTxId {
UnminedTxId::Legacy(Hash::from_bytes_in_display_order(&[tag; 32]))
}

/// Returns a witnessed transaction ID, as a v5 or v6 transaction has.
fn witnessed_tx_id(txid_tag: u8, auth_digest_tag: u8) -> UnminedTxId {
UnminedTxId::Witnessed(WtxId {
id: Hash::from_bytes_in_display_order(&[txid_tag; 32]),
auth_digest: AuthDigest::from_bytes_in_display_order(&[auth_digest_tag; 32]),
})
}

/// The pool separates the bundles a v6 transaction carries under one ID and one sighash.
///
/// The Orchard and Ironwood caches are one cache from NU6.3 onward, so this is what keeps their
/// entries apart there.
#[test]
fn keys_for_the_same_transaction_differ_by_pool() {
let tx_id = witnessed_tx_id(1, 2);
let sighash = [3; 32];

let keys = [
CacheKey::new(tx_id, sighash, ShieldedPool::Sapling),
CacheKey::new(tx_id, sighash, ShieldedPool::Orchard),
CacheKey::new(tx_id, sighash, ShieldedPool::Ironwood),
];

let unique: std::collections::HashSet<_> = keys.iter().collect();
assert_eq!(
unique.len(),
keys.len(),
"one transaction's three shielded bundles must not share a cache key"
);
}

/// The authorizing-data digest separates two witnessed IDs that share a txid.
///
/// Under ZIP 244 a v5 transaction's txid excludes its proofs and signatures, so a key that named
/// only the txid would answer both of these with one verification. That is CVE-2026-34377.
#[test]
fn witnessed_keys_differ_by_authorizing_data() {
let sighash = [3; 32];

assert_ne!(
CacheKey::new(witnessed_tx_id(1, 2), sighash, ShieldedPool::Orchard),
CacheKey::new(witnessed_tx_id(1, 4), sighash, ShieldedPool::Orchard),
"the same txid with different authorizing data must not share a cache key"
);
}

/// The sighash separates two verifications of one transaction's bundle.
///
/// The sighash is not a function of the transaction alone: the amounts and scripts of the spent
/// transparent outputs enter it, and those come from the verification context.
#[test]
fn keys_differ_by_sighash() {
let tx_id = witnessed_tx_id(1, 2);

assert_ne!(
CacheKey::new(tx_id, [3; 32], ShieldedPool::Sapling),
CacheKey::new(tx_id, [4; 32], ShieldedPool::Sapling),
"the sighash is an input to verification, so it must be an input to the key"
);
}

/// A legacy ID and a witnessed ID never collide, whatever they contain.
#[test]
fn legacy_and_witnessed_keys_differ() {
let sighash = [3; 32];

assert_ne!(
CacheKey::new(legacy_tx_id(1), sighash, ShieldedPool::Sapling),
CacheKey::new(witnessed_tx_id(1, 1), sighash, ShieldedPool::Sapling),
"a v4 transaction ID must not collide with a witnessed one"
);
}

/// Every Orchard value pool has a distinct tag.
#[test]
fn orchard_value_pools_map_to_distinct_tags() {
assert_eq!(
ShieldedPool::from(orchard::ValuePool::Orchard),
ShieldedPool::Orchard
);
assert_eq!(
ShieldedPool::from(orchard::ValuePool::Ironwood),
ShieldedPool::Ironwood
);
}

/// The lookup set and the eviction queue always hold the same keys.
///
/// They are two representations of one fact. A path that updated one without the other would
/// either drop a key that `contains` still answers — remembering a bundle for the rest of the
/// process — or grow the queue past the capacity it was built with.
#[test]
fn the_lookup_set_and_the_eviction_queue_hold_the_same_keys() {
let mut verified = VerifiedBundles::new(2);
let keys = [
CacheKey::new(legacy_tx_id(1), [0; 32], ShieldedPool::Sapling),
CacheKey::new(legacy_tx_id(2), [0; 32], ShieldedPool::Sapling),
CacheKey::new(legacy_tx_id(3), [0; 32], ShieldedPool::Sapling),
];

for key in keys {
verified.insert(key);
assert_eq!(
verified.keys.len(),
verified.insertion_order.len(),
"the lookup set and the eviction queue must hold the same keys"
);
assert!(verified.keys.len() <= 2, "the capacity bounds the cache");
}
assert!(
!verified.contains(&keys[0]),
"the oldest key must be evicted"
);

let repeated = verified.insert(keys[2]);
assert!(!repeated.inserted, "a concurrent duplicate is not recorded");
assert_eq!(repeated.evicted, 0, "a duplicate must not evict anything");
assert_eq!(verified.keys.len(), verified.insertion_order.len());

verified.clear();
assert!(verified.keys.is_empty() && verified.insertion_order.is_empty());
}
112 changes: 98 additions & 14 deletions zebra-consensus/src/primitives/halo2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ use orchard::{
};
use rand::thread_rng;
use zcash_protocol::value::ZatBalance;
use zebra_chain::{parameters::NetworkUpgrade, transaction::SigHash};
use zebra_chain::{
parameters::NetworkUpgrade,
transaction::{SigHash, UnminedTxId, WtxId},
};

use crate::{error::TransactionError, BoxError};
use thiserror::Error;
Expand All @@ -26,7 +29,10 @@ use tower::Service;
use tower_batch_control::{Batch, BatchControl, RequestWeight};
use tower_fallback::Fallback;

use super::spawn_fifo;
use super::{
cache::{CacheKey, Cached, CachedItem, ShieldedPool, CACHE_CAPACITY},
spawn_fifo,
};

#[cfg(test)]
mod tests;
Expand Down Expand Up @@ -110,16 +116,19 @@ lazy_static::lazy_static! {

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

impl RequestWeight for Item {
Expand All @@ -130,13 +139,40 @@ impl RequestWeight for Item {

impl Item {
/// Creates a new [`Item`] from a bundle and sighash.
///
/// Items constructed without their transaction's [`WtxId`] are verified normally but are not
/// cached. The transaction verifier supplies the witnessed transaction ID through a
/// crate-private constructor so its items can reuse successful results.
pub fn new(
bundle: orchard::bundle::Bundle<orchard::bundle::Authorized, ZatBalance>,
sighash: SigHash,
) -> Self {
Self {
bundle: Arc::new(bundle),
sighash,
cache_key: None,
}
}

/// Creates a cacheable item using its already-computed witnessed transaction ID.
///
/// `wtx_id` must identify the transaction containing `bundle`. The transaction verifier
/// passes the ID it derived from its own request, whose caller must preserve this invariant.
pub(crate) fn new_with_wtx_id(
bundle: orchard::bundle::Bundle<orchard::bundle::Authorized, ZatBalance>,
sighash: SigHash,
wtx_id: WtxId,
) -> Self {
let pool = ShieldedPool::from(bundle.bundle_version().value_pool());

Self {
bundle: Arc::new(bundle),
sighash,
cache_key: Some(CacheKey::new(
UnminedTxId::Witnessed(wtx_id),
sighash.0,
pool,
)),
}
}

Expand All @@ -156,12 +192,35 @@ impl Item {
}
}

impl CachedItem for Item {
/// Returns this item's cache key, if it was constructed with a witnessed transaction ID.
///
/// [`WtxId`] commits to the transaction's effecting and authorizing data. The sighash
/// additionally commits to the amounts and scripts of spent transparent outputs, which are
/// supplied by the verification context and are not part of the `WtxId`. The pool selects one
/// of the two Orchard-shaped bundles a v6 transaction can carry. The verifying key is absent
/// on purpose: each Orchard circuit era has its own cache, so an entry is only read back
/// under the key it was written against (see [`orchard_v5_verifier_for`]).
///
/// The txid alone is insufficient because it excludes authorizing data under ZIP 244
/// (CVE-2026-34377). The pool is also required because both bundles in a v6 transaction share
/// the same [`WtxId`].
fn cache_key(&self) -> Option<CacheKey> {
self.cache_key
}
}

trait QueueBatchVerify {
fn queue(&mut self, item: Item) -> Result<(), orchard::bundle::BatchError>;
}

impl QueueBatchVerify for BatchValidator<'_> {
fn queue(&mut self, Item { bundle, sighash }: Item) -> Result<(), orchard::bundle::BatchError> {
fn queue(
&mut self,
Item {
bundle, sighash, ..
}: Item,
) -> Result<(), orchard::bundle::BatchError> {
self.add_bundle(bundle.as_ref(), sighash.0)
}
}
Expand Down Expand Up @@ -221,13 +280,17 @@ impl Service<Item> for OrchardFallback {
}
}

/// The batching-and-fallback stack for one Orchard circuit version, before caching.
type BatchFallbackService = Fallback<Batch<Verifier, Item>, OrchardFallback>;

/// The concrete type of a global Halo2 verification service.
///
/// Each Orchard circuit version gets its own instance — see [`VERIFIER_PRE_NU6_2`],
/// [`VERIFIER_NU6_2`], and [`VERIFIER_NU6_3_ONWARD`] — so that batches, fallbacks, and verifying
/// keys are fully separated per circuit version. The Orchard verifier routing functions
/// ([`orchard_v5_verifier_for`] / [`orchard_v6_verifier`]) return a borrow of the matching one.
pub type VerifierService = Fallback<Batch<Verifier, Item>, OrchardFallback>;
/// [`VERIFIER_NU6_2`], and [`VERIFIER_NU6_3_ONWARD`] — so that batches, fallbacks, verifying
/// keys, and caches are fully separated per circuit version. The Orchard verifier routing
/// functions ([`orchard_v5_verifier_for`] / [`orchard_v6_verifier`]) return a borrow of the
/// matching one.
pub type VerifierService = Cached<BatchFallbackService>;

/// Builds a global Halo2 verifier that validates every item against `vk`.
///
Expand All @@ -236,7 +299,19 @@ pub type VerifierService = Fallback<Batch<Verifier, Item>, OrchardFallback>;
/// passed here, so an item built by this verifier is always checked against exactly one era's key.
/// Callers select the correct era's key by which `VERIFYING_KEY_*` they pass (see the two statics
/// below); there is no runtime key resolution.
fn batch_verifier(vk: &'static ItemVerifyingKey) -> VerifierService {
///
/// The stack is wrapped in a [`Cached`] so that a proof verified when its transaction was
/// gossiped into the mempool does not have to be verified again when the block that mines it
/// arrives. Because each circuit version builds its own verifier here, each also gets its own
/// cache, which is what binds a remembered result to the `vk` it was produced under.
/// `verifier_name` is the era's `verifier` metrics label, so each era's cache reports its own hit
/// rate.
fn batch_verifier(vk: &'static ItemVerifyingKey, verifier_name: &'static str) -> VerifierService {
Cached::new(batch_fallback_verifier(vk), CACHE_CAPACITY, verifier_name)
}

/// Builds the uncached batching-and-fallback stack for `vk`.
fn batch_fallback_verifier(vk: &'static ItemVerifyingKey) -> BatchFallbackService {
Fallback::new(
Batch::new(
Verifier::new(vk),
Expand All @@ -257,7 +332,7 @@ fn batch_verifier(vk: &'static ItemVerifyingKey) -> VerifierService {
/// Note that making a `Service` call requires mutable access to the service, so you should call
/// `.clone()` on the global handle to create a local, mutable handle.
pub static VERIFIER_PRE_NU6_2: Lazy<VerifierService> =
Lazy::new(|| batch_verifier(&VERIFYING_KEY_PRE_NU6_2));
Lazy::new(|| batch_verifier(&VERIFYING_KEY_PRE_NU6_2, "halo2_pre_nu6_2"));

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

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

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

/// Returns how many times `item` has reached the inner Halo2 verifier of the circuit version
/// `network_upgrade` routes v5 Orchard bundles to.
///
/// Test-only. See [`Cached::inner_calls_for`].
#[cfg(test)]
pub(crate) fn inner_calls_for(network_upgrade: NetworkUpgrade, item: &Item) -> usize {
orchard_v5_verifier_for(network_upgrade).inner_calls_for(item)
}

/// Returns the global Halo2 verifier for **v6** Orchard-pool and Ironwood-pool bundles.
///
/// v6 Orchard and Ironwood bundles only exist from NU6.3 onward, so they always use the NU6.3
Expand Down
Loading