diff --git a/CHANGELOG.md b/CHANGELOG.md index d1fdb51..9a1fc89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 process IDs can repeat) never write the same temporary file. A write that fails removes its temporary file. On Unix, the directory is synced after the file is published, so the published file survives a power loss. +- 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. +- `PreparedCache::g1_len` and `PreparedCache::g2_len` return the number of + cached points. + +### Changed + +- **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 550c365..59aa972 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}; @@ -13,25 +15,61 @@ 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, +} + +impl PreparedCache { + fn covers(&self, g1: &[ArkG1], g2: &[ArkG2]) -> bool { + 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()]) + } + + /// 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 +90,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 +124,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..cad3148 100644 --- a/tests/arkworks/cache.rs +++ b/tests/arkworks/cache.rs @@ -4,6 +4,48 @@ 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(), + ) +} + +/// 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() + .zip(qs) + .fold(ArkGT::identity(), |acc, (p, q)| acc.add(&BN254::pair(p, q))) +} + #[test] fn multi_pair_correctness() { let n = 10; @@ -42,6 +84,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(); @@ -49,25 +92,26 @@ 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")] #[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(); 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(); @@ -75,13 +119,14 @@ 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")] #[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 +148,141 @@ 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}"); + } +} + +/// 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] +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(); +}