From d828703d3c3513fe751ec63305c30ec505ad37d1 Mon Sep 17 00:00:00 2001 From: Quang Dao Date: Fri, 23 Oct 2026 17:40:39 -0400 Subject: [PATCH 1/2] fix(cache): serve prepared points only for the points they came from The setup multi-pairings took cached prepared points for any input no longer than the cache, without checking the input. A cache built from another setup gave wrong pairings, and an input longer than the cache panicked. The cache now keeps its source points. A lookup serves a prefix of them only, and the pairing otherwise prepares its input. init_cache replaces a cache that does not cover its input. The cache tests take a shared lock, and new tests pin the fallback, the replacement, and a proof that verifies after a foreign cache. --- CHANGELOG.md | 27 +++++++ src/backends/arkworks/ark_cache.rs | 61 +++++++++++---- src/backends/arkworks/ark_pairing.rs | 25 ++++--- tests/arkworks/cache.rs | 106 +++++++++++++++++++++++++++ 4 files changed, 192 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5552474..8ca9499 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- The prepared-point cache (`cache` feature) no longer pairs against the + wrong points. The setup multi-pairings used the cached prepared points + for any input of no greater length, without checking that the input was + the cached points. So a cache built from another setup, or from a larger + setup generated separately, gave wrong pairings. An input longer than + the cache panicked. The cache now keeps the points it prepared. It serves + an input only when the input is a prefix of those points, and otherwise + the pairing prepares the input itself. `init_cache` replaces a cache that + does not cover its input, instead of keeping any cache at least as large. + Under `cargo test`, the cache tests no longer break the proofs that later + tests verify in the same process. + +### Added + +- `PreparedCache::g1_prepared_for` and `PreparedCache::g2_prepared_for` + return the prepared form of a prefix of the cached points. + +### Changed + +- **Breaking:** `PreparedCache` has private fields, so code outside the + crate can no longer construct it with a struct literal. `init_cache` + builds it. + ## [0.4.2] - 2026-08-20 ### Changed diff --git a/src/backends/arkworks/ark_cache.rs b/src/backends/arkworks/ark_cache.rs index 550c365..6248388 100644 --- a/src/backends/arkworks/ark_cache.rs +++ b/src/backends/arkworks/ark_cache.rs @@ -4,8 +4,10 @@ //! across multiple pairing operations. Prepared points skip the affine conversion //! and preprocessing steps, providing ~20-30% speedup for repeated pairings. //! -//! The cache supports smart re-initialization: if a larger setup is needed, -//! the cache is automatically replaced. Smaller or equal setups reuse the existing cache. +//! The cache remembers the points it prepared. A lookup returns prepared +//! points only for a prefix of those points, so a stale cache, or one built +//! from another setup, falls back to preparing the caller's points instead +//! of pairing against the wrong ones. use super::ark_group::{ArkG1, ArkG2}; use ark_bn254::{Bn254, G1Affine, G2Affine}; @@ -20,18 +22,41 @@ pub struct PreparedCache { pub g1_prepared: Vec<::G1Prepared>, /// Prepared G2 points for efficient pairing operations pub g2_prepared: Vec<::G2Prepared>, + g1: Vec, + g2: Vec, +} + +impl PreparedCache { + fn covers(&self, g1: &[ArkG1], g2: &[ArkG2]) -> bool { + is_prefix(&self.g1, g1) && is_prefix(&self.g2, g2) + } + + /// The prepared form of `g1`, if `g1` is a prefix of the cached G1 points. + pub fn g1_prepared_for(&self, g1: &[ArkG1]) -> Option<&[::G1Prepared]> { + is_prefix(&self.g1, g1).then(|| &self.g1_prepared[..g1.len()]) + } + + /// The prepared form of `g2`, if `g2` is a prefix of the cached G2 points. + pub fn g2_prepared_for(&self, g2: &[ArkG2]) -> Option<&[::G2Prepared]> { + is_prefix(&self.g2, g2).then(|| &self.g2_prepared[..g2.len()]) + } +} + +/// Whether `points` is a prefix of `cached`. Comparing projective points +/// costs a few field multiplications each, far less than preparing them. +fn is_prefix(cached: &[P], points: &[P]) -> bool { + cached + .get(..points.len()) + .is_some_and(|cached| cached.par_iter().zip(points).all(|(a, b)| a == b)) } static CACHE: RwLock>> = RwLock::new(None); /// Initialize the global cache with G1 and G2 vectors. /// -/// This function implements smart re-initialization: -/// - If the cache doesn't exist, it creates one -/// - If the cache exists but is too small, it replaces it with a larger one -/// - If the cache exists and is large enough, it does nothing (reuses existing) -/// -/// This allows multiple proofs with different setup sizes to run in the same process. +/// If `g1_vec` and `g2_vec` are prefixes of the cached points, this does +/// nothing. Otherwise it replaces the cache, whether the cache is smaller or +/// was built from other points. /// /// # Arguments /// * `g1_vec` - Vector of G1 points to prepare and cache @@ -52,10 +77,11 @@ pub fn init_cache(g1_vec: &[ArkG1], g2_vec: &[ArkG2]) { // Fast path: check if existing cache is sufficient (read lock only) { let read_guard = CACHE.read().unwrap(); - if let Some(ref cache) = *read_guard { - if cache.g1_prepared.len() >= g1_vec.len() && cache.g2_prepared.len() >= g2_vec.len() { - return; // Existing cache is large enough - } + if read_guard + .as_ref() + .is_some_and(|cache| cache.covers(g1_vec, g2_vec)) + { + return; } } @@ -85,15 +111,18 @@ pub fn init_cache(g1_vec: &[ArkG1], g2_vec: &[ArkG2]) { let mut write_guard = CACHE.write().unwrap(); // Double-check after acquiring the write lock (another thread may have initialized). - if let Some(ref cache) = *write_guard { - if cache.g1_prepared.len() >= g1_vec.len() && cache.g2_prepared.len() >= g2_vec.len() { - return; - } + if write_guard + .as_ref() + .is_some_and(|cache| cache.covers(g1_vec, g2_vec)) + { + return; } *write_guard = Some(Arc::new(PreparedCache { g1_prepared, g2_prepared, + g1: g1_vec.to_vec(), + g2: g2_vec.to_vec(), })); } diff --git a/src/backends/arkworks/ark_pairing.rs b/src/backends/arkworks/ark_pairing.rs index 2304f06..53e2406 100644 --- a/src/backends/arkworks/ark_pairing.rs +++ b/src/backends/arkworks/ark_pairing.rs @@ -76,7 +76,9 @@ mod pairing_helpers { #[cfg(feature = "cache")] { if let Some(cache) = crate::backends::arkworks::ark_cache::get_prepared_cache() { - return multi_pair_with_prepared(ps_prep, &cache.g2_prepared[..qs.len()]); + if let Some(qs_prep) = cache.g2_prepared_for(qs) { + return multi_pair_with_prepared(ps_prep, qs_prep); + } } } @@ -108,12 +110,9 @@ mod pairing_helpers { #[cfg(feature = "cache")] { if let Some(cache) = crate::backends::arkworks::ark_cache::get_prepared_cache() { - let ps_prep: Vec<_> = ps - .iter() - .enumerate() - .map(|(i, _)| cache.g1_prepared[i].clone()) - .collect(); - return multi_pair_with_prepared(ps_prep, &qs_prep); + if let Some(ps_prep) = cache.g1_prepared_for(ps) { + return multi_pair_with_prepared(ps_prep.to_vec(), &qs_prep); + } } } @@ -190,6 +189,8 @@ mod pairing_helpers { #[cfg(feature = "cache")] let cache = crate::backends::arkworks::ark_cache::get_prepared_cache(); + #[cfg(feature = "cache")] + let cached_qs = cache.as_deref().and_then(|c| c.g2_prepared_for(qs)); let combined = ps .par_chunks(chunk_size) @@ -207,8 +208,8 @@ mod pairing_helpers { .collect(); #[cfg(feature = "cache")] - let qs_prep: Vec<::G2Prepared> = if let Some(ref c) = cache { - c.g2_prepared[start_idx..end_idx].to_vec() + let qs_prep: Vec<::G2Prepared> = if let Some(cached) = cached_qs { + cached[start_idx..end_idx].to_vec() } else { use ark_bn254::G2Affine; qs[start_idx..end_idx] @@ -254,6 +255,8 @@ mod pairing_helpers { #[cfg(feature = "cache")] let cache = crate::backends::arkworks::ark_cache::get_prepared_cache(); + #[cfg(feature = "cache")] + let cached_ps = cache.as_deref().and_then(|c| c.g1_prepared_for(ps)); let combined = qs .par_chunks(chunk_size) @@ -271,8 +274,8 @@ mod pairing_helpers { .collect(); #[cfg(feature = "cache")] - let ps_prep: Vec<::G1Prepared> = if let Some(ref c) = cache { - c.g1_prepared[start_idx..end_idx].to_vec() + let ps_prep: Vec<::G1Prepared> = if let Some(cached) = cached_ps { + cached[start_idx..end_idx].to_vec() } else { use ark_bn254::G1Affine; ps[start_idx..end_idx] diff --git a/tests/arkworks/cache.rs b/tests/arkworks/cache.rs index e66f866..3284118 100644 --- a/tests/arkworks/cache.rs +++ b/tests/arkworks/cache.rs @@ -4,6 +4,33 @@ use dory_pcs::primitives::arithmetic::{Group, PairingCurve}; #[cfg(feature = "cache")] use dory_pcs::backends::arkworks::ark_cache; +/// Serializes the tests that write the global prepared-point cache, so +/// their assertions hold when tests share a process (`cargo test`). +#[cfg(feature = "cache")] +static CACHE_WRITERS: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(feature = "cache")] +fn lock_cache() -> std::sync::MutexGuard<'static, ()> { + CACHE_WRITERS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(feature = "cache")] +fn random_points(n: usize) -> (Vec, Vec) { + ( + (0..n).map(|_| ArkG1::random()).collect(), + (0..n).map(|_| ArkG2::random()).collect(), + ) +} + +#[cfg(feature = "cache")] +fn pair_naive(ps: &[ArkG1], qs: &[ArkG2]) -> ArkGT { + ps.iter() + .zip(qs) + .fold(ArkGT::identity(), |acc, (p, q)| acc.add(&BN254::pair(p, q))) +} + #[test] fn multi_pair_correctness() { let n = 10; @@ -42,6 +69,7 @@ fn multi_pair_length_mismatch() { #[cfg(feature = "cache")] #[test] fn cache_initialization() { + let _guard = lock_cache(); let g1_vec: Vec = (0..10).map(|_| ArkG1::random()).collect(); let g2_vec: Vec = (0..10).map(|_| ArkG2::random()).collect(); @@ -56,6 +84,7 @@ fn cache_initialization() { #[cfg(feature = "cache")] #[test] fn cache_smart_reinit() { + let _guard = lock_cache(); // Initialize with small size let g1_small: Vec = (0..5).map(|_| ArkG1::random()).collect(); let g2_small: Vec = (0..5).map(|_| ArkG2::random()).collect(); @@ -82,6 +111,7 @@ fn cache_smart_reinit() { #[cfg(feature = "cache")] #[test] fn multi_pair_with_cache_optimization() { + let _guard = lock_cache(); let n = 20; let g1_vec: Vec = (0..n).map(|_| ArkG1::random()).collect(); @@ -103,3 +133,79 @@ fn multi_pair_with_cache_optimization() { assert_eq!(result, expected); } + +/// A cache built from other points must not change setup pairings, whether +/// the caller's points are shorter or longer than the cache. +#[cfg(feature = "cache")] +#[test] +fn foreign_cache_falls_back() { + let _guard = lock_cache(); + let (cached_g1, cached_g2) = random_points(20); + ark_cache::init_cache(&cached_g1, &cached_g2); + + for n in [10, 30] { + let (ps, qs) = random_points(n); + let expected = pair_naive(&ps, &qs); + assert_eq!(BN254::multi_pair_g2_setup(&ps, &qs), expected, "n={n}"); + assert_eq!(BN254::multi_pair_g1_setup(&ps, &qs), expected, "n={n}"); + } +} + +/// Re-initializing with other points of no larger size replaces the cache. +#[cfg(feature = "cache")] +#[test] +fn reinit_with_other_points_replaces() { + let _guard = lock_cache(); + let (large_g1, large_g2) = random_points(20); + ark_cache::init_cache(&large_g1, &large_g2); + + let (small_g1, small_g2) = random_points(10); + ark_cache::init_cache(&small_g1, &small_g2); + + let cache = ark_cache::get_prepared_cache().unwrap(); + assert!(cache.g1_prepared_for(&small_g1).is_some()); + assert!(cache.g2_prepared_for(&small_g2).is_some()); + assert!(cache.g1_prepared_for(&large_g1).is_none()); + assert!(cache.g2_prepared_for(&large_g2).is_none()); +} + +/// A proof verifies after the cache was filled with another setup's points. +#[cfg(feature = "cache")] +#[test] +fn proof_verifies_after_foreign_cache() { + use super::*; + use dory_pcs::primitives::poly::Polynomial; + use dory_pcs::{prove, verify, Transparent}; + + let _guard = lock_cache(); + let (cached_g1, cached_g2) = random_points(64); + ark_cache::init_cache(&cached_g1, &cached_g2); + + let (prover_setup, verifier_setup) = test_setup_pair(8); + let (nu, sigma) = (4, 4); + let poly = random_polynomial(1 << (nu + sigma)); + let point = random_point(nu + sigma); + let (commitment, row_commitments, commit_blind) = poly + .commit::(nu, sigma, &prover_setup) + .unwrap(); + let (proof, _) = prove::<_, BN254, TestG1Routines, TestG2Routines, _, _, Transparent>( + &poly, + &point, + row_commitments, + commit_blind, + nu, + sigma, + &prover_setup, + &mut fresh_transcript(), + ) + .unwrap(); + verify::<_, BN254, TestG1Routines, TestG2Routines, _>( + commitment, + poly.evaluate(&point), + &point, + &proof, + verifier_setup, + &mut fresh_transcript(), + ) + .unwrap(); +} From be4064a1689953aabf8ae4e08004b5e9e52ff346 Mon Sep 17 00:00:00 2001 From: Quang Dao Date: Sun, 25 Oct 2026 18:24:04 -0700 Subject: [PATCH 2/2] fix(cache): make the prepared-point vectors private g1_prepared and g2_prepared were still public, so downstream code could index them without the prefix check that g1_prepared_for and g2_prepared_for apply. Make them private and add g1_len and g2_len for callers that only need the cache size. Add tests that pair prefixes of the cached points (the cache-hit path) against a naive pairing, and that offset or altered point sets miss the cache and still pair correctly. --- CHANGELOG.md | 11 +++- src/backends/arkworks/ark_cache.rs | 23 ++++++-- tests/arkworks/cache.rs | 89 ++++++++++++++++++++++++++++-- 3 files changed, 109 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca9499..d1e7803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,12 +25,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `PreparedCache::g1_prepared_for` and `PreparedCache::g2_prepared_for` return the prepared form of a prefix of the cached points. +- `PreparedCache::g1_len` and `PreparedCache::g2_len` return the number of + cached points. ### Changed -- **Breaking:** `PreparedCache` has private fields, so code outside the - crate can no longer construct it with a struct literal. `init_cache` - builds it. +- **Breaking:** all of `PreparedCache`'s fields are private, including + `g1_prepared` and `g2_prepared`. Read prepared points through + `g1_prepared_for` and `g2_prepared_for`, which check that the input is a + prefix of the cached points, and read the cache size through `g1_len` and + `g2_len`. Code outside the crate can no longer construct a + `PreparedCache` with a struct literal. `init_cache` builds it. ## [0.4.2] - 2026-08-20 diff --git a/src/backends/arkworks/ark_cache.rs b/src/backends/arkworks/ark_cache.rs index 6248388..59aa972 100644 --- a/src/backends/arkworks/ark_cache.rs +++ b/src/backends/arkworks/ark_cache.rs @@ -15,13 +15,16 @@ use ark_ec::pairing::Pairing; use rayon::prelude::*; use std::sync::{Arc, RwLock}; -/// Global cache for prepared points +/// Global cache for prepared points. +/// +/// The prepared points are reachable only through +/// [`g1_prepared_for`](Self::g1_prepared_for) and +/// [`g2_prepared_for`](Self::g2_prepared_for), which check that the caller's +/// points are the ones that were prepared. #[derive(Debug, Clone)] pub struct PreparedCache { - /// Prepared G1 points for efficient pairing operations - pub g1_prepared: Vec<::G1Prepared>, - /// Prepared G2 points for efficient pairing operations - pub g2_prepared: Vec<::G2Prepared>, + g1_prepared: Vec<::G1Prepared>, + g2_prepared: Vec<::G2Prepared>, g1: Vec, g2: Vec, } @@ -31,6 +34,16 @@ impl PreparedCache { is_prefix(&self.g1, g1) && is_prefix(&self.g2, g2) } + /// The number of cached G1 points. + pub fn g1_len(&self) -> usize { + self.g1.len() + } + + /// The number of cached G2 points. + pub fn g2_len(&self) -> usize { + self.g2.len() + } + /// The prepared form of `g1`, if `g1` is a prefix of the cached G1 points. pub fn g1_prepared_for(&self, g1: &[ArkG1]) -> Option<&[::G1Prepared]> { is_prefix(&self.g1, g1).then(|| &self.g1_prepared[..g1.len()]) diff --git a/tests/arkworks/cache.rs b/tests/arkworks/cache.rs index 3284118..cad3148 100644 --- a/tests/arkworks/cache.rs +++ b/tests/arkworks/cache.rs @@ -24,6 +24,21 @@ fn random_points(n: usize) -> (Vec, Vec) { ) } +/// Fills the cache with `g1`/`g2` and returns a snapshot of it. Other tests +/// in this binary may invalidate the cache concurrently (for example through +/// `setup()`), so retry until the snapshot is ours. +#[cfg(feature = "cache")] +fn cache_of(g1: &[ArkG1], g2: &[ArkG2]) -> std::sync::Arc { + loop { + ark_cache::init_cache(g1, g2); + if let Some(cache) = ark_cache::get_prepared_cache() { + if cache.g1_prepared_for(g1).is_some() && cache.g2_prepared_for(g2).is_some() { + return cache; + } + } + } +} + #[cfg(feature = "cache")] fn pair_naive(ps: &[ArkG1], qs: &[ArkG2]) -> ArkGT { ps.iter() @@ -77,8 +92,8 @@ fn cache_initialization() { assert!(ark_cache::is_cached()); let cache = ark_cache::get_prepared_cache().unwrap(); - assert_eq!(cache.g1_prepared.len(), 10); - assert_eq!(cache.g2_prepared.len(), 10); + assert_eq!(cache.g1_len(), 10); + assert_eq!(cache.g2_len(), 10); } #[cfg(feature = "cache")] @@ -91,12 +106,12 @@ fn cache_smart_reinit() { ark_cache::init_cache(&g1_small, &g2_small); let cache = ark_cache::get_prepared_cache().unwrap(); - let small_len = cache.g1_prepared.len(); + let small_len = cache.g1_len(); // Re-init with same size — should be a no-op (reuses existing) ark_cache::init_cache(&g1_small, &g2_small); let cache = ark_cache::get_prepared_cache().unwrap(); - assert_eq!(cache.g1_prepared.len(), small_len); + assert_eq!(cache.g1_len(), small_len); // Re-init with larger size — should replace cache let g1_large: Vec = (0..20).map(|_| ArkG1::random()).collect(); @@ -104,8 +119,8 @@ fn cache_smart_reinit() { ark_cache::init_cache(&g1_large, &g2_large); let cache = ark_cache::get_prepared_cache().unwrap(); - assert_eq!(cache.g1_prepared.len(), 20); - assert_eq!(cache.g2_prepared.len(), 20); + assert_eq!(cache.g1_len(), 20); + assert_eq!(cache.g2_len(), 20); } #[cfg(feature = "cache")] @@ -151,6 +166,68 @@ fn foreign_cache_falls_back() { } } +/// Setup pairings on prefixes of the cached points take the cache-hit path +/// and agree with a naive pairing. +#[cfg(feature = "cache")] +#[test] +fn cache_hit_matches_naive_pairing() { + let _guard = lock_cache(); + let n = 16; + let (cached_g1, cached_g2) = random_points(n); + let cache = cache_of(&cached_g1, &cached_g2); + + for k in [1, n / 2, n] { + let (ps, qs) = random_points(k); + let (setup_g1, setup_g2) = (&cached_g1[..k], &cached_g2[..k]); + assert!(cache.g1_prepared_for(setup_g1).is_some(), "k={k}"); + assert!(cache.g2_prepared_for(setup_g2).is_some(), "k={k}"); + assert_eq!( + BN254::multi_pair_g2_setup(&ps, setup_g2), + pair_naive(&ps, setup_g2), + "g2 k={k}" + ); + assert_eq!( + BN254::multi_pair_g1_setup(setup_g1, &qs), + pair_naive(setup_g1, &qs), + "g1 k={k}" + ); + } +} + +/// Points drawn from the cached set but not as a prefix (an offset slice, or +/// a prefix with its last point changed) miss the cache and still pair +/// correctly. +#[cfg(feature = "cache")] +#[test] +fn non_prefix_points_fall_back() { + let _guard = lock_cache(); + let n = 16; + let (cached_g1, cached_g2) = random_points(n); + let cache = cache_of(&cached_g1, &cached_g2); + + let k = n / 2; + let (ps, qs) = random_points(k); + let offset_g1 = &cached_g1[1..=k]; + let offset_g2 = &cached_g2[1..=k]; + let mut changed_g1 = cached_g1[..k].to_vec(); + let mut changed_g2 = cached_g2[..k].to_vec(); + changed_g1[k - 1] = ArkG1::random(); + changed_g2[k - 1] = ArkG2::random(); + + for (setup_g1, setup_g2) in [(offset_g1, offset_g2), (&changed_g1[..], &changed_g2[..])] { + assert!(cache.g1_prepared_for(setup_g1).is_none()); + assert!(cache.g2_prepared_for(setup_g2).is_none()); + assert_eq!( + BN254::multi_pair_g2_setup(&ps, setup_g2), + pair_naive(&ps, setup_g2) + ); + assert_eq!( + BN254::multi_pair_g1_setup(setup_g1, &qs), + pair_naive(setup_g1, &qs) + ); + } +} + /// Re-initializing with other points of no larger size replaces the cache. #[cfg(feature = "cache")] #[test]