diff --git a/Cargo.lock b/Cargo.lock index 066b4eca67..6d5c32336b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2788,12 +2788,15 @@ dependencies = [ name = "jolt-dory" version = "0.1.0" dependencies = [ + "ark-bn254", + "ark-ec", "ark-serialize 0.5.0", "criterion", "dory-pcs", "jolt-crypto", "jolt-field", "jolt-openings", + "jolt-optimizations", "jolt-poly", "jolt-transcript", "rand_chacha 0.3.1", diff --git a/crates/jolt-crypto/src/ec/bn254/gt.rs b/crates/jolt-crypto/src/ec/bn254/gt.rs index befa42df4c..eea76efe45 100644 --- a/crates/jolt-crypto/src/ec/bn254/gt.rs +++ b/crates/jolt-crypto/src/ec/bn254/gt.rs @@ -1,9 +1,14 @@ -use std::fmt::Debug; +use std::fmt::{self, Debug}; use std::ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub, SubAssign}; use ark_bn254::{Fq12, Fr}; use ark_ff::{AdditiveGroup, Field as ArkField, PrimeField}; +use ark_serialize::{ + CanonicalDeserialize, CanonicalSerialize, Compress, Read, SerializationError, Valid, Validate, + Write, +}; use jolt_field::Field; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use jolt_transcript::{AppendToTranscript, Transcript}; @@ -32,7 +37,7 @@ use super::field_to_fr; pub struct Bn254GT(pub(crate) Fq12); impl Debug for Bn254GT { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_tuple("Bn254GT").field(&self.0).finish() } } @@ -131,7 +136,6 @@ impl MulAssign for Bn254GT { #[expect(clippy::expect_used)] impl AppendToTranscript for Bn254GT { fn append_to_transcript(&self, transcript: &mut T) { - use ark_serialize::CanonicalSerialize; let mut buf = Vec::with_capacity(self.0.uncompressed_size()); self.0 .serialize_uncompressed(&mut buf) @@ -177,9 +181,8 @@ impl JoltGroup for Bn254GT { } } -impl serde::Serialize for Bn254GT { - fn serialize(&self, serializer: S) -> Result { - use ark_serialize::CanonicalSerialize; +impl Serialize for Bn254GT { + fn serialize(&self, serializer: S) -> Result { let mut buf = Vec::with_capacity(self.0.compressed_size()); self.0 .serialize_compressed(&mut buf) @@ -188,9 +191,8 @@ impl serde::Serialize for Bn254GT { } } -impl<'de> serde::Deserialize<'de> for Bn254GT { - fn deserialize>(deserializer: D) -> Result { - use ark_serialize::CanonicalDeserialize; +impl<'de> Deserialize<'de> for Bn254GT { + fn deserialize>(deserializer: D) -> Result { let buf = >::deserialize(deserializer)?; let inner = Fq12::deserialize_compressed(&buf[..]).map_err(serde::de::Error::custom)?; // Reject Fq12::ZERO: not in any multiplicative subgroup, and later @@ -209,3 +211,33 @@ impl<'de> serde::Deserialize<'de> for Bn254GT { Ok(Self(inner)) } } + +impl CanonicalSerialize for Bn254GT { + fn serialize_with_mode( + &self, + writer: W, + compress: Compress, + ) -> Result<(), SerializationError> { + self.0.serialize_with_mode(writer, compress) + } + + fn serialized_size(&self, compress: Compress) -> usize { + self.0.serialized_size(compress) + } +} + +impl Valid for Bn254GT { + fn check(&self) -> Result<(), SerializationError> { + self.0.check() + } +} + +impl CanonicalDeserialize for Bn254GT { + fn deserialize_with_mode( + reader: R, + compress: Compress, + validate: Validate, + ) -> Result { + Fq12::deserialize_with_mode(reader, compress, validate).map(Self) + } +} diff --git a/crates/jolt-crypto/src/ec/bn254/mod.rs b/crates/jolt-crypto/src/ec/bn254/mod.rs index 29ccc507ab..587a6d65c4 100644 --- a/crates/jolt-crypto/src/ec/bn254/mod.rs +++ b/crates/jolt-crypto/src/ec/bn254/mod.rs @@ -129,6 +129,36 @@ macro_rules! impl_jolt_group_wrapper { } } + impl ::ark_serialize::CanonicalSerialize for $wrapper { + fn serialize_with_mode( + &self, + writer: W, + compress: ::ark_serialize::Compress, + ) -> Result<(), ::ark_serialize::SerializationError> { + self.0.serialize_with_mode(writer, compress) + } + + fn serialized_size(&self, compress: ::ark_serialize::Compress) -> usize { + self.0.serialized_size(compress) + } + } + + impl ::ark_serialize::Valid for $wrapper { + fn check(&self) -> Result<(), ::ark_serialize::SerializationError> { + self.0.check() + } + } + + impl ::ark_serialize::CanonicalDeserialize for $wrapper { + fn deserialize_with_mode( + reader: R, + compress: ::ark_serialize::Compress, + validate: ::ark_serialize::Validate, + ) -> Result { + <$projective>::deserialize_with_mode(reader, compress, validate).map(Self) + } + } + impl ::jolt_transcript::AppendToTranscript for $wrapper { fn append_to_transcript(&self, transcript: &mut T) { use ::ark_serialize::CanonicalSerialize; @@ -194,11 +224,13 @@ pub use g1::Bn254G1; pub use g2::Bn254G2; pub use gt::Bn254GT; -use ark_bn254::Bn254 as ArkBn254; +use ark_bn254::{Bn254 as ArkBn254, Fr as ArkFr, G1Affine, G1Projective, G2Affine, G2Projective}; use ark_ec::pairing::Pairing; +use ark_ec::AffineRepr; use ark_ec::CurveGroup; use ark_ff::PrimeField as _; -use jolt_field::Field; +use ark_std::UniformRand; +use jolt_field::{Field, Fr as JoltFr}; use crate::PairingGroup; @@ -209,25 +241,22 @@ pub struct Bn254; impl Bn254 { /// Standard G1 generator. Useful for tests and PCS setup code. pub fn g1_generator() -> Bn254G1 { - use ark_ec::AffineRepr; - Bn254G1(ark_bn254::G1Affine::generator().into()) + Bn254G1(G1Affine::generator().into()) } /// Standard G2 generator. Useful for tests and PCS setup code. pub fn g2_generator() -> Bn254G2 { - use ark_ec::AffineRepr; - Bn254G2(ark_bn254::G2Affine::generator().into()) + Bn254G2(G2Affine::generator().into()) } /// Samples a uniformly random G1 element. pub fn random_g1(rng: &mut R) -> Bn254G1 { - use ark_std::UniformRand; - Bn254G1(ark_bn254::G1Projective::rand(rng)) + Bn254G1(G1Projective::rand(rng)) } } impl PairingGroup for Bn254 { - type ScalarField = jolt_field::Fr; + type ScalarField = JoltFr; type G1 = Bn254G1; type G2 = Bn254G2; type GT = Bn254GT; @@ -241,10 +270,10 @@ impl PairingGroup for Bn254 { // Batched projective → affine normalization (one inversion for all points) // is 10-100× faster than per-point `into_affine` for typical Dory/KZG verifier // sizes. - let g1_projs: Vec = g1s.iter().map(|g| g.0).collect(); - let g2_projs: Vec = g2s.iter().map(|g| g.0).collect(); - let g1_affines = ark_bn254::G1Projective::normalize_batch(&g1_projs); - let g2_affines = ark_bn254::G2Projective::normalize_batch(&g2_projs); + let g1_projs: Vec = g1s.iter().map(|g| g.0).collect(); + let g2_projs: Vec = g2s.iter().map(|g| g.0).collect(); + let g1_affines = G1Projective::normalize_batch(&g1_projs); + let g2_affines = G2Projective::normalize_batch(&g2_projs); Bn254GT(ArkBn254::multi_pairing(&g1_affines, &g2_affines).0) } } @@ -258,18 +287,20 @@ impl PairingGroup for Bn254 { /// In debug builds, asserts that the source value fits in the BN254 Fr modulus — /// catches silent modular reduction when `F` has a larger modulus than BN254 Fr. #[inline] -pub(crate) fn field_to_fr(f: &F) -> ark_bn254::Fr { +pub(crate) fn field_to_fr(f: &F) -> ArkFr { let mut bytes = vec![0u8; F::NUM_BYTES]; f.to_bytes_le(&mut bytes); #[cfg(debug_assertions)] { use ark_ff::{BigInteger, PrimeField as _}; - let value = num_bigint::BigUint::from_bytes_le(&bytes); - let modulus = num_bigint::BigUint::from_bytes_le(&ark_bn254::Fr::MODULUS.to_bytes_le()); + use num_bigint::BigUint; + + let value = BigUint::from_bytes_le(&bytes); + let modulus = BigUint::from_bytes_le(&ArkFr::MODULUS.to_bytes_le()); debug_assert!( value < modulus, "field_to_fr: source value >= BN254 Fr modulus (silent reduction)", ); } - ark_bn254::Fr::from_le_bytes_mod_order(&bytes) + ArkFr::from_le_bytes_mod_order(&bytes) } diff --git a/crates/jolt-dory/Cargo.toml b/crates/jolt-dory/Cargo.toml index 30e3ea95ea..8ec088b4c8 100644 --- a/crates/jolt-dory/Cargo.toml +++ b/crates/jolt-dory/Cargo.toml @@ -9,16 +9,19 @@ description = "Dory commitment scheme implementation for the Jolt zkVM" workspace = true [dependencies] +ark-bn254 = { workspace = true, features = ["curve"] } +ark-ec = { workspace = true } +ark-serialize = { workspace = true } jolt-crypto = { workspace = true } jolt-field = { workspace = true } jolt-openings = { workspace = true } +jolt-optimizations = { workspace = true } jolt-poly = { workspace = true } jolt-transcript = { workspace = true } dory = { workspace = true } +tracing.workspace = true rayon = { workspace = true } serde = { workspace = true, features = ["derive"] } -tracing.workspace = true -ark-serialize = { workspace = true } [dev-dependencies] criterion = { workspace = true } diff --git a/crates/jolt-dory/benches/dory.rs b/crates/jolt-dory/benches/dory.rs index d733c8822d..7815940885 100644 --- a/crates/jolt-dory/benches/dory.rs +++ b/crates/jolt-dory/benches/dory.rs @@ -1,12 +1,15 @@ #![allow(unused_results)] -use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion}; use jolt_dory::{DoryScheme, DoryVerifierSetup}; use jolt_field::{Fr, RandomSampling}; -use jolt_openings::{CommitmentScheme, StreamingCommitment, ZkOpeningScheme}; +use jolt_openings::{ + AdditivelyHomomorphic, AdditivelyHomomorphicVerifier, CommitmentScheme, + CommitmentSchemeVerifier, ZkOpeningScheme, ZkOpeningSchemeVerifier, +}; use jolt_poly::{OneHotPolynomial, Polynomial}; -use jolt_transcript::Transcript; +use jolt_transcript::{Blake2bTranscript, Transcript}; use rand_chacha::ChaCha20Rng; use rand_core::{RngCore, SeedableRng}; @@ -38,7 +41,7 @@ fn bench_commit(c: &mut Criterion) { Polynomial::::random(nv, &mut rng) }, |poly| DoryScheme::commit(poly.evaluations(), &setup), - criterion::BatchSize::SmallInput, + BatchSize::SmallInput, ); }, ); @@ -65,10 +68,10 @@ fn bench_open(c: &mut Criterion) { (poly, point, eval) }, |(poly, point, eval)| { - let mut transcript = jolt_transcript::Blake2bTranscript::new(b"bench-open"); + let mut transcript = Blake2bTranscript::new(b"bench-open"); DoryScheme::open(&poly, &point, eval, &setup, None, &mut transcript) }, - criterion::BatchSize::SmallInput, + BatchSize::SmallInput, ); }, ); @@ -94,15 +97,13 @@ fn bench_verify(c: &mut Criterion) { .collect(); let eval = poly.evaluate(&point); let (commitment, _) = DoryScheme::commit(poly.evaluations(), &setup); - let mut transcript = - jolt_transcript::Blake2bTranscript::new(b"bench-verify"); + let mut transcript = Blake2bTranscript::new(b"bench-verify"); let proof = DoryScheme::open(&poly, &point, eval, &setup, None, &mut transcript); (commitment, point, eval, proof) }, |(commitment, point, eval, proof)| { - let mut transcript = - jolt_transcript::Blake2bTranscript::new(b"bench-verify"); + let mut transcript = Blake2bTranscript::new(b"bench-verify"); DoryScheme::verify( &commitment, &point, @@ -112,38 +113,7 @@ fn bench_verify(c: &mut Criterion) { &mut transcript, ) }, - criterion::BatchSize::SmallInput, - ); - }, - ); - } - group.finish(); -} - -fn bench_streaming_commit(c: &mut Criterion) { - let mut group = c.benchmark_group("streaming_commit"); - for num_vars in [4, 8, 12] { - let setup = DoryScheme::setup_prover(num_vars); - let sigma = num_vars.div_ceil(2); - let num_cols = 1usize << sigma; - - group.bench_with_input( - BenchmarkId::from_parameter(num_vars), - &num_vars, - |b, &nv| { - b.iter_batched( - || { - let mut rng = ChaCha20Rng::seed_from_u64(0); - Polynomial::::random(nv, &mut rng) - }, - |poly| { - let mut partial = DoryScheme::begin(&setup); - for row in poly.evaluations().chunks(num_cols) { - DoryScheme::feed(&mut partial, row, &setup); - } - DoryScheme::finish(partial, &setup) - }, - criterion::BatchSize::SmallInput, + BatchSize::SmallInput, ); }, ); @@ -165,7 +135,7 @@ fn bench_combine(c: &mut Criterion) { group.bench_with_input(BenchmarkId::from_parameter(num_vars), &num_vars, |b, _| { b.iter(|| { - ::combine( + ::combine( &[commit_a.clone(), commit_b.clone()], &[s_a, s_b], ) @@ -198,12 +168,8 @@ fn bench_combine_hints(c: &mut Criterion) { |b, _| { b.iter_batched( || hints.clone(), - |hs| { - ::combine_hints( - hs, &scalars, - ) - }, - criterion::BatchSize::SmallInput, + |hs| ::combine_hints(hs, &scalars), + BatchSize::SmallInput, ); }, ); @@ -232,11 +198,10 @@ fn bench_open_zk(c: &mut Criterion) { (poly, point, eval, hint) }, |(poly, point, eval, hint)| { - let mut transcript = - jolt_transcript::Blake2bTranscript::new(b"bench-open-zk"); + let mut transcript = Blake2bTranscript::new(b"bench-open-zk"); DoryScheme::open_zk(&poly, &point, eval, &setup, hint, &mut transcript) }, - criterion::BatchSize::SmallInput, + BatchSize::SmallInput, ); }, ); @@ -263,15 +228,13 @@ fn bench_verify_zk(c: &mut Criterion) { let eval = poly.evaluate(&point); let (commitment, hint) = ::commit_zk(poly.evaluations(), &setup); - let mut transcript = - jolt_transcript::Blake2bTranscript::new(b"bench-verify-zk"); + let mut transcript = Blake2bTranscript::new(b"bench-verify-zk"); let (proof, _eval_com, _blind) = DoryScheme::open_zk(&poly, &point, eval, &setup, hint, &mut transcript); (commitment, point, proof) }, |(commitment, point, proof)| { - let mut transcript = - jolt_transcript::Blake2bTranscript::new(b"bench-verify-zk"); + let mut transcript = Blake2bTranscript::new(b"bench-verify-zk"); DoryScheme::verify_zk( &commitment, &point, @@ -280,7 +243,7 @@ fn bench_verify_zk(c: &mut Criterion) { &mut transcript, ) }, - criterion::BatchSize::SmallInput, + BatchSize::SmallInput, ); }, ); @@ -304,7 +267,7 @@ fn bench_commit_one_hot(c: &mut Criterion) { OneHotPolynomial::new(k, indices) }, |poly| DoryScheme::commit(&poly, &setup), - criterion::BatchSize::SmallInput, + BatchSize::SmallInput, ); }); } @@ -318,7 +281,6 @@ criterion_group!( bench_commit_one_hot, bench_open, bench_verify, - bench_streaming_commit, bench_combine, bench_combine_hints, bench_open_zk, diff --git a/crates/jolt-dory/src/lib.rs b/crates/jolt-dory/src/lib.rs index bd69dc68bd..6ebacad5c4 100644 --- a/crates/jolt-dory/src/lib.rs +++ b/crates/jolt-dory/src/lib.rs @@ -2,12 +2,11 @@ //! //! Wraps the [Dory](https://eprint.iacr.org/2020/1274) polynomial commitment //! scheme for BN254 with transparent setup, logarithmic proof size, and -//! logarithmic verification. Supports streaming commitment and additive +//! logarithmic verification. Supports source-batch commitment and additive //! homomorphism for batch opening reduction. //! //! Implements [`CommitmentScheme`](jolt_openings::CommitmentScheme), //! [`AdditivelyHomomorphic`](jolt_openings::AdditivelyHomomorphic), -//! [`StreamingCommitment`](jolt_openings::StreamingCommitment), and //! [`ZkOpeningScheme`](jolt_openings::ZkOpeningScheme) from `jolt-openings`. //! //! # Public API @@ -22,15 +21,12 @@ //! - [`DoryCommitment`] — BN254 pairing target element (GT). //! - [`DoryProof`] — single opening proof. //! - [`DoryProverSetup`] / [`DoryVerifierSetup`] — prover and verifier SRS. -//! - [`DoryPartialCommitment`] — intermediate state for streaming commitment. -//! - [`DoryHint`] — row commitments and commitment blind reusable as opening proof hint. +//! - [`DoryHint`] — row commitments, row width, and commitment blind reusable as opening proof hint. +mod routines; mod scheme; -mod streaming; mod transcript; mod types; -pub use scheme::DoryScheme; -pub use types::{ - DoryCommitment, DoryHint, DoryPartialCommitment, DoryProof, DoryProverSetup, DoryVerifierSetup, -}; +pub use scheme::{ArkFr, DoryScheme}; +pub use types::{DoryCommitment, DoryHint, DoryProof, DoryProverSetup, DoryVerifierSetup}; diff --git a/crates/jolt-dory/src/routines.rs b/crates/jolt-dory/src/routines.rs new file mode 100644 index 0000000000..e6ce9ca16f --- /dev/null +++ b/crates/jolt-dory/src/routines.rs @@ -0,0 +1,145 @@ +//! Jolt-optimized dory-pcs group routines. +//! +//! These mirror the historical in-core Dory routines so extracting Dory into +//! this crate does not silently fall back to slower default dory-pcs group +//! operations in Stage 8. + +#![expect( + clippy::expect_used, + reason = "DoryRoutines cannot return Result; MSM length mismatches are caller invariant violations" +)] + +use std::mem::transmute_copy; +use std::slice::{from_raw_parts, from_raw_parts_mut}; + +use ark_bn254::{Fr as ArkworksFr, G1Projective, G2Projective}; +use ark_ec::scalar_mul::variable_base::VariableBaseMSM; +use ark_ec::CurveGroup; +use dory::backends::arkworks::{ArkFr, ArkG1, ArkG2}; +use dory::primitives::arithmetic::DoryRoutines; +use jolt_optimizations::{ + fixed_base_vector_msm_g1, glv_four_scalar_mul_online, vector_add_scalar_mul_g1_online, + vector_add_scalar_mul_g2_online, vector_scalar_mul_add_gamma_g1_online, + vector_scalar_mul_add_gamma_g2_online, +}; +use rayon::prelude::*; + +fn raw_scalars(scalars: &[ArkFr]) -> &[ArkworksFr] { + // SAFETY: ArkFr is repr(transparent) over ark_bn254::Fr. + unsafe { from_raw_parts(scalars.as_ptr().cast::(), scalars.len()) } +} + +fn raw_scalar(scalar: &ArkFr) -> ArkworksFr { + // SAFETY: ArkFr is repr(transparent) over ark_bn254::Fr. + unsafe { transmute_copy::(scalar) } +} + +fn g1_points(points: &[ArkG1]) -> &[G1Projective] { + // SAFETY: ArkG1 is repr(transparent) over ark_bn254::G1Projective. + unsafe { from_raw_parts(points.as_ptr().cast::(), points.len()) } +} + +fn g1_points_mut(points: &mut [ArkG1]) -> &mut [G1Projective] { + // SAFETY: ArkG1 is repr(transparent) over ark_bn254::G1Projective. + unsafe { from_raw_parts_mut(points.as_mut_ptr().cast::(), points.len()) } +} + +fn g2_points(points: &[ArkG2]) -> &[G2Projective] { + // SAFETY: ArkG2 is repr(transparent) over ark_bn254::G2Projective. + unsafe { from_raw_parts(points.as_ptr().cast::(), points.len()) } +} + +fn g2_points_mut(points: &mut [ArkG2]) -> &mut [G2Projective] { + // SAFETY: ArkG2 is repr(transparent) over ark_bn254::G2Projective. + unsafe { from_raw_parts_mut(points.as_mut_ptr().cast::(), points.len()) } +} + +fn fold_field_vectors(left: &mut [ArkFr], right: &[ArkFr], scalar: &ArkFr) { + assert_eq!(left.len(), right.len(), "Dory vector lengths must match"); + left.par_iter_mut() + .zip(right.par_iter()) + .for_each(|(left, right)| { + *left = *left * *scalar + *right; + }); +} + +pub struct JoltG1Routines; + +impl DoryRoutines for JoltG1Routines { + fn msm(bases: &[ArkG1], scalars: &[ArkFr]) -> ArkG1 { + let affines = G1Projective::normalize_batch(g1_points(bases)); + let result = ::msm_serial(&affines, raw_scalars(scalars)) + .expect("Dory G1 MSM input lengths should match"); + ArkG1(result) + } + + fn fixed_base_vector_scalar_mul(base: &ArkG1, scalars: &[ArkFr]) -> Vec { + if scalars.is_empty() { + return vec![]; + } + fixed_base_vector_msm_g1(&base.0, raw_scalars(scalars)) + .into_iter() + .map(ArkG1) + .collect() + } + + fn fixed_scalar_mul_bases_then_add(bases: &[ArkG1], vs: &mut [ArkG1], scalar: &ArkFr) { + assert_eq!(bases.len(), vs.len(), "Dory vector lengths must match"); + vector_add_scalar_mul_g1_online(g1_points_mut(vs), g1_points(bases), raw_scalar(scalar)); + } + + fn fixed_scalar_mul_vs_then_add(vs: &mut [ArkG1], addends: &[ArkG1], scalar: &ArkFr) { + assert_eq!(vs.len(), addends.len(), "Dory vector lengths must match"); + vector_scalar_mul_add_gamma_g1_online( + g1_points_mut(vs), + raw_scalar(scalar), + g1_points(addends), + ); + } + + fn fold_field_vectors(left: &mut [ArkFr], right: &[ArkFr], scalar: &ArkFr) { + fold_field_vectors(left, right, scalar); + } +} + +pub struct JoltG2Routines; + +impl DoryRoutines for JoltG2Routines { + fn msm(bases: &[ArkG2], scalars: &[ArkFr]) -> ArkG2 { + let affines = G2Projective::normalize_batch(g2_points(bases)); + let result = ::msm_serial( + &affines[..scalars.len()], + raw_scalars(scalars), + ) + .expect("Dory G2 MSM input lengths should match"); + ArkG2(result) + } + + fn fixed_base_vector_scalar_mul(base: &ArkG2, scalars: &[ArkFr]) -> Vec { + if scalars.is_empty() { + return vec![]; + } + raw_scalars(scalars) + .par_iter() + .map(|&scalar| ArkG2(glv_four_scalar_mul_online(scalar, &[base.0])[0])) + .collect() + } + + fn fixed_scalar_mul_bases_then_add(bases: &[ArkG2], vs: &mut [ArkG2], scalar: &ArkFr) { + assert_eq!(bases.len(), vs.len(), "Dory vector lengths must match"); + vector_add_scalar_mul_g2_online(g2_points_mut(vs), g2_points(bases), raw_scalar(scalar)); + } + + fn fixed_scalar_mul_vs_then_add(vs: &mut [ArkG2], addends: &[ArkG2], scalar: &ArkFr) { + assert_eq!(vs.len(), addends.len(), "Dory vector lengths must match"); + vector_scalar_mul_add_gamma_g2_online( + g2_points_mut(vs), + raw_scalar(scalar), + g2_points(addends), + ); + } + + fn fold_field_vectors(left: &mut [ArkFr], right: &[ArkFr], scalar: &ArkFr) { + fold_field_vectors(left, right, scalar); + } +} diff --git a/crates/jolt-dory/src/scheme.rs b/crates/jolt-dory/src/scheme.rs index dc6491b36a..323c1d1c76 100644 --- a/crates/jolt-dory/src/scheme.rs +++ b/crates/jolt-dory/src/scheme.rs @@ -7,30 +7,72 @@ reason = "ZK proof y_com/y_blinding are Dory-mode invariants; dory::prove/verify errors are caller-precondition violations surfaced via panic; the dory adapter's commit is unreachable because DoryScheme pre-computes row commitments" )] -use dory::backends::arkworks::{ArkworksProverSetup, G1Routines, G2Routines}; +use std::collections::BTreeMap; +use std::iter::successors; +use std::mem::{transmute, transmute_copy}; + +#[cfg(not(test))] +use dory::backends::arkworks::{init_cache, is_cached}; +use dory::backends::arkworks::{ + ArkFr as DoryArkFr, ArkG1 as ArkG1Struct, ArkGT as DoryArkGT, ArkworksProverSetup, + BN254 as DoryBN254, +}; use dory::mode::Transparent; use dory::primitives::arithmetic::{ DoryRoutines, Field as DoryField, Group as DoryGroup, PairingCurve, }; use dory::primitives::poly::{MultilinearLagrange, Polynomial as DoryPolynomial}; -use dory::Mode; +use dory::primitives::transcript::Transcript as DoryTranscript; +use dory::setup::ProverSetup as DoryNativeProverSetup; +use dory::{error::DoryError, mode::Mode as DoryMode}; +use dory::{prove, verify, Mode, ZK}; use jolt_crypto::{Bn254G1, Bn254GT, Commitment, DeriveSetup, JoltGroup, PedersenSetup}; -use jolt_field::Fr; -use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme, OpeningsError, ZkOpeningScheme}; -use jolt_poly::MultilinearPoly; +use jolt_field::{Fr, FromPrimitiveInt}; +use jolt_openings::{ + homomorphic_prove_batch, homomorphic_verify_batch, AdditivelyHomomorphic, + AdditivelyHomomorphicVerifier, BatchCommitmentSource, BatchOpeningProverResult, + BatchOpeningPublic, BatchOpeningSource, BatchOutputExpression, BatchOutputRelation, + BatchOutputValue, CommitmentScheme, CommitmentSchemeVerifier, CommitmentSource, + EvaluationCommitmentProver, EvaluationCommitmentScheme, LinearCombinationOpeningSource, + LinearOpeningScheme, LinearOpeningSchemeVerifier, LinearSourceTerm, OneHotEntries, OneHotRow, + OpenedBatchOutput, OpeningClaim, OpeningsError, ProverBatchOpeningTerm, ProverClaim, SourceId, + SourceRow, VerifierBatchOpeningTerm, VerifierSetupFromPublicParams, ZkBatchOpeningProverResult, + ZkBatchOpeningWitness, ZkLinearOpeningScheme, ZkLinearOpeningSchemeVerifier, ZkOpeningScheme, + ZkOpeningSchemeVerifier, +}; +use jolt_optimizations::batch_g1_additions_multi; use jolt_transcript::{AppendToTranscript, Label, LabelWithCount, Transcript}; use rayon::prelude::*; +use ark_bn254::{G1Affine, G1Projective}; +use ark_ec::scalar_mul::variable_base::{msm_i128, msm_u64}; +use ark_ec::CurveGroup; + +use crate::routines::{JoltG1Routines, JoltG2Routines}; use crate::transcript::JoltToDoryTranscript; use crate::types::{DoryCommitment, DoryHint, DoryProof, DoryProverSetup, DoryVerifierSetup}; // All jolt types below are #[repr(transparent)] over the same arkworks // inner type as their dory-pcs counterpart, guaranteeing identical layout. -pub(crate) type ArkFr = dory::backends::arkworks::ArkFr; -pub(crate) type ArkG1 = dory::backends::arkworks::ArkG1; -pub(crate) type ArkGT = dory::backends::arkworks::ArkGT; -type InnerBN254 = dory::backends::arkworks::BN254; +/// Dory-pcs's arkworks scalar wrapper. +/// +/// Most callers should use the backend-neutral `jolt_field::Fr` APIs on +/// `DoryScheme`. This type is exposed for Dory-native integration points that +/// already implement dory-pcs polynomial traits and need to avoid converting +/// large opening-time vectors through the generic source abstraction. +pub type ArkFr = DoryArkFr; +pub(crate) type ArkG1 = ArkG1Struct; +pub(crate) type ArkGT = DoryArkGT; +type InnerBN254 = DoryBN254; + +// The dory-pcs prepared-point cache is global and tied to one URS. Jolt +// initializes one large Dory setup during preprocessing, so caching that setup +// preserves the old in-core performance path. Small standalone callers can +// create several independent toy URS sizes in one process, so avoid seeding the +// global cache from those setups. +#[cfg(not(test))] +const PREPARED_CACHE_MIN_NUM_VARS: usize = 16; // All conversion functions below rely on repr(transparent) layout identity // between jolt and dory-pcs wrappers over the same arkworks inner type. @@ -38,44 +80,44 @@ type InnerBN254 = dory::backends::arkworks::BN254; #[inline] pub(crate) fn jolt_fr_to_ark(f: &Fr) -> ArkFr { // SAFETY: Fr and ArkFr are both repr(transparent) over ark_bn254::Fr. - unsafe { std::mem::transmute_copy(f) } + unsafe { transmute_copy(f) } } #[inline] pub(crate) fn ark_to_jolt_fr(ark: &ArkFr) -> Fr { // SAFETY: same layout as jolt_fr_to_ark. - unsafe { std::mem::transmute_copy(ark) } + unsafe { transmute_copy(ark) } } #[inline] pub(crate) fn jolt_gt_to_ark(gt: &Bn254GT) -> ArkGT { // SAFETY: Bn254GT and ArkGT are both repr(transparent) over Fq12. - unsafe { std::mem::transmute_copy(gt) } + unsafe { transmute_copy(gt) } } #[inline] pub(crate) fn ark_to_jolt_gt(ark: &ArkGT) -> Bn254GT { // SAFETY: same layout as jolt_gt_to_ark. - unsafe { std::mem::transmute_copy(ark) } + unsafe { transmute_copy(ark) } } #[inline] pub(crate) fn jolt_g1_vec_to_ark(v: Vec) -> Vec { // SAFETY: Bn254G1 and ArkG1 have identical size/align (repr(transparent) // over G1Projective), so Vec layout is identical. - unsafe { std::mem::transmute(v) } + unsafe { transmute(v) } } #[inline] pub(crate) fn ark_to_jolt_g1_vec(v: Vec) -> Vec { // SAFETY: same layout as jolt_g1_vec_to_ark. - unsafe { std::mem::transmute(v) } + unsafe { transmute(v) } } #[inline] pub(crate) fn ark_to_jolt_g1(ark: ArkG1) -> Bn254G1 { // SAFETY: Bn254G1 and ArkG1 are both repr(transparent) over G1Projective. - unsafe { std::mem::transmute(ark) } + unsafe { transmute(ark) } } #[derive(Clone)] @@ -84,7 +126,15 @@ pub struct DoryScheme; impl DoryScheme { #[tracing::instrument(skip_all, name = "DoryScheme::setup_prover", fields(max_num_vars))] pub fn setup_prover(max_num_vars: usize) -> DoryProverSetup { - DoryProverSetup(ArkworksProverSetup::new_from_urs(max_num_vars)) + #[cfg(not(target_arch = "wasm32"))] + let setup = ArkworksProverSetup::new_from_urs(max_num_vars); + #[cfg(target_arch = "wasm32")] + let setup = ArkworksProverSetup::new(max_num_vars); + #[cfg(not(test))] + if max_num_vars >= PREPARED_CACHE_MIN_NUM_VARS && !is_cached() { + init_cache(&setup.g1_vec, &setup.g2_vec); + } + DoryProverSetup(setup) } /// Derives the verifier SRS (a subset of the prover SRS). @@ -94,349 +144,1507 @@ impl DoryScheme { DoryVerifierSetup(prover_setup.0.to_verifier_setup()) } - fn commit_with_mode(poly: &P, setup: &DoryProverSetup) -> (DoryCommitment, DoryHint) + fn commit_with_mode(source: &S, setup: &ArkworksProverSetup) -> (DoryCommitment, DoryHint) where - P: MultilinearPoly + ?Sized, + S: CommitmentSource + ?Sized, M: Mode, { - let row_commitments = compute_row_commitments(poly, setup); - let (tier_2, commit_blind) = commit_rows_tier_2::(&row_commitments, setup); - - ( - DoryCommitment(ark_to_jolt_gt(&tier_2)), - DoryHint::new( - ark_to_jolt_g1_vec(row_commitments), - ark_to_jolt_fr(&commit_blind), - ), - ) + let chunk_len = natural_or_balanced_chunk_len(source); + let row_commitments = compute_row_commitments(source, chunk_len, setup); + finish_row_commitments::(row_commitments, chunk_len, setup) } -} -impl DeriveSetup for PedersenSetup { - fn derive(source: &DoryProverSetup, capacity: usize) -> Self { - assert!( - capacity <= source.0.g1_vec.len(), - "Pedersen capacity ({}) exceeds Dory SRS size ({})", - capacity, - source.0.g1_vec.len(), - ); - let generators = ark_to_jolt_g1_vec(source.0.g1_vec[..capacity].to_vec()); - let blinding = ark_to_jolt_g1(source.0.h1); - PedersenSetup::new(generators, blinding) - } -} + fn commit_batch_with_mode( + batch: &B, + ids: &[B::Id], + setup: &ArkworksProverSetup, + ) -> Vec<(DoryCommitment, DoryHint)> + where + B: BatchCommitmentSource, + M: Mode, + { + if ids.is_empty() { + return Vec::new(); + } -impl Commitment for DoryScheme { - type Output = DoryCommitment; -} + let max_num_vars = ids + .iter() + .map(|&id| batch.num_vars(id)) + .max() + .expect("ids is non-empty"); + let chunk_len = batch + .natural_chunk_len(ids) + .unwrap_or_else(|| balanced_chunk_len(max_num_vars)); + validate_chunk_len(chunk_len); + let ctx = CommitRowContext::new(setup, chunk_len); + let row_major = batch.map_rows(chunk_len, ids, |_, row| commit_source_row(row, &ctx)); + + let mut chunks_by_source: Vec> = (0..ids.len()) + .map(|_| Vec::with_capacity(row_major.len())) + .collect(); + for row in row_major { + assert_eq!( + row.len(), + ids.len(), + "batch source returned a ragged row of committed sources", + ); + for (source_chunks, chunk) in chunks_by_source.iter_mut().zip(row) { + source_chunks.push(chunk); + } + } -impl CommitmentScheme for DoryScheme { - type Field = Fr; - type Proof = DoryProof; - type ProverSetup = DoryProverSetup; - type VerifierSetup = DoryVerifierSetup; - type Polynomial = jolt_poly::Polynomial; - type OpeningHint = DoryHint; - type SetupParams = usize; + chunks_by_source + .into_par_iter() + .map(|chunks| aggregate_batch_chunks::(chunks, chunk_len, setup)) + .collect() + } - fn setup(max_num_vars: Self::SetupParams) -> (DoryProverSetup, DoryVerifierSetup) { - let prover = Self::setup_prover(max_num_vars); - let verifier = Self::verifier_setup(&prover); - (prover, verifier) + fn open_source_with_mode( + source: &S, + point: &[Fr], + setup: &ArkworksProverSetup, + hint: DoryHint, + transcript: &mut T, + ) -> (DoryProof, Option) + where + S: CommitmentSource + ?Sized, + T: DoryTranscript, + M: Mode, + { + let adapter = DorySourceAdapter::new(source); + let ark_point: Vec = point.iter().rev().map(jolt_fr_to_ark).collect(); + let (nu, sigma) = hint_shape(&hint); + + Self::open_dory_source_with_mode::<_, _, M>( + &adapter, &ark_point, nu, sigma, setup, hint, transcript, + ) } - fn verifier_setup(prover_setup: &DoryProverSetup) -> DoryVerifierSetup { - DoryVerifierSetup(prover_setup.0.to_verifier_setup()) + fn open_dory_source_with_mode( + source: &S, + ark_point: &[ArkFr], + nu: usize, + sigma: usize, + setup: &ArkworksProverSetup, + hint: DoryHint, + transcript: &mut T, + ) -> (DoryProof, Option) + where + S: DoryPolynomial + MultilinearLagrange, + T: DoryTranscript, + M: Mode, + { + let (row_commitments, commit_blind) = hint.into_ark_parts(); + let (proof, y_blinding) = + prove::( + source, + ark_point, + row_commitments, + commit_blind, + nu, + sigma, + setup, + transcript, + ) + .unwrap_or_else(|e| panic!("dory::prove failed: {e:?}")); + + ( + DoryProof(proof), + y_blinding.map(|blind| ark_to_jolt_fr(&blind)), + ) } - #[tracing::instrument(skip_all, name = "DoryScheme::commit")] - fn commit + ?Sized>( - poly: &P, - setup: &Self::ProverSetup, - ) -> (Self::Output, Self::OpeningHint) { - Self::commit_with_mode::(poly, setup) + /// Opens a transparent Dory commitment using the traversal recorded in the hint. + #[tracing::instrument(skip_all, name = "DoryScheme::open_source_with_hint")] + fn open_source_with_hint( + source: &S, + point: &[Fr], + setup: &DoryProverSetup, + hint: DoryHint, + transcript: &mut T, + ) -> DoryProof + where + S: CommitmentSource + ?Sized, + T: DoryTranscript, + { + let (proof, _blind) = Self::open_source_with_mode::( + source, point, &setup.0, hint, transcript, + ); + proof } - #[tracing::instrument(skip_all, name = "DoryScheme::open")] - fn open( - poly: &Self::Polynomial, + /// Opens a ZK/hiding Dory commitment using the traversal recorded in the hint. + #[tracing::instrument(skip_all, name = "DoryScheme::open_zk_source_with_hint")] + fn open_zk_source_with_hint( + source: &S, point: &[Fr], - _eval: Fr, - setup: &Self::ProverSetup, - hint: Option, - transcript: &mut impl Transcript, - ) -> Self::Proof { - let num_vars = point.len(); - let adapter = DorySourceAdapter::new(poly); - let sigma = num_vars.div_ceil(2); - let nu = num_vars - sigma; - - let (row_commitments, commit_blind) = match hint { - Some(h) => h.into_ark_parts(), - None => ( - compute_row_commitments(poly, setup), - ::zero(), - ), - }; - debug_assert!( - commit_blind.is_zero(), - "commit_blind should be 0 for transparent mode" + setup: &DoryProverSetup, + hint: DoryHint, + transcript: &mut T, + ) -> (DoryProof, Bn254G1, Fr) + where + S: CommitmentSource + ?Sized, + T: DoryTranscript, + { + let (proof, y_blinding) = + Self::open_source_with_mode::(source, point, &setup.0, hint, transcript); + let y_com = ark_to_jolt_g1(proof.0.y_com.expect("ZK proof must contain y_com")); + let blinding = y_blinding.expect("ZK proof must return y_blinding"); + (proof, y_com, blinding) + } + + fn prove_source_backed_batch_with_mode( + terms: Vec>, + source_batch: &mut B, + setup: &DoryProverSetup, + transcript: &mut T, + ) -> ( + DoryProof, + BatchOpeningPublic, + Fr, + Option, + ) + where + B: LinearCombinationOpeningSource, + T: Transcript, + M: DoryBatchOpeningMode, + { + assert!( + !terms.is_empty(), + "Dory source-backed batch opening requires at least one term", ); + let (public_point, proof_point) = prover_batch_points(&terms); - let ark_point: Vec = point.iter().rev().map(jolt_fr_to_ark).collect(); + if M::ABSORB_PUBLIC_EVALS { + bind_source_backed_evals(&terms, transcript); + } + let gamma_powers = challenge_powers(transcript, terms.len()); + let joint_claim = joint_claim(&terms, &gamma_powers); + let source_terms = aggregate_source_terms(&terms, &gamma_powers); + let combined_hint = combine_batch_opening_hints(source_batch, &source_terms); + let combined_source = source_batch.linear_combination(&source_terms); + + let adapter = DoryBatchOpeningAdapter { + source: &combined_source, + output_eval: joint_claim, + num_vars: proof_point.len(), + }; + + let ark_point: Vec = proof_point.iter().rev().map(jolt_fr_to_ark).collect(); + let (nu, sigma) = hint_shape(&combined_hint); let mut dory_transcript = JoltToDoryTranscript::new(transcript); + let (proof, y_blinding) = Self::open_dory_source_with_mode::<_, _, M::DoryMode>( + &adapter, + &ark_point, + nu, + sigma, + &setup.0, + combined_hint, + &mut dory_transcript, + ); - let (proof, _blind) = - dory::prove::( - &adapter, - &ark_point, - row_commitments, - commit_blind, - nu, - sigma, - &setup.0, - &mut dory_transcript, - ) - .unwrap_or_else(|e| panic!("dory::prove failed: {e:?}")); + let output_value = M::output_value(&proof, joint_claim); + M::bind_output(transcript, &public_point, &output_value); + let public = batch_opening_public(terms, gamma_powers, public_point, output_value); + (proof, public, joint_claim, y_blinding) + } + + fn verify_source_backed_batch_with_mode( + terms: Vec>, + proof: &Vec, + setup: &DoryVerifierSetup, + transcript: &mut T, + ) -> Result, OpeningsError> + where + SourceIdT: SourceId, + T: Transcript, + M: DoryBatchOpeningMode, + { + if terms.is_empty() { + return Err(OpeningsError::VerificationFailed); + } + let [proof] = proof.as_slice() else { + return Err(OpeningsError::VerificationFailed); + }; + let (public_point, proof_point) = verifier_batch_points(&terms)?; - DoryProof(proof) + if M::ABSORB_PUBLIC_EVALS { + bind_source_backed_evals(&terms, transcript); + } + let gamma_powers = challenge_powers(transcript, terms.len()); + let joint_claim = joint_claim(&terms, &gamma_powers); + let joint_commitment = combine_batch_opening_commitments(&terms, &gamma_powers); + + let output_value = M::output_value(proof, joint_claim); + match &output_value { + BatchOutputValue::Public(eval) => { + Self::verify( + &joint_commitment, + &proof_point, + *eval, + proof, + setup, + transcript, + )?; + } + BatchOutputValue::Hidden(_) => { + Self::verify_zk(&joint_commitment, &proof_point, proof, setup, transcript)?; + } + } + M::bind_output(transcript, &public_point, &output_value); + Ok(batch_opening_public( + terms, + gamma_powers, + public_point, + output_value, + )) } - #[tracing::instrument(skip_all, name = "DoryScheme::verify")] - fn verify( - commitment: &Self::Output, + /// Verifies a transparent Dory opening using an already Dory-compatible + /// transcript adapter. + /// + /// This is the verifier-side counterpart to opening with a hint for + /// protocol layers that still own their transcript type but delegate Dory + /// verification to this crate. + #[tracing::instrument(skip_all, name = "DoryScheme::verify_with_transcript")] + pub fn verify_with_transcript( + commitment: &DoryCommitment, point: &[Fr], eval: Fr, - proof: &Self::Proof, - setup: &Self::VerifierSetup, - transcript: &mut impl Transcript, - ) -> Result<(), OpeningsError> { + proof: &DoryProof, + setup: &DoryVerifierSetup, + transcript: &mut T, + ) -> Result<(), OpeningsError> + where + T: DoryTranscript, + { let ark_point: Vec = point.iter().rev().map(jolt_fr_to_ark).collect(); let ark_eval = jolt_fr_to_ark(&eval); let ark_commitment = jolt_gt_to_ark(&commitment.0); - let mut dory_transcript = JoltToDoryTranscript::new(transcript); - dory::verify::( + verify::( ark_commitment, ark_eval, &ark_point, &proof.0, setup.0.clone().into_inner(), - &mut dory_transcript, + transcript, ) .map_err(|_| OpeningsError::VerificationFailed) } - fn bind_opening_inputs( - transcript: &mut impl Transcript, - point: &[Self::Field], - eval: &Self::Field, + /// Verifies a ZK/hiding Dory opening using an already Dory-compatible + /// transcript adapter. + /// + /// In ZK mode the evaluation is hidden and Dory verifies against the + /// evaluation commitment embedded in the proof. + #[tracing::instrument(skip_all, name = "DoryScheme::verify_zk_with_transcript")] + pub fn verify_zk_with_transcript( + commitment: &DoryCommitment, + point: &[Fr], + proof: &DoryProof, + setup: &DoryVerifierSetup, + transcript: &mut T, + ) -> Result<(), OpeningsError> + where + T: DoryTranscript, + { + let ark_point: Vec = point.iter().rev().map(jolt_fr_to_ark).collect(); + let dummy_eval = ::zero(); + let ark_commitment = jolt_gt_to_ark(&commitment.0); + + verify::( + ark_commitment, + dummy_eval, + &ark_point, + &proof.0, + setup.0.clone().into_inner(), + transcript, + ) + .map_err(|_| OpeningsError::VerificationFailed) + } +} + +trait DoryBatchOpeningMode { + type DoryMode: Mode; + type OutputValue: Clone + std::fmt::Debug + Eq + Send + Sync + 'static; + + const ABSORB_PUBLIC_EVALS: bool; + + fn output_value(proof: &DoryProof, joint_claim: Fr) -> BatchOutputValue; + + fn bind_output( + transcript: &mut impl Transcript, + public_point: &[Fr], + value: &BatchOutputValue, + ); +} + +struct TransparentBatchOpening; + +impl DoryBatchOpeningMode for TransparentBatchOpening { + type DoryMode = Transparent; + type OutputValue = (); + + const ABSORB_PUBLIC_EVALS: bool = true; + + fn output_value( + _proof: &DoryProof, + joint_claim: Fr, + ) -> BatchOutputValue { + BatchOutputValue::Public(joint_claim) + } + + fn bind_output( + transcript: &mut impl Transcript, + public_point: &[Fr], + value: &BatchOutputValue, ) { - transcript.append(&LabelWithCount(b"dory_opening_point", point.len() as u64)); - for p in point { - p.append_to_transcript(transcript); - } - transcript.append(&Label(b"dory_opening_eval")); - eval.append_to_transcript(transcript); + let BatchOutputValue::Public(eval) = value else { + unreachable!("transparent Dory batch opening returns a public scalar"); + }; + DoryScheme::bind_opening_inputs(transcript, public_point, eval); } } -impl AdditivelyHomomorphic for DoryScheme { - #[tracing::instrument(skip_all, name = "DoryScheme::combine")] - fn combine(commitments: &[Self::Output], scalars: &[Self::Field]) -> Self::Output { - assert_eq!(commitments.len(), scalars.len()); +struct ZkBatchOpening; - let combined = commitments - .par_iter() - .zip(scalars.par_iter()) - .map(|(c, s)| jolt_fr_to_ark(s) * jolt_gt_to_ark(&c.0)) - .reduce(ArkGT::identity, |acc, x| acc + x); +impl DoryBatchOpeningMode for ZkBatchOpening { + type DoryMode = ZK; + type OutputValue = Bn254G1; - DoryCommitment(ark_to_jolt_gt(&combined)) + const ABSORB_PUBLIC_EVALS: bool = false; + + fn output_value( + proof: &DoryProof, + _joint_claim: Fr, + ) -> BatchOutputValue { + let y_com = proof + .0 + .y_com + .expect("ZK source-backed Dory proof must contain y_com"); + BatchOutputValue::Hidden(ark_to_jolt_g1(y_com)) } - #[tracing::instrument(skip_all, name = "DoryScheme::combine_hints")] - fn combine_hints(hints: Vec, scalars: &[Self::Field]) -> Self::OpeningHint { - assert_eq!(hints.len(), scalars.len()); - assert!(!hints.is_empty(), "combine_hints: empty hint set"); + fn bind_output( + transcript: &mut impl Transcript, + public_point: &[Fr], + value: &BatchOutputValue, + ) { + let BatchOutputValue::Hidden(y_com) = value else { + unreachable!("ZK Dory batch opening returns a hidden output"); + }; + DoryScheme::bind_zk_opening_inputs(transcript, public_point, y_com); + } +} - let num_rows = hints[0].row_commitments.len(); - assert!( - hints.iter().all(|h| h.row_commitments.len() == num_rows), - "combine_hints: ragged hint lengths", - ); +fn challenge_powers(transcript: &mut T, len: usize) -> Vec +where + T: Transcript, +{ + let gamma = transcript.challenge(); + successors(Some(Fr::from_u64(1)), |prev| Some(*prev * gamma)) + .take(len) + .collect() +} - let combined_blind = hints - .iter() - .zip(scalars.iter()) - .map(|(hint, &scalar)| scalar * hint.commit_blind) - .sum(); +fn bind_source_backed_evals(terms: &[Term], transcript: &mut T) +where + Term: DoryBatchTerm, + T: Transcript, +{ + transcript.append(&LabelWithCount(b"rlc_claims", terms.len() as u64)); + for term in terms { + let scaled_eval = term.eval() * term.eval_scale(); + scaled_eval.append_to_transcript(transcript); + } +} - let combined: Vec = (0..num_rows) - .into_par_iter() - .map(|row| { - let mut acc = Bn254G1::default(); - for (hint, &scalar) in hints.iter().zip(scalars.iter()) { - acc += hint.row_commitments[row].scalar_mul(&scalar); - } - acc - }) - .collect(); +fn joint_claim(terms: &[Term], gamma_powers: &[Fr]) -> Fr +where + Term: DoryBatchTerm, +{ + terms + .iter() + .zip(gamma_powers.iter()) + .map(|(term, gamma)| *gamma * term.eval_scale() * term.eval()) + .sum() +} - DoryHint::new(combined, combined_blind) +fn batch_opening_public( + terms: Vec, + gamma_powers: Vec, + point: Vec, + value: BatchOutputValue, +) -> BatchOpeningPublic +where + Term: IntoDoryBatchTerm, +{ + let relation_terms = terms + .into_iter() + .zip(gamma_powers) + .map(|(term, gamma)| { + let (claim_id, eval_scale) = term.into_claim_id_and_scale(); + (claim_id, gamma * eval_scale) + }) + .collect(); + + BatchOpeningPublic { + outputs: vec![OpenedBatchOutput { point, value }], + relations: vec![BatchOutputRelation { + output_index: 0, + expression: BatchOutputExpression::Linear(relation_terms), + }], } } -impl ZkOpeningScheme for DoryScheme { - type HidingCommitment = Bn254G1; - type Blind = Fr; +fn aggregate_source_terms( + terms: &[Term], + gamma_powers: &[Fr], +) -> Vec> +where + Term: DoryBatchTerm, + SourceIdT: SourceId, +{ + let mut terms_by_source = BTreeMap::new(); + for (term, gamma) in terms.iter().zip(gamma_powers.iter()) { + *terms_by_source + .entry(term.source_id()) + .or_insert_with(|| Fr::from_u64(0)) += *gamma; + } - fn commit_zk + ?Sized>( - poly: &P, - setup: &Self::ProverSetup, - ) -> (Self::Output, Self::OpeningHint) { - Self::commit_with_mode::(poly, setup) + terms_by_source + .into_iter() + .map(|(source_id, coefficient)| LinearSourceTerm { + source_id, + coefficient, + }) + .collect() +} + +fn combine_batch_opening_hints( + source_batch: &B, + source_terms: &[LinearSourceTerm], +) -> DoryHint +where + B: BatchOpeningSource, +{ + let hints: Vec<&DoryHint> = source_terms + .iter() + .map(|term| source_batch.opening_hint(term.source_id)) + .collect(); + let scalars: Vec = source_terms.iter().map(|term| term.coefficient).collect(); + combine_hint_refs(&hints, &scalars) +} + +fn combine_batch_opening_commitments( + terms: &[VerifierBatchOpeningTerm], + gamma_powers: &[Fr], +) -> DoryCommitment +where + SourceIdT: SourceId, +{ + let mut terms_by_source = BTreeMap::::new(); + for (term, gamma) in terms.iter().zip(gamma_powers.iter()) { + let _ = terms_by_source + .entry(term.source_id) + .and_modify(|(_, coefficient)| *coefficient += *gamma) + .or_insert_with(|| (term.commitment.clone(), *gamma)); } - #[tracing::instrument(skip_all, name = "DoryScheme::open_zk")] - fn open_zk( - poly: &Self::Polynomial, - point: &[Fr], - _eval: Fr, - setup: &Self::ProverSetup, - hint: Self::OpeningHint, - transcript: &mut impl Transcript, - ) -> (Self::Proof, Self::HidingCommitment, Self::Blind) { - let num_vars = point.len(); - let adapter = DorySourceAdapter::new(poly); - let sigma = num_vars.div_ceil(2); - let nu = num_vars - sigma; - let (row_commitments, commit_blind) = hint.into_ark_parts(); + let (commitments, scalars): (Vec<_>, Vec<_>) = terms_by_source.into_values().unzip(); + DoryScheme::combine(&commitments, &scalars) +} - let ark_point: Vec = point.iter().rev().map(jolt_fr_to_ark).collect(); - let mut dory_transcript = JoltToDoryTranscript::new(transcript); +fn combine_hint_refs(hints: &[&DoryHint], scalars: &[Fr]) -> DoryHint { + assert_eq!(hints.len(), scalars.len()); + assert!(!hints.is_empty(), "combine_hints: empty hint set"); - let (proof, y_blinding) = - dory::prove::( - &adapter, - &ark_point, - row_commitments, - commit_blind, - nu, - sigma, - &setup.0, - &mut dory_transcript, - ) - .unwrap_or_else(|e| panic!("dory::prove (ZK) failed: {e:?}")); + let num_rows = hints + .iter() + .map(|hint| hint.row_commitments.len()) + .max() + .unwrap_or(0); + + let combined_blind = hints + .iter() + .zip(scalars.iter()) + .map(|(hint, &scalar)| scalar * hint.commit_blind) + .sum(); + + let combined: Vec = (0..num_rows) + .into_par_iter() + .map(|row| { + let mut acc = Bn254G1::default(); + for (hint, &scalar) in hints.iter().zip(scalars.iter()) { + if let Some(row_commitment) = hint.row_commitments.get(row) { + acc += row_commitment.scalar_mul(&scalar); + } + } + acc + }) + .collect(); + + let chunk_len = hints + .iter() + .map(|hint| hint.chunk_len) + .max() + .unwrap_or_default(); + DoryHint::new(combined, combined_blind, chunk_len) +} - let y_com = ark_to_jolt_g1(proof.y_com.expect("ZK proof must contain y_com")); - let blinding = ark_to_jolt_fr(&y_blinding.expect("ZK proof must return y_blinding")); +fn prover_batch_points( + terms: &[ProverBatchOpeningTerm], +) -> (Vec, Vec) { + let first = &terms[0].point; + for term in terms.iter().skip(1) { + assert_eq!( + term.point.public, first.public, + "Dory source-backed batch opening expects one public point", + ); + assert_eq!( + term.point.proof, first.proof, + "Dory source-backed batch opening expects one proof point", + ); + } + (first.public.clone(), first.proof.clone()) +} - (DoryProof(proof), y_com, blinding) +fn verifier_batch_points( + terms: &[VerifierBatchOpeningTerm], +) -> Result<(Vec, Vec), OpeningsError> { + let first = &terms[0].point; + if terms + .iter() + .skip(1) + .any(|term| term.point.public != first.public || term.point.proof != first.proof) + { + return Err(OpeningsError::VerificationFailed); } + Ok((first.public.clone(), first.proof.clone())) +} - #[tracing::instrument(skip_all, name = "DoryScheme::verify_zk")] - fn verify_zk( - commitment: &Self::Output, - point: &[Fr], - proof: &Self::Proof, - setup: &Self::VerifierSetup, - transcript: &mut impl Transcript, - ) -> Result<(), OpeningsError> { - let ark_point: Vec = point.iter().rev().map(jolt_fr_to_ark).collect(); - // In ZK mode dory::verify reads the evaluation commitment from `proof.y_com`, - // so the caller-side eval is unused here. - let dummy_eval = ::zero(); - let ark_commitment = jolt_gt_to_ark(&commitment.0); - let mut dory_transcript = JoltToDoryTranscript::new(transcript); +trait DoryBatchTerm { + type SourceId: SourceId; - dory::verify::( - ark_commitment, - dummy_eval, - &ark_point, - &proof.0, - setup.0.clone().into_inner(), - &mut dory_transcript, - ) - .map_err(|_| OpeningsError::VerificationFailed) + fn source_id(&self) -> Self::SourceId; + fn eval(&self) -> Fr; + fn eval_scale(&self) -> Fr; +} + +impl DoryBatchTerm for ProverBatchOpeningTerm +where + SourceIdT: SourceId, +{ + type SourceId = SourceIdT; + + fn source_id(&self) -> Self::SourceId { + self.source_id + } + + fn eval(&self) -> Fr { + self.eval + } + + fn eval_scale(&self) -> Fr { + self.eval_scale + } +} + +impl DoryBatchTerm + for VerifierBatchOpeningTerm +where + SourceIdT: SourceId, +{ + type SourceId = SourceIdT; + + fn source_id(&self) -> Self::SourceId { + self.source_id + } + + fn eval(&self) -> Fr { + self.eval + } + + fn eval_scale(&self) -> Fr { + self.eval_scale + } +} + +trait IntoDoryBatchTerm { + fn into_claim_id_and_scale(self) -> (ClaimId, Fr); +} + +impl IntoDoryBatchTerm + for ProverBatchOpeningTerm +{ + fn into_claim_id_and_scale(self) -> (ClaimId, Fr) { + (self.claim_id, self.eval_scale) + } +} + +impl IntoDoryBatchTerm + for VerifierBatchOpeningTerm +{ + fn into_claim_id_and_scale(self) -> (ClaimId, Fr) { + (self.claim_id, self.eval_scale) + } +} + +struct DoryBatchOpeningAdapter<'a, S> +where + S: CommitmentSource + ?Sized, +{ + source: &'a S, + output_eval: Fr, + num_vars: usize, +} + +impl DoryPolynomial for DoryBatchOpeningAdapter<'_, S> +where + S: CommitmentSource + ?Sized, +{ + fn num_vars(&self) -> usize { + self.num_vars + } + + fn evaluate(&self, _point: &[ArkFr]) -> ArkFr { + jolt_fr_to_ark(&self.output_eval) + } + + fn commit( + &self, + _nu: usize, + _sigma: usize, + _setup: &DoryNativeProverSetup, + ) -> Result<(E::GT, Vec, ArkFr), DoryError> + where + E: PairingCurve, + Mo: DoryMode, + M1: DoryRoutines, + E::G1: DoryGroup, + { + unimplemented!( + "DoryScheme pre-computes source-backed row commitments before invoking dory::prove; \ + dory::Polynomial::commit on this adapter is not exercised" + ) + } +} + +impl MultilinearLagrange for DoryBatchOpeningAdapter<'_, S> +where + S: CommitmentSource + ?Sized, +{ + fn vector_matrix_product(&self, left_vec: &[ArkFr], _nu: usize, sigma: usize) -> Vec { + let native_left: Vec = left_vec.iter().map(ark_to_jolt_fr).collect(); + let result = self.source.fold_rows(&native_left, 1usize << sigma); + result.iter().map(jolt_fr_to_ark).collect() + } +} + +impl DeriveSetup for PedersenSetup { + fn derive(source: &DoryProverSetup, capacity: usize) -> Self { + assert!( + capacity <= source.0.g1_vec.len(), + "Pedersen capacity ({}) exceeds Dory SRS size ({})", + capacity, + source.0.g1_vec.len(), + ); + let generators = ark_to_jolt_g1_vec(source.0.g1_vec[..capacity].to_vec()); + let blinding = ark_to_jolt_g1(source.0.h1); + PedersenSetup::new(generators, blinding) + } +} + +impl Commitment for DoryScheme { + type Output = DoryCommitment; +} + +impl CommitmentSchemeVerifier for DoryScheme { + type Field = Fr; + type Proof = DoryProof; + type BatchProof = Vec; + type VerifierSetup = DoryVerifierSetup; + + #[tracing::instrument(skip_all, name = "DoryScheme::verify")] + fn verify( + commitment: &Self::Output, + point: &[Fr], + eval: Fr, + proof: &Self::Proof, + setup: &Self::VerifierSetup, + transcript: &mut impl Transcript, + ) -> Result<(), OpeningsError> { + let mut dory_transcript = JoltToDoryTranscript::new(transcript); + Self::verify_with_transcript(commitment, point, eval, proof, setup, &mut dory_transcript) + } + + fn verify_batch( + claims: Vec>, + proof: &Self::BatchProof, + setup: &Self::VerifierSetup, + transcript: &mut impl Transcript, + ) -> Result<(), OpeningsError> { + homomorphic_verify_batch::(claims, proof, setup, transcript) + } + + fn bind_opening_inputs( + transcript: &mut impl Transcript, + point: &[Self::Field], + eval: &Self::Field, + ) { + transcript.append(&LabelWithCount(b"dory_opening_point", point.len() as u64)); + for p in point { + p.append_to_transcript(transcript); + } + transcript.append(&Label(b"dory_opening_eval")); + eval.append_to_transcript(transcript); + } +} + +impl VerifierSetupFromPublicParams for DoryScheme { + type PublicParams = usize; + + fn verifier_setup_from_public_params(max_num_vars: Self::PublicParams) -> DoryVerifierSetup { + Self::setup_verifier(max_num_vars) + } +} + +impl CommitmentScheme for DoryScheme { + type ProverSetup = DoryProverSetup; + type OpeningHint = DoryHint; + type SetupParams = usize; + + fn setup(max_num_vars: Self::SetupParams) -> (DoryProverSetup, DoryVerifierSetup) { + let prover = Self::setup_prover(max_num_vars); + let verifier = Self::prover_to_verifier_setup(&prover); + (prover, verifier) + } + + fn prover_to_verifier_setup(prover_setup: &DoryProverSetup) -> DoryVerifierSetup { + DoryVerifierSetup(prover_setup.0.to_verifier_setup()) + } + + #[tracing::instrument(skip_all, name = "DoryScheme::commit")] + fn commit + ?Sized>( + source: &S, + setup: &Self::ProverSetup, + ) -> (Self::Output, Self::OpeningHint) { + Self::commit_with_mode::(source, &setup.0) + } + + #[tracing::instrument(skip_all, name = "DoryScheme::commit_batch")] + fn commit_batch>( + batch: &B, + ids: &[B::Id], + setup: &Self::ProverSetup, + ) -> Vec<(Self::Output, Self::OpeningHint)> { + Self::commit_batch_with_mode::(batch, ids, &setup.0) + } + + #[tracing::instrument(skip_all, name = "DoryScheme::open")] + fn open( + poly: &S, + point: &[Fr], + _eval: Fr, + setup: &Self::ProverSetup, + hint: Option, + transcript: &mut impl Transcript, + ) -> Self::Proof + where + S: CommitmentSource + ?Sized, + { + let hint = if let Some(hint) = hint { + hint + } else { + let chunk_len = natural_or_balanced_chunk_len(poly); + DoryHint::new( + ark_to_jolt_g1_vec(compute_row_commitments(poly, chunk_len, &setup.0)), + Fr::from_u64(0), + chunk_len, + ) + }; + debug_assert!( + hint.commit_blind == Fr::from_u64(0), + "commit_blind should be 0 for transparent mode" + ); + let mut dory_transcript = JoltToDoryTranscript::new(transcript); + Self::open_source_with_hint(poly, point, setup, hint, &mut dory_transcript) + } + + fn prove_batch( + claims: Vec>, + hints: Vec, + setup: &Self::ProverSetup, + transcript: &mut impl Transcript, + ) -> Self::BatchProof + where + S: CommitmentSource, + { + homomorphic_prove_batch::(claims, hints, setup, transcript) + } +} + +impl LinearOpeningSchemeVerifier for DoryScheme { + fn verify_batch_opening( + terms: Vec>, + proof: &Self::BatchProof, + setup: &Self::VerifierSetup, + transcript: &mut impl Transcript, + ) -> Result, OpeningsError> + where + SourceIdT: SourceId, + { + Self::verify_source_backed_batch_with_mode::<_, _, _, TransparentBatchOpening>( + terms, proof, setup, transcript, + ) + } +} + +impl LinearOpeningScheme for DoryScheme { + fn prove_batch_opening( + terms: Vec>, + source_batch: &mut B, + setup: &Self::ProverSetup, + transcript: &mut impl Transcript, + ) -> BatchOpeningProverResult + where + B: LinearCombinationOpeningSource, + { + let (proof, public, _joint_claim, _blind) = + Self::prove_source_backed_batch_with_mode::<_, _, _, TransparentBatchOpening>( + terms, + source_batch, + setup, + transcript, + ); + BatchOpeningProverResult { + proof: vec![proof], + public, + } + } +} + +impl AdditivelyHomomorphicVerifier for DoryScheme { + #[tracing::instrument(skip_all, name = "DoryScheme::combine")] + fn combine(commitments: &[Self::Output], scalars: &[Self::Field]) -> Self::Output { + assert_eq!(commitments.len(), scalars.len()); + + let combined = commitments + .par_iter() + .zip(scalars.par_iter()) + .map(|(c, s)| jolt_fr_to_ark(s) * jolt_gt_to_ark(&c.0)) + .reduce(ArkGT::identity, |acc, x| acc + x); + + DoryCommitment(ark_to_jolt_gt(&combined)) + } +} + +impl AdditivelyHomomorphic for DoryScheme { + #[tracing::instrument(skip_all, name = "DoryScheme::combine_hints")] + fn combine_hints(hints: Vec, scalars: &[Self::Field]) -> Self::OpeningHint { + let hint_refs: Vec<&DoryHint> = hints.iter().collect(); + combine_hint_refs(&hint_refs, scalars) + } +} + +impl ZkOpeningSchemeVerifier for DoryScheme { + type HidingCommitment = Bn254G1; + + #[tracing::instrument(skip_all, name = "DoryScheme::verify_zk")] + fn verify_zk( + commitment: &Self::Output, + point: &[Fr], + proof: &Self::Proof, + setup: &Self::VerifierSetup, + transcript: &mut impl Transcript, + ) -> Result<(), OpeningsError> { + let mut dory_transcript = JoltToDoryTranscript::new(transcript); + Self::verify_zk_with_transcript(commitment, point, proof, setup, &mut dory_transcript) + } + + fn verify_batch_zk( + claims: Vec>, + proof: &Self::BatchProof, + setup: &Self::VerifierSetup, + transcript: &mut impl Transcript, + ) -> Result<(), OpeningsError> { + let [claim] = claims.as_slice() else { + return Err(OpeningsError::VerificationFailed); + }; + let [proof] = proof.as_slice() else { + return Err(OpeningsError::VerificationFailed); + }; + Self::verify_zk(&claim.commitment, &claim.point, proof, setup, transcript) + } + + fn bind_zk_opening_inputs( + transcript: &mut impl Transcript, + point: &[Self::Field], + hiding_commitment: &Self::HidingCommitment, + ) { + transcript.append(&LabelWithCount(b"dory_opening_point", point.len() as u64)); + for p in point { + p.append_to_transcript(transcript); + } + transcript.append(&Label(b"dory_eval_commitment")); + hiding_commitment.append_to_transcript(transcript); + } +} + +impl ZkOpeningScheme for DoryScheme { + type Blind = Fr; + + fn commit_zk + ?Sized>( + source: &S, + setup: &Self::ProverSetup, + ) -> (Self::Output, Self::OpeningHint) { + Self::commit_with_mode::(source, &setup.0) + } + + #[tracing::instrument(skip_all, name = "DoryScheme::commit_batch_zk")] + fn commit_batch_zk>( + batch: &B, + ids: &[B::Id], + setup: &Self::ProverSetup, + ) -> Vec<(Self::Output, Self::OpeningHint)> { + Self::commit_batch_with_mode::(batch, ids, &setup.0) + } + + #[tracing::instrument(skip_all, name = "DoryScheme::open_zk")] + fn open_zk( + poly: &S, + point: &[Fr], + _eval: Fr, + setup: &Self::ProverSetup, + hint: Self::OpeningHint, + transcript: &mut impl Transcript, + ) -> (Self::Proof, Self::HidingCommitment, Self::Blind) + where + S: CommitmentSource + ?Sized, + { + let mut dory_transcript = JoltToDoryTranscript::new(transcript); + Self::open_zk_source_with_hint(poly, point, setup, hint, &mut dory_transcript) + } + + fn prove_batch_zk( + claims: Vec>, + hints: Vec, + setup: &Self::ProverSetup, + transcript: &mut impl Transcript, + ) -> (Self::BatchProof, Self::HidingCommitment, Self::Blind) + where + S: CommitmentSource, + { + let [claim] = claims.as_slice() else { + panic!("Dory ZK batch opening expects one already-combined claim"); + }; + let [hint] = hints.as_slice() else { + panic!("Dory ZK batch opening expects one already-combined hint"); + }; + let (proof, y_com, y_blinding) = Self::open_zk( + &claim.polynomial, + &claim.point, + claim.eval, + setup, + hint.clone(), + transcript, + ); + (vec![proof], y_com, y_blinding) + } +} + +impl ZkLinearOpeningSchemeVerifier for DoryScheme { + fn verify_batch_opening_zk( + terms: Vec>, + proof: &Self::BatchProof, + setup: &Self::VerifierSetup, + transcript: &mut impl Transcript, + ) -> Result, OpeningsError> + where + SourceIdT: SourceId, + { + Self::verify_source_backed_batch_with_mode::<_, _, _, ZkBatchOpening>( + terms, proof, setup, transcript, + ) + } +} + +impl ZkLinearOpeningScheme for DoryScheme { + fn prove_batch_opening_zk( + terms: Vec>, + source_batch: &mut B, + setup: &Self::ProverSetup, + transcript: &mut impl Transcript, + ) -> ZkBatchOpeningProverResult + where + B: LinearCombinationOpeningSource, + { + let (proof, public, joint_claim, y_blinding) = + Self::prove_source_backed_batch_with_mode::<_, _, _, ZkBatchOpening>( + terms, + source_batch, + setup, + transcript, + ); + let y_blinding = y_blinding.expect("ZK source-backed Dory proof must return y_blinding"); + ZkBatchOpeningProverResult { + proof: vec![proof], + public, + witness: ZkBatchOpeningWitness { + output_values: vec![joint_claim], + output_blinds: vec![y_blinding], + }, + } + } +} + +impl EvaluationCommitmentScheme for DoryScheme { + fn batch_eval_commitment(proof: &Self::BatchProof) -> Option { + let [proof] = proof.as_slice() else { + return None; + }; + proof.0.y_com.as_ref().copied().map(ark_to_jolt_g1) + } + + fn eval_commitment_gens_verifier(setup: &Self::VerifierSetup) -> Option<(Bn254G1, Bn254G1)> { + Some((ark_to_jolt_g1(setup.0.g1_0), ark_to_jolt_g1(setup.0.h1))) + } +} + +impl EvaluationCommitmentProver for DoryScheme { + fn eval_commitment_gens(setup: &Self::ProverSetup) -> Option<(Bn254G1, Bn254G1)> { + let g1_0 = setup.0.g1_vec.first().copied().map(ark_to_jolt_g1)?; + Some((g1_0, ark_to_jolt_g1(setup.0.h1))) + } + + fn zk_generators(setup: &Self::ProverSetup, count: usize) -> Option<(Vec, Bn254G1)> { + let count = count.min(setup.0.g1_vec.len()); + let g1s = ark_to_jolt_g1_vec(setup.0.g1_vec[..count].to_vec()); + Some((g1s, ark_to_jolt_g1(setup.0.h1))) + } +} + +enum DoryChunkCommitment { + Dense(ArkG1), + OneHot(Vec), +} + +struct CommitRowContext<'a> { + setup: &'a ArkworksProverSetup, + g1_bases_affine: Vec, +} + +impl<'a> CommitRowContext<'a> { + fn new(setup: &'a ArkworksProverSetup, row_len: usize) -> Self { + Self { + setup, + g1_bases_affine: g1_bases_affine(setup, row_len), + } + } +} + +/// Dense commit: full MSM per row, parallel over rows. +fn commit_rows_dense + ?Sized>( + source: &S, + chunk_len: usize, + setup: &ArkworksProverSetup, +) -> Vec { + let ctx = CommitRowContext::new(setup, chunk_len); + + let chunks = source.map_rows(chunk_len, |_, row| commit_source_row(row, &ctx)); + flatten_chunks(chunks) +} + +fn commit_field_row(values: &[Fr], setup: &ArkworksProverSetup) -> ArkG1 { + assert!( + values.len() <= setup.g1_vec.len(), + "Dory row length ({}) exceeds G1 SRS size ({})", + values.len(), + setup.g1_vec.len(), + ); + let scalars: Vec = values.iter().map(jolt_fr_to_ark).collect(); + JoltG1Routines::msm(&setup.g1_vec[..scalars.len()], &scalars) +} + +fn strided_affine_bases( + values_len: usize, + column_stride: usize, + ctx: &CommitRowContext<'_>, +) -> Vec { + assert!( + column_stride > 0, + "Dory strided row column stride must be nonzero" + ); + assert!( + values_len == 0 || (values_len - 1) * column_stride < ctx.g1_bases_affine.len(), + "Dory strided row length ({values_len}) with stride ({column_stride}) exceeds G1 SRS row size ({})", + ctx.g1_bases_affine.len(), + ); + ctx.g1_bases_affine + .iter() + .step_by(column_stride) + .take(values_len) + .copied() + .collect() +} + +fn commit_strided_field_row( + values: &[Fr], + column_stride: usize, + ctx: &CommitRowContext<'_>, +) -> ArkG1 { + assert!( + column_stride > 0, + "Dory strided row column stride must be nonzero" + ); + assert!( + values.is_empty() || (values.len() - 1) * column_stride < ctx.setup.g1_vec.len(), + "Dory strided row length ({}) with stride ({column_stride}) exceeds G1 SRS size ({})", + values.len(), + ctx.setup.g1_vec.len(), + ); + let bases: Vec = ctx + .setup + .g1_vec + .iter() + .step_by(column_stride) + .take(values.len()) + .copied() + .collect(); + let scalars: Vec = values.iter().map(jolt_fr_to_ark).collect(); + JoltG1Routines::msm(&bases, &scalars) +} + +fn commit_i128_row(values: &[i128], ctx: &CommitRowContext<'_>) -> ArkG1 { + assert!( + values.len() <= ctx.g1_bases_affine.len(), + "Dory row length ({}) exceeds G1 SRS size ({})", + values.len(), + ctx.g1_bases_affine.len(), + ); + ArkG1Struct(msm_i128::( + &ctx.g1_bases_affine[..values.len()], + values, + true, + )) +} + +fn commit_strided_i128_row( + values: &[i128], + column_stride: usize, + ctx: &CommitRowContext<'_>, +) -> ArkG1 { + let bases = strided_affine_bases(values.len(), column_stride, ctx); + ArkG1Struct(msm_i128::(&bases, values, true)) +} + +fn commit_u64_row(values: &[u64], ctx: &CommitRowContext<'_>) -> ArkG1 { + assert!( + values.len() <= ctx.g1_bases_affine.len(), + "Dory row length ({}) exceeds G1 SRS size ({})", + values.len(), + ctx.g1_bases_affine.len(), + ); + ArkG1Struct(msm_u64::( + &ctx.g1_bases_affine[..values.len()], + values, + true, + )) +} + +fn commit_strided_u64_row( + values: &[u64], + column_stride: usize, + ctx: &CommitRowContext<'_>, +) -> ArkG1 { + let bases = strided_affine_bases(values.len(), column_stride, ctx); + ArkG1Struct(msm_u64::(&bases, values, true)) +} + +/// One-hot commit: O(T) group additions for unit-valued one-hot polynomials. +fn commit_rows_one_hot + ?Sized>( + source: &S, + num_rows: usize, + num_cols: usize, + setup: &ArkworksProverSetup, +) -> Vec { + let g1_bases = &setup.g1_vec[..num_cols]; + + let mut cols_per_row: Vec> = vec![Vec::new(); num_rows]; + source.for_each_one(|flat_idx: usize| { + let row = flat_idx / num_cols; + let col = flat_idx % num_cols; + debug_assert!( + row < num_rows && col < num_cols, + "for_each_one out-of-bounds flat_idx: row={row} num_rows={num_rows} col={col} num_cols={num_cols}", + ); + cols_per_row[row].push(col); + }); + + cols_per_row + .par_iter() + .map(|cols| { + cols.iter() + .fold(::G1::identity(), |acc, &col| { + ::G1::add(&acc, &g1_bases[col]) + }) + }) + .collect() +} + +fn commit_one_hot_row(row: OneHotRow<'_>, ctx: &CommitRowContext<'_>) -> Vec { + let k = 1usize << row.log_domain_size; + let num_columns = match row.entries { + OneHotEntries::OnePerColumn(indices) => indices.len(), + OneHotEntries::MaybeZero(indices) => indices.len(), + }; + assert!( + num_columns <= ctx.g1_bases_affine.len(), + "Dory one-hot row length ({}) exceeds G1 SRS size ({})", + num_columns, + ctx.g1_bases_affine.len(), + ); + + let mut columns_by_hot_index: Vec> = vec![Vec::new(); k]; + match row.entries { + OneHotEntries::OnePerColumn(indices) => { + for (column, hot_index) in indices.iter().enumerate() { + columns_by_hot_index[hot_index.get()].push(column); + } + } + OneHotEntries::MaybeZero(indices) => { + for (column, hot_index) in indices.iter().enumerate() { + if let Some(hot_index) = hot_index { + columns_by_hot_index[hot_index.get()].push(column); + } + } + } + } + + batch_g1_additions_multi(&ctx.g1_bases_affine[..num_columns], &columns_by_hot_index) + .into_iter() + .map(|affine| ArkG1Struct(affine.into())) + .collect() +} + +fn g1_bases_affine(setup: &ArkworksProverSetup, len: usize) -> Vec { + setup.g1_vec[..len] + .par_iter() + .map(|base| base.0.into_affine()) + .collect() +} + +fn commit_source_row(row: SourceRow<'_, Fr>, ctx: &CommitRowContext<'_>) -> DoryChunkCommitment { + match row { + SourceRow::StridedFieldElements { + values, + column_stride, + } => { + let commitment = if column_stride == 1 { + commit_field_row(values, ctx.setup) + } else { + commit_strided_field_row(values, column_stride, ctx) + }; + DoryChunkCommitment::Dense(commitment) + } + SourceRow::StridedI128 { + values, + column_stride, + } => { + let commitment = if column_stride == 1 { + commit_i128_row(values, ctx) + } else { + commit_strided_i128_row(values, column_stride, ctx) + }; + DoryChunkCommitment::Dense(commitment) + } + SourceRow::StridedU64 { + values, + column_stride, + } => { + let commitment = if column_stride == 1 { + commit_u64_row(values, ctx) + } else { + commit_strided_u64_row(values, column_stride, ctx) + }; + DoryChunkCommitment::Dense(commitment) + } + SourceRow::OneHot(row) => DoryChunkCommitment::OneHot(commit_one_hot_row(row, ctx)), + } +} + +fn flatten_chunks(chunks: Vec) -> Vec { + let Some(first) = chunks.first() else { + return Vec::new(); + }; + + match first { + DoryChunkCommitment::Dense(_) => chunks + .into_iter() + .map(|chunk| match chunk { + DoryChunkCommitment::Dense(row_commitment) => row_commitment, + DoryChunkCommitment::OneHot(_) => { + panic!("source mixed dense and one-hot rows during commitment"); + } + }) + .collect(), + DoryChunkCommitment::OneHot(first) => { + let rows_per_hot_index = chunks.len(); + let k = first.len(); + let mut row_commitments = + vec![::G1::identity(); rows_per_hot_index * k]; + for (chunk_index, chunk) in chunks.into_iter().enumerate() { + match chunk { + DoryChunkCommitment::OneHot(commitments) => { + assert_eq!( + commitments.len(), + k, + "source changed one-hot domain size during commitment", + ); + for (hot_index, row_commitment) in commitments.into_iter().enumerate() { + row_commitments[chunk_index + hot_index * rows_per_hot_index] = + row_commitment; + } + } + DoryChunkCommitment::Dense(_) => { + panic!("source mixed dense and one-hot rows during commitment"); + } + } + } + row_commitments + } + } +} + +fn aggregate_batch_chunks( + chunks: Vec, + chunk_len: usize, + setup: &ArkworksProverSetup, +) -> (DoryCommitment, DoryHint) { + assert!(!chunks.is_empty(), "cannot aggregate an empty source"); + + match &chunks[0] { + DoryChunkCommitment::Dense(_) => { + let mut row_commitments = Vec::with_capacity(chunks.len()); + for chunk in chunks { + match chunk { + DoryChunkCommitment::Dense(row_commitment) => { + row_commitments.push(row_commitment); + } + DoryChunkCommitment::OneHot(_) => { + panic!("batch source mixed dense and one-hot rows for one source"); + } + } + } + finish_row_commitments::(row_commitments, chunk_len, setup) + } + DoryChunkCommitment::OneHot(first) => { + let rows_per_hot_index = chunks.len(); + let k = first.len(); + let mut row_commitments = + vec![::G1::identity(); rows_per_hot_index * k]; + + let chunks: Vec> = chunks + .into_iter() + .map(|chunk| match chunk { + DoryChunkCommitment::OneHot(commitments) => { + assert_eq!( + commitments.len(), + k, + "batch source changed one-hot domain size within one source", + ); + commitments + } + DoryChunkCommitment::Dense(_) => { + panic!("batch source mixed dense and one-hot rows for one source"); + } + }) + .collect(); + + for (chunk_index, commitments) in chunks.iter().enumerate() { + row_commitments + .par_iter_mut() + .skip(chunk_index) + .step_by(rows_per_hot_index) + .zip(commitments.par_iter()) + .for_each(|(dest, src)| *dest = *src); + } + finish_row_commitments::(row_commitments, chunk_len, setup) + } } } -/// Dense commit: full MSM per row, parallel over rows. -fn commit_rows_dense + ?Sized>( - poly: &P, - sigma: usize, +fn finish_row_commitments( + row_commitments: Vec, + chunk_len: usize, setup: &ArkworksProverSetup, -) -> Vec { - let num_cols = 1usize << sigma; - let g1_bases = &setup.g1_vec[..num_cols]; - - let mut rows: Vec> = Vec::new(); - poly.for_each_row(sigma, &mut |_, row| rows.push(row.to_vec())); +) -> (DoryCommitment, DoryHint) { + let (tier_2, commit_blind) = commit_rows_tier_2::(&row_commitments, setup); + ( + DoryCommitment(ark_to_jolt_gt(&tier_2)), + DoryHint::new( + ark_to_jolt_g1_vec(row_commitments), + ark_to_jolt_fr(&commit_blind), + chunk_len, + ), + ) +} - rows.par_iter() - .map(|row| { - let scalars: Vec = row.iter().map(jolt_fr_to_ark).collect(); - G1Routines::msm(&g1_bases[..scalars.len()], &scalars) - }) - .collect() +fn balanced_chunk_len(num_vars: usize) -> usize { + 1usize << num_vars.div_ceil(2) } -/// One-hot commit: O(T) group additions for unit-valued one-hot polynomials. -fn commit_rows_one_hot + ?Sized>( - poly: &P, - num_rows: usize, - num_cols: usize, - setup: &ArkworksProverSetup, -) -> Vec { - let g1_bases = &setup.g1_vec[..num_cols]; +fn validate_chunk_len(chunk_len: usize) { + assert!( + chunk_len.is_power_of_two(), + "Dory commitment chunk length ({chunk_len}) must be a power of two", + ); +} - let mut cols_per_row: Vec> = vec![Vec::new(); num_rows]; - poly.for_each_one(&mut |flat_idx| { - let row = flat_idx / num_cols; - let col = flat_idx % num_cols; - debug_assert!( - row < num_rows && col < num_cols, - "for_each_one out-of-bounds flat_idx: row={row} num_rows={num_rows} col={col} num_cols={num_cols}", - ); - cols_per_row[row].push(col); - }); +fn natural_or_balanced_chunk_len + ?Sized>(source: &S) -> usize { + let chunk_len = source + .natural_chunk_len() + .unwrap_or_else(|| balanced_chunk_len(source.num_vars())); + validate_chunk_len(chunk_len); + chunk_len +} - cols_per_row - .par_iter() - .map(|cols| { - cols.iter() - .fold(::G1::identity(), |acc, &col| { - ::G1::add(&acc, &g1_bases[col]) - }) - }) - .collect() +fn hint_shape(hint: &DoryHint) -> (usize, usize) { + validate_chunk_len(hint.chunk_len); + assert!( + hint.row_commitments.len().is_power_of_two(), + "Dory hint row count ({}) must be a power of two", + hint.row_commitments.len(), + ); + let sigma = hint.chunk_len.trailing_zeros() as usize; + let nu = hint.row_commitments.len().trailing_zeros() as usize; + (nu, sigma) } -fn compute_row_commitments + ?Sized>( - poly: &P, - setup: &DoryProverSetup, +fn compute_row_commitments + ?Sized>( + source: &S, + chunk_len: usize, + setup: &ArkworksProverSetup, ) -> Vec { - let num_vars = poly.num_vars(); - let sigma = num_vars.div_ceil(2); - let num_cols = 1usize << sigma; - let num_rows = 1usize << (num_vars - sigma); - - if poly.is_one_hot() { - commit_rows_one_hot(poly, num_rows, num_cols, &setup.0) + let num_vars = source.num_vars(); + let sigma = chunk_len.trailing_zeros() as usize; + let num_rows = 1usize << num_vars.saturating_sub(sigma); + if source.is_one_hot() { + commit_rows_one_hot(source, num_rows, chunk_len, setup) } else { - commit_rows_dense(poly, sigma, &setup.0) + commit_rows_dense(source, chunk_len, setup) } } pub(crate) fn commit_rows_tier_2( row_commitments: &[ArkG1], - setup: &DoryProverSetup, + setup: &ArkworksProverSetup, ) -> (ArkGT, ArkFr) { - let g2_bases = &setup.0.g2_vec[..row_commitments.len()]; + let g2_bases = &setup.g2_vec[..row_commitments.len()]; let tier_2 = ::multi_pair_g2_setup(row_commitments, g2_bases); let commit_blind = M::sample::(); - let tier_2 = M::mask(tier_2, &setup.0.ht, &commit_blind); + let tier_2 = M::mask(tier_2, &setup.ht, &commit_blind); (tier_2, commit_blind) } @@ -449,25 +1657,25 @@ impl DoryHint { } } -/// Bridges [`MultilinearPoly`] to dory-pcs's polynomial traits +/// Adapts [`CommitmentSource`] to dory-pcs's polynomial traits /// without materializing the full evaluation table. -struct DorySourceAdapter<'a, S: MultilinearPoly> { +struct DorySourceAdapter<'a, S: CommitmentSource + ?Sized> { source: &'a S, } -impl<'a, S: MultilinearPoly> DorySourceAdapter<'a, S> { +impl<'a, S: CommitmentSource + ?Sized> DorySourceAdapter<'a, S> { fn new(source: &'a S) -> Self { Self { source } } } -impl> DoryPolynomial for DorySourceAdapter<'_, S> { +impl + ?Sized> DoryPolynomial for DorySourceAdapter<'_, S> { fn num_vars(&self) -> usize { self.source.num_vars() } fn evaluate(&self, point: &[ArkFr]) -> ArkFr { - let native_point: Vec = point.iter().map(ark_to_jolt_fr).collect(); + let native_point: Vec = point.iter().rev().map(ark_to_jolt_fr).collect(); jolt_fr_to_ark(&self.source.evaluate(&native_point)) } @@ -475,11 +1683,11 @@ impl> DoryPolynomial for DorySourceAdapter<'_, S> &self, _nu: usize, _sigma: usize, - _setup: &dory::setup::ProverSetup, - ) -> Result<(E::GT, Vec, ArkFr), dory::error::DoryError> + _setup: &DoryNativeProverSetup, + ) -> Result<(E::GT, Vec, ArkFr), DoryError> where E: PairingCurve, - Mo: dory::mode::Mode, + Mo: DoryMode, M1: DoryRoutines, E::G1: DoryGroup, { @@ -490,10 +1698,10 @@ impl> DoryPolynomial for DorySourceAdapter<'_, S> } } -impl> MultilinearLagrange for DorySourceAdapter<'_, S> { +impl + ?Sized> MultilinearLagrange for DorySourceAdapter<'_, S> { fn vector_matrix_product(&self, left_vec: &[ArkFr], _nu: usize, sigma: usize) -> Vec { let native_left: Vec = left_vec.iter().map(ark_to_jolt_fr).collect(); - let result = self.source.fold_rows(&native_left, sigma); + let result = self.source.fold_rows(&native_left, 1usize << sigma); result.iter().map(jolt_fr_to_ark).collect() } } @@ -501,12 +1709,169 @@ impl> MultilinearLagrange for DorySourceAdapter<'_ #[cfg(test)] mod tests { use super::*; - use jolt_crypto::{Pedersen, VectorCommitment}; + use jolt_crypto::{Bn254, JoltGroup, Pedersen, VectorCommitment}; use jolt_field::{FromPrimitiveInt, RandomSampling}; - use jolt_poly::Polynomial; + use jolt_openings::{ + BatchOpeningPoint, BatchOpeningSource, LinearCombinationOpeningSource, + MaterializedLinearCombination, ProverBatchOpeningTerm, SourceRow, VerifierBatchOpeningTerm, + }; + use jolt_poly::{MultilinearPoly, Polynomial}; + use jolt_transcript::Blake2bTranscript; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; + struct FoldOnlySource { + poly: Polynomial, + } + + struct U64Source { + evaluations: Vec, + } + + struct StridedU64Source { + rows: Vec>, + dense: Polynomial, + column_stride: usize, + } + + struct TestOpeningBatch { + polynomials: Vec>, + hints: Vec, + } + + impl BatchOpeningSource for TestOpeningBatch { + type Id = usize; + type Source<'a> + = &'a Polynomial + where + Self: 'a; + + fn source(&self, id: Self::Id) -> Self::Source<'_> { + &self.polynomials[id] + } + + fn opening_hint(&self, id: Self::Id) -> &DoryHint { + &self.hints[id] + } + } + + impl LinearCombinationOpeningSource for TestOpeningBatch { + type LinearCombination<'a> + = MaterializedLinearCombination + where + Self: 'a; + + fn linear_combination<'a>( + &'a mut self, + terms: &[LinearSourceTerm], + ) -> Self::LinearCombination<'a> { + MaterializedLinearCombination::new(self, terms) + } + } + + impl U64Source { + fn field_evaluation(&self, point: &[Fr]) -> Fr { + let dense: Vec = self + .evaluations + .iter() + .map(|&value| Fr::from_u64(value)) + .collect(); + MultilinearPoly::evaluate(&dense, point) + } + } + + impl CommitmentSource for U64Source { + fn num_vars(&self) -> usize { + self.evaluations.len().ilog2() as usize + } + + fn evaluate(&self, point: &[Fr]) -> Fr { + self.field_evaluation(point) + } + + fn for_each_row(&self, chunk_len: usize, mut visit: V) + where + V: for<'row> FnMut(usize, SourceRow<'row, Fr>), + { + for (row_index, row) in self.evaluations.chunks(chunk_len).enumerate() { + visit( + row_index, + SourceRow::StridedU64 { + values: row, + column_stride: 1, + }, + ); + } + } + + fn fold_rows(&self, left: &[Fr], chunk_len: usize) -> Vec { + let mut result = vec![Fr::from_u64(0); chunk_len]; + for (row_index, row) in self.evaluations.chunks(chunk_len).enumerate() { + let weight = left[row_index]; + for (dest, &value) in result.iter_mut().zip(row) { + *dest += Fr::from_u64(value) * weight; + } + } + result + } + } + + impl CommitmentSource for StridedU64Source { + fn num_vars(&self) -> usize { + self.dense.num_vars() + } + + fn evaluate(&self, point: &[Fr]) -> Fr { + self.dense.evaluate(point) + } + + fn natural_chunk_len(&self) -> Option { + Some(self.rows[0].len() * self.column_stride) + } + + fn for_each_row(&self, _chunk_len: usize, mut visit: V) + where + V: for<'row> FnMut(usize, SourceRow<'row, Fr>), + { + for (row_index, row) in self.rows.iter().enumerate() { + visit( + row_index, + SourceRow::StridedU64 { + values: row, + column_stride: self.column_stride, + }, + ); + } + } + + fn fold_rows(&self, left: &[Fr], chunk_len: usize) -> Vec { + let sigma = chunk_len.trailing_zeros() as usize; + MultilinearPoly::fold_rows(&self.dense, left, sigma) + } + } + + impl CommitmentSource for FoldOnlySource { + fn num_vars(&self) -> usize { + self.poly.num_vars() + } + + fn evaluate(&self, point: &[Fr]) -> Fr { + self.poly.evaluate(point) + } + + fn for_each_row(&self, _chunk_len: usize, _visit: V) + where + V: for<'row> FnMut(usize, SourceRow<'row, Fr>), + { + panic!("single-claim prove_batch must not materialize source rows") + } + + fn fold_rows(&self, left: &[Fr], chunk_len: usize) -> Vec { + let sigma = chunk_len.trailing_zeros() as usize; + MultilinearPoly::fold_rows(&self.poly, left, sigma) + } + } + #[test] fn commit_open_verify_round_trip() { let num_vars = 4; @@ -523,7 +1888,7 @@ mod tests { let (commitment, hint) = DoryScheme::commit(poly.evaluations(), &prover_setup); - let mut prove_transcript = jolt_transcript::Blake2bTranscript::new(b"test"); + let mut prove_transcript = Blake2bTranscript::new(b"test"); let proof = DoryScheme::open( &poly, &point, @@ -533,7 +1898,7 @@ mod tests { &mut prove_transcript, ); - let mut verify_transcript = jolt_transcript::Blake2bTranscript::new(b"test"); + let mut verify_transcript = Blake2bTranscript::new(b"test"); let result = DoryScheme::verify( &commitment, &point, @@ -545,6 +1910,72 @@ mod tests { assert!(result.is_ok(), "Verification failed: {result:?}"); } + #[test] + fn u64_source_commit_open_verify_round_trip() { + let num_vars = 4; + let prover_setup = DoryScheme::setup_prover(num_vars); + let verifier_setup = DoryVerifierSetup(prover_setup.0.to_verifier_setup()); + + let source = U64Source { + evaluations: (0..(1 << num_vars)).map(|value| value as u64).collect(), + }; + let point: Vec = (0..num_vars) + .map(|idx| Fr::from_u64((idx + 2) as u64)) + .collect(); + let eval = source.evaluate(&point); + + let (commitment, hint) = DoryScheme::commit(&source, &prover_setup); + let mut prove_transcript = Blake2bTranscript::new(b"test-u64"); + let proof = DoryScheme::open( + &source, + &point, + eval, + &prover_setup, + Some(hint), + &mut prove_transcript, + ); + + let mut verify_transcript = Blake2bTranscript::new(b"test-u64"); + let result = DoryScheme::verify( + &commitment, + &point, + eval, + &proof, + &verifier_setup, + &mut verify_transcript, + ); + + assert!(result.is_ok()); + } + + #[test] + fn strided_u64_commit_matches_dense_zero_padded_rows() { + let prover_setup = DoryScheme::setup_prover(6); + let mut dense = vec![Fr::from_u64(0); 16]; + dense[0] = Fr::from_u64(3); + dense[4] = Fr::from_u64(5); + dense[8] = Fr::from_u64(7); + dense[12] = Fr::from_u64(11); + + let source = StridedU64Source { + rows: vec![vec![3, 5], vec![7, 11]], + dense: Polynomial::new(dense.clone()), + column_stride: 4, + }; + let dense_source = Polynomial::new(dense); + + let (strided_commitment, strided_hint) = DoryScheme::commit(&source, &prover_setup); + let dense_wrapped = StridedU64Source { + rows: vec![vec![3, 0, 0, 0, 5, 0, 0, 0], vec![7, 0, 0, 0, 11, 0, 0, 0]], + dense: dense_source, + column_stride: 1, + }; + let (dense_commitment, dense_hint) = DoryScheme::commit(&dense_wrapped, &prover_setup); + + assert_eq!(strided_commitment, dense_commitment); + assert_eq!(strided_hint.row_commitments, dense_hint.row_commitments); + } + #[test] fn combine_commitments_homomorphic() { let num_vars = 2; @@ -580,6 +2011,137 @@ mod tests { ); } + #[test] + fn combine_hints_zero_pads_ragged_rows() { + let g = Bn254::g1_generator(); + let h = g.scalar_mul(&Fr::from_u64(11)); + let k = g.scalar_mul(&Fr::from_u64(13)); + let a = Fr::from_u64(2); + let b = Fr::from_u64(7); + + let hint_a = DoryHint::new(vec![g], Fr::from_u64(3), 4); + let hint_b = DoryHint::new(vec![h, k], Fr::from_u64(5), 8); + + let combined = DoryScheme::combine_hints(vec![hint_a, hint_b], &[a, b]); + + assert_eq!(combined.row_commitments.len(), 2); + assert_eq!( + combined.row_commitments[0], + g.scalar_mul(&a) + h.scalar_mul(&b) + ); + assert_eq!(combined.row_commitments[1], k.scalar_mul(&b)); + assert_eq!( + combined.commit_blind, + a * Fr::from_u64(3) + b * Fr::from_u64(5), + "combined hint blind must match the same linear combination" + ); + assert_eq!(combined.chunk_len, 8); + } + + #[test] + fn generic_open_preserves_jolt_point_order() { + let num_vars = 3; + let prover_setup = DoryScheme::setup_prover(num_vars); + let verifier_setup = DoryVerifierSetup(prover_setup.0.to_verifier_setup()); + + // f(x0, x1, x2) = x0. Reversing the opening point evaluates x2 + // instead, so this catches accidental Dory/Jolt point-order swaps. + let evals = vec![ + Fr::from_u64(0), + Fr::from_u64(0), + Fr::from_u64(0), + Fr::from_u64(0), + Fr::from_u64(1), + Fr::from_u64(1), + Fr::from_u64(1), + Fr::from_u64(1), + ]; + let poly = Polynomial::new(evals); + let point = vec![Fr::from_u64(2), Fr::from_u64(3), Fr::from_u64(5)]; + let eval = poly.evaluate(&point); + + let (commitment, hint) = DoryScheme::commit(poly.evaluations(), &prover_setup); + + let mut prove_transcript = Blake2bTranscript::new(b"point-order"); + let proof = DoryScheme::open( + &poly, + &point, + eval, + &prover_setup, + Some(hint), + &mut prove_transcript, + ); + + let mut verify_transcript = Blake2bTranscript::new(b"point-order"); + let result = DoryScheme::verify( + &commitment, + &point, + eval, + &proof, + &verifier_setup, + &mut verify_transcript, + ); + assert!( + result.is_ok(), + "generic Dory opening must evaluate at the caller's Jolt-order point" + ); + } + + #[test] + fn single_claim_prove_batch_opens_source_without_materializing_rows() { + let num_vars = 3; + let mut rng = ChaCha20Rng::seed_from_u64(414); + let prover_setup = DoryScheme::setup_prover(num_vars); + let verifier_setup = DoryVerifierSetup(prover_setup.0.to_verifier_setup()); + + let poly = Polynomial::::random(num_vars, &mut rng); + let source = FoldOnlySource { poly: poly.clone() }; + let point: Vec = (0..num_vars) + .map(|_| ::random(&mut rng)) + .collect(); + let eval = poly.evaluate(&point); + let (commitment, hint) = DoryScheme::commit(&poly, &prover_setup); + + let mut prove_transcript = Blake2bTranscript::new(b"single-batch"); + let proof = DoryScheme::prove_batch( + vec![ProverClaim { + polynomial: source, + point: point.clone(), + eval, + }], + vec![hint], + &prover_setup, + &mut prove_transcript, + ); + + let mut verify_transcript = Blake2bTranscript::new(b"single-batch"); + DoryScheme::verify_batch( + vec![OpeningClaim { + commitment: commitment.clone(), + point: point.clone(), + eval, + }], + &proof, + &verifier_setup, + &mut verify_transcript, + ) + .expect("single-claim batch proof should verify"); + + let [single_proof] = proof.as_slice() else { + panic!("single-claim batch should contain one proof"); + }; + let mut direct_verify_transcript = Blake2bTranscript::new(b"single-batch"); + DoryScheme::verify( + &commitment, + &point, + eval, + single_proof, + &verifier_setup, + &mut direct_verify_transcript, + ) + .expect("single-claim batch should use the raw single-opening transcript"); + } + #[test] fn zk_open_verify_round_trip() { let num_vars = 4; @@ -597,7 +2159,7 @@ mod tests { let (commitment, hint) = ::commit_zk(poly.evaluations(), &prover_setup); - let mut prove_transcript = jolt_transcript::Blake2bTranscript::new(b"zk-test"); + let mut prove_transcript = Blake2bTranscript::new(b"zk-test"); let (proof, _eval_com, _blinding) = DoryScheme::open_zk( &poly, &point, @@ -607,7 +2169,7 @@ mod tests { &mut prove_transcript, ); - let mut verify_transcript = jolt_transcript::Blake2bTranscript::new(b"zk-test"); + let mut verify_transcript = Blake2bTranscript::new(b"zk-test"); let result = DoryScheme::verify_zk( &commitment, &point, @@ -618,6 +2180,214 @@ mod tests { assert!(result.is_ok(), "ZK verification failed: {result:?}"); } + #[test] + fn zk_single_claim_batch_round_trip() { + let num_vars = 3; + let mut rng = ChaCha20Rng::seed_from_u64(601); + + let prover_setup = DoryScheme::setup_prover(num_vars); + let verifier_setup = DoryScheme::prover_to_verifier_setup(&prover_setup); + + let poly = Polynomial::::random(num_vars, &mut rng); + let point: Vec = (0..num_vars) + .map(|_| ::random(&mut rng)) + .collect(); + let eval = poly.evaluate(&point); + let (commitment, hint) = DoryScheme::commit_zk(&poly, &prover_setup); + + let mut prove_transcript = Blake2bTranscript::new(b"zk-batch"); + let (proof, y_com, _blind) = DoryScheme::prove_batch_zk( + vec![ProverClaim { + polynomial: poly, + point: point.clone(), + eval, + }], + vec![hint], + &prover_setup, + &mut prove_transcript, + ); + assert_eq!( + DoryScheme::batch_eval_commitment(&proof), + Some(y_com), + "batch proof should expose the hidden evaluation commitment" + ); + + let mut verify_transcript = Blake2bTranscript::new(b"zk-batch"); + DoryScheme::verify_batch_zk( + vec![OpeningClaim { + commitment, + point, + eval, + }], + &proof, + &verifier_setup, + &mut verify_transcript, + ) + .expect("ZK batch proof should verify"); + } + + #[test] + fn source_backed_batch_round_trip() { + let num_vars = 3; + let mut rng = ChaCha20Rng::seed_from_u64(603); + + let prover_setup = DoryScheme::setup_prover(num_vars); + let verifier_setup = DoryScheme::prover_to_verifier_setup(&prover_setup); + + let p1 = Polynomial::::random(num_vars, &mut rng); + let p2 = Polynomial::::random(num_vars, &mut rng); + let point: Vec = (0..num_vars) + .map(|_| ::random(&mut rng)) + .collect(); + let eval1 = p1.evaluate(&point); + let eval2 = p2.evaluate(&point); + + let (c1, h1) = DoryScheme::commit(&p1, &prover_setup); + let (c2, h2) = DoryScheme::commit(&p2, &prover_setup); + let mut batch = TestOpeningBatch { + polynomials: vec![p1, p2], + hints: vec![h1, h2], + }; + + let prover_terms = vec![ + ProverBatchOpeningTerm { + claim_id: 0u8, + source_id: 0usize, + point: BatchOpeningPoint::same(point.clone()), + eval: eval1, + eval_scale: Fr::from_u64(1), + }, + ProverBatchOpeningTerm { + claim_id: 1u8, + source_id: 1usize, + point: BatchOpeningPoint::same(point.clone()), + eval: eval2, + eval_scale: Fr::from_u64(1), + }, + ]; + let verifier_terms = vec![ + VerifierBatchOpeningTerm:: { + claim_id: 0u8, + source_id: 0usize, + commitment: c1, + point: BatchOpeningPoint::same(point.clone()), + eval: eval1, + eval_scale: Fr::from_u64(1), + }, + VerifierBatchOpeningTerm:: { + claim_id: 1u8, + source_id: 1usize, + commitment: c2, + point: BatchOpeningPoint::same(point), + eval: eval2, + eval_scale: Fr::from_u64(1), + }, + ]; + + let mut prove_transcript = Blake2bTranscript::new(b"source-backed-batch"); + let prover_result = DoryScheme::prove_batch_opening( + prover_terms, + &mut batch, + &prover_setup, + &mut prove_transcript, + ); + + let mut verify_transcript = Blake2bTranscript::new(b"source-backed-batch"); + let verifier_public = DoryScheme::verify_batch_opening( + verifier_terms, + &prover_result.proof, + &verifier_setup, + &mut verify_transcript, + ) + .expect("source-backed Dory batch proof should verify"); + + assert_eq!(prover_result.public, verifier_public); + assert_eq!(verifier_public.outputs.len(), 1); + assert_eq!(verifier_public.relations.len(), 1); + } + + #[test] + fn source_backed_zk_batch_round_trip() { + let num_vars = 3; + let mut rng = ChaCha20Rng::seed_from_u64(604); + + let prover_setup = DoryScheme::setup_prover(num_vars); + let verifier_setup = DoryScheme::prover_to_verifier_setup(&prover_setup); + + let p1 = Polynomial::::random(num_vars, &mut rng); + let p2 = Polynomial::::random(num_vars, &mut rng); + let point: Vec = (0..num_vars) + .map(|_| ::random(&mut rng)) + .collect(); + let eval1 = p1.evaluate(&point); + let eval2 = p2.evaluate(&point); + + let (c1, h1) = DoryScheme::commit_zk(&p1, &prover_setup); + let (c2, h2) = DoryScheme::commit_zk(&p2, &prover_setup); + let mut batch = TestOpeningBatch { + polynomials: vec![p1, p2], + hints: vec![h1, h2], + }; + + let prover_terms = vec![ + ProverBatchOpeningTerm { + claim_id: 0u8, + source_id: 0usize, + point: BatchOpeningPoint::same(point.clone()), + eval: eval1, + eval_scale: Fr::from_u64(1), + }, + ProverBatchOpeningTerm { + claim_id: 1u8, + source_id: 1usize, + point: BatchOpeningPoint::same(point.clone()), + eval: eval2, + eval_scale: Fr::from_u64(1), + }, + ]; + let verifier_terms = vec![ + VerifierBatchOpeningTerm:: { + claim_id: 0u8, + source_id: 0usize, + commitment: c1, + point: BatchOpeningPoint::same(point.clone()), + eval: eval1, + eval_scale: Fr::from_u64(1), + }, + VerifierBatchOpeningTerm:: { + claim_id: 1u8, + source_id: 1usize, + commitment: c2, + point: BatchOpeningPoint::same(point), + eval: eval2, + eval_scale: Fr::from_u64(1), + }, + ]; + + let mut prove_transcript = Blake2bTranscript::new(b"source-backed-zk-batch"); + let prover_result = DoryScheme::prove_batch_opening_zk( + prover_terms, + &mut batch, + &prover_setup, + &mut prove_transcript, + ); + + let mut verify_transcript = Blake2bTranscript::new(b"source-backed-zk-batch"); + let verifier_public = DoryScheme::verify_batch_opening_zk( + verifier_terms, + &prover_result.proof, + &verifier_setup, + &mut verify_transcript, + ) + .expect("source-backed ZK Dory batch proof should verify"); + + assert_eq!(prover_result.public, verifier_public); + assert_eq!(verifier_public.outputs.len(), 1); + assert_eq!(verifier_public.relations.len(), 1); + assert_eq!(prover_result.witness.output_values.len(), 1); + assert_eq!(prover_result.witness.output_blinds.len(), 1); + } + #[test] fn extract_vc_setup_produces_valid_pedersen_setup() { let num_vars = 6; diff --git a/crates/jolt-dory/src/streaming.rs b/crates/jolt-dory/src/streaming.rs deleted file mode 100644 index a26bd68ea0..0000000000 --- a/crates/jolt-dory/src/streaming.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! Streaming (chunked) commitment for the Dory scheme. - -use dory::backends::arkworks::G1Routines; -use dory::primitives::arithmetic::DoryRoutines; -use jolt_field::Fr; -use jolt_openings::StreamingCommitment; - -use crate::scheme::{ - ark_to_jolt_fr, ark_to_jolt_g1, ark_to_jolt_g1_vec, ark_to_jolt_gt, commit_rows_tier_2, - jolt_fr_to_ark, jolt_g1_vec_to_ark, ArkFr, -}; -use crate::types::{DoryCommitment, DoryHint, DoryPartialCommitment, DoryProverSetup}; - -impl crate::DoryScheme { - #[tracing::instrument(skip_all, name = "DoryScheme::stream_finish_zk")] - pub fn finish_zk( - partial: DoryPartialCommitment, - setup: &DoryProverSetup, - ) -> (DoryCommitment, DoryHint) { - validate_row_count(partial.row_commitments.len(), setup); - let row_commitments = jolt_g1_vec_to_ark(partial.row_commitments); - let (tier_2, commit_blind) = commit_rows_tier_2::(&row_commitments, setup); - ( - DoryCommitment(ark_to_jolt_gt(&tier_2)), - DoryHint::new( - ark_to_jolt_g1_vec(row_commitments), - ark_to_jolt_fr(&commit_blind), - ), - ) - } -} - -impl StreamingCommitment for crate::DoryScheme { - type PartialCommitment = DoryPartialCommitment; - - fn begin(_setup: &Self::ProverSetup) -> Self::PartialCommitment { - DoryPartialCommitment { - row_commitments: Vec::new(), - } - } - - /// Commits one full row of the polynomial as `MSM(g1_bases[..chunk.len()], chunk)`, - /// matching the per-row work in [`DoryScheme::commit`](crate::DoryScheme::commit)'s - /// dense path. Caller must feed every row at the same chunk width. - #[tracing::instrument(skip_all, name = "DoryScheme::stream_feed")] - fn feed(partial: &mut Self::PartialCommitment, chunk: &[Fr], setup: &Self::ProverSetup) { - assert!( - chunk.len().is_power_of_two(), - "streaming: chunk length ({}) must be a power of two", - chunk.len(), - ); - assert!( - chunk.len() <= setup.0.g1_vec.len(), - "streaming: chunk length ({}) exceeds Dory SRS size ({})", - chunk.len(), - setup.0.g1_vec.len(), - ); - - let g1_bases = &setup.0.g1_vec[..chunk.len()]; - let scalars: Vec = chunk.iter().map(jolt_fr_to_ark).collect(); - let row_commitment = G1Routines::msm(g1_bases, &scalars); - partial.row_commitments.push(ark_to_jolt_g1(row_commitment)); - } - - /// Aggregates row commitments into the final tier-2 commitment, matching - /// [`DoryScheme::commit`](crate::DoryScheme::commit). Asserts that the - /// streamed row count is a power of two (the layout `DoryScheme::commit` - /// produces). - #[tracing::instrument(skip_all, name = "DoryScheme::stream_finish")] - fn finish(partial: Self::PartialCommitment, setup: &Self::ProverSetup) -> Self::Output { - let num_rows = partial.row_commitments.len(); - validate_row_count(num_rows, setup); - - let ark_rows = jolt_g1_vec_to_ark(partial.row_commitments); - let (tier_2, _) = commit_rows_tier_2::(&ark_rows, setup); - DoryCommitment(ark_to_jolt_gt(&tier_2)) - } -} - -fn validate_row_count(num_rows: usize, setup: &DoryProverSetup) { - assert!( - num_rows.is_power_of_two(), - "streaming: row count ({num_rows}) must be a power of two", - ); - assert!( - num_rows <= setup.0.g2_vec.len(), - "streaming: row count ({}) exceeds Dory SRS size ({})", - num_rows, - setup.0.g2_vec.len(), - ); -} - -#[cfg(test)] -mod tests { - use jolt_field::RandomSampling; - use jolt_openings::{CommitmentScheme, StreamingCommitment}; - use rand_chacha::ChaCha20Rng; - use rand_core::SeedableRng; - - use jolt_field::Fr; - - use crate::DoryScheme; - - #[test] - fn streaming_matches_direct() { - let num_vars: usize = 4; - let num_cols = 1usize << num_vars.div_ceil(2); - let num_rows = 1usize << (num_vars - num_vars.div_ceil(2)); - let mut rng = ChaCha20Rng::seed_from_u64(99); - - let prover_setup = DoryScheme::setup_prover(num_vars); - - let evals: Vec = (0..num_rows * num_cols) - .map(|_| ::random(&mut rng)) - .collect(); - - let poly = jolt_poly::Polynomial::new(evals.clone()); - let (direct, _) = DoryScheme::commit(poly.evaluations(), &prover_setup); - - let mut partial = DoryScheme::begin(&prover_setup); - for row in evals.chunks(num_cols) { - DoryScheme::feed(&mut partial, row, &prover_setup); - } - let streamed = DoryScheme::finish(partial, &prover_setup); - - assert_eq!( - direct, streamed, - "streaming and direct commitments must match" - ); - } -} diff --git a/crates/jolt-dory/src/types.rs b/crates/jolt-dory/src/types.rs index c0e720f8cb..b9cb6d43cc 100644 --- a/crates/jolt-dory/src/types.rs +++ b/crates/jolt-dory/src/types.rs @@ -1,14 +1,17 @@ -//! Wrapper types bridging dory-pcs to jolt-openings. +//! Public Dory types for the `jolt-openings` commitment traits. -use std::io::Cursor; +use std::io::{Cursor, Read, Result as IoResult, Write}; -use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use ark_serialize::{ + CanonicalDeserialize, CanonicalSerialize, Compress, SerializationError, Valid, Validate, +}; use dory::backends::arkworks::{ ArkDoryProof, ArkG1, ArkGT, ArkworksProverSetup, ArkworksVerifierSetup, }; use jolt_crypto::{Bn254G1, Bn254GT, HomomorphicCommitment}; use jolt_field::Fr; use jolt_transcript::{AppendToTranscript, Transcript}; +use serde::{de::Error as DeError, ser::Error as SerError}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// Caps the upstream `Vec::with_capacity(num_rounds)` allocation against @@ -35,6 +38,36 @@ impl<'de> Deserialize<'de> for DoryCommitment { } } +impl CanonicalSerialize for DoryCommitment { + fn serialize_with_mode( + &self, + writer: W, + compress: Compress, + ) -> Result<(), SerializationError> { + self.0.serialize_with_mode(writer, compress) + } + + fn serialized_size(&self, compress: Compress) -> usize { + self.0.serialized_size(compress) + } +} + +impl Valid for DoryCommitment { + fn check(&self) -> Result<(), SerializationError> { + self.0.check() + } +} + +impl CanonicalDeserialize for DoryCommitment { + fn deserialize_with_mode( + reader: R, + compress: Compress, + validate: Validate, + ) -> Result { + Bn254GT::deserialize_with_mode(reader, compress, validate).map(Self) + } +} + impl AppendToTranscript for DoryCommitment { fn append_to_transcript(&self, transcript: &mut T) { self.0.append_to_transcript(transcript); @@ -60,17 +93,89 @@ impl Serialize for DoryProof { impl<'de> Deserialize<'de> for DoryProof { fn deserialize>(deserializer: D) -> Result { let buf: Vec = Deserialize::deserialize(deserializer)?; - validate_proof_round_count(&buf).map_err(serde::de::Error::custom)?; + validate_proof_round_count(&buf).map_err(DeError::custom)?; ArkDoryProof::deserialize_compressed(&buf[..]) - .map_err(serde::de::Error::custom) + .map_err(DeError::custom) .map(Self) } } -#[derive(Clone)] +impl CanonicalSerialize for DoryProof { + fn serialize_with_mode( + &self, + writer: W, + compress: Compress, + ) -> Result<(), SerializationError> { + self.0.serialize_with_mode(writer, compress) + } + + fn serialized_size(&self, compress: Compress) -> usize { + self.0.serialized_size(compress) + } +} + +impl Valid for DoryProof { + fn check(&self) -> Result<(), SerializationError> { + self.0.check() + } +} + +impl CanonicalDeserialize for DoryProof { + fn deserialize_with_mode( + reader: R, + compress: Compress, + validate: Validate, + ) -> Result { + deserialize_proof_with_round_limit(reader, compress, validate).map(Self) + } +} + +#[derive(Clone, Debug)] pub struct DoryProverSetup(pub ArkworksProverSetup); -#[derive(Clone)] +impl Serialize for DoryProverSetup { + fn serialize(&self, serializer: S) -> Result { + canonical_serialize(&self.0, serializer) + } +} + +impl<'de> Deserialize<'de> for DoryProverSetup { + fn deserialize>(deserializer: D) -> Result { + canonical_deserialize(deserializer).map(Self) + } +} + +impl CanonicalSerialize for DoryProverSetup { + fn serialize_with_mode( + &self, + writer: W, + compress: Compress, + ) -> Result<(), SerializationError> { + self.0.serialize_with_mode(writer, compress) + } + + fn serialized_size(&self, compress: Compress) -> usize { + self.0.serialized_size(compress) + } +} + +impl Valid for DoryProverSetup { + fn check(&self) -> Result<(), SerializationError> { + self.0.check() + } +} + +impl CanonicalDeserialize for DoryProverSetup { + fn deserialize_with_mode( + reader: R, + compress: Compress, + validate: Validate, + ) -> Result { + ArkworksProverSetup::deserialize_with_mode(reader, compress, validate).map(Self) + } +} + +#[derive(Clone, Debug)] pub struct DoryVerifierSetup(pub ArkworksVerifierSetup); impl Serialize for DoryVerifierSetup { @@ -85,26 +190,53 @@ impl<'de> Deserialize<'de> for DoryVerifierSetup { } } -#[derive(Clone, Debug, Default)] +impl CanonicalSerialize for DoryVerifierSetup { + fn serialize_with_mode( + &self, + writer: W, + compress: Compress, + ) -> Result<(), SerializationError> { + self.0.serialize_with_mode(writer, compress) + } + + fn serialized_size(&self, compress: Compress) -> usize { + self.0.serialized_size(compress) + } +} + +impl Valid for DoryVerifierSetup { + fn check(&self) -> Result<(), SerializationError> { + self.0.check() + } +} + +impl CanonicalDeserialize for DoryVerifierSetup { + fn deserialize_with_mode( + reader: R, + compress: Compress, + validate: Validate, + ) -> Result { + ArkworksVerifierSetup::deserialize_with_mode(reader, compress, validate).map(Self) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct DoryHint { pub(crate) row_commitments: Vec, pub(crate) commit_blind: Fr, + pub(crate) chunk_len: usize, } impl DoryHint { - pub(crate) fn new(row_commitments: Vec, commit_blind: Fr) -> Self { + pub(crate) fn new(row_commitments: Vec, commit_blind: Fr, chunk_len: usize) -> Self { Self { row_commitments, commit_blind, + chunk_len, } } } -#[derive(Clone)] -pub struct DoryPartialCommitment { - pub row_commitments: Vec, -} - fn canonical_serialize( value: &T, serializer: S, @@ -112,7 +244,7 @@ fn canonical_serialize( let mut buf = Vec::new(); value .serialize_compressed(&mut buf) - .map_err(serde::ser::Error::custom)?; + .map_err(SerError::custom)?; serializer.serialize_bytes(&buf) } @@ -120,7 +252,7 @@ fn canonical_deserialize<'de, T: CanonicalDeserialize, D: Deserializer<'de>>( deserializer: D, ) -> Result { let buf: Vec = Deserialize::deserialize(deserializer)?; - T::deserialize_compressed(&buf[..]).map_err(serde::de::Error::custom) + T::deserialize_compressed(&buf[..]).map_err(DeError::custom) } /// Pre-validates the round count from the proof's wire bytes before invoking @@ -128,43 +260,82 @@ fn canonical_deserialize<'de, T: CanonicalDeserialize, D: Deserializer<'de>>( /// and would OOM on attacker-supplied lengths near `u32::MAX`. fn validate_proof_round_count(buf: &[u8]) -> Result<(), String> { let mut cursor = Cursor::new(buf); - let _: ArkGT = CanonicalDeserialize::deserialize_compressed(&mut cursor) - .map_err(|e| format!("invalid Dory proof VMV.c: {e}"))?; - let _: ArkGT = CanonicalDeserialize::deserialize_compressed(&mut cursor) - .map_err(|e| format!("invalid Dory proof VMV.d2: {e}"))?; - let _: ArkG1 = CanonicalDeserialize::deserialize_compressed(&mut cursor) - .map_err(|e| format!("invalid Dory proof VMV.e1: {e}"))?; - let num_rounds: u32 = CanonicalDeserialize::deserialize_compressed(&mut cursor) - .map_err(|e| format!("invalid Dory proof round count: {e}"))?; + read_and_validate_proof_round_count(&mut cursor, Compress::Yes, Validate::No) + .map_err(|e| format!("invalid Dory proof prefix: {e}")) +} + +fn deserialize_proof_with_round_limit( + reader: R, + compress: Compress, + validate: Validate, +) -> Result { + let mut recorder = RecordingReader::new(reader); + read_and_validate_proof_round_count(&mut recorder, compress, validate)?; + let replay = Cursor::new(recorder.recorded).chain(recorder.reader); + ArkDoryProof::deserialize_with_mode(replay, compress, validate) +} + +fn read_and_validate_proof_round_count( + reader: &mut R, + compress: Compress, + validate: Validate, +) -> Result<(), SerializationError> { + let _: ArkGT = + CanonicalDeserialize::deserialize_with_mode(reader.by_ref(), compress, validate)?; + let _: ArkGT = + CanonicalDeserialize::deserialize_with_mode(reader.by_ref(), compress, validate)?; + let _: ArkG1 = + CanonicalDeserialize::deserialize_with_mode(reader.by_ref(), compress, validate)?; + let num_rounds: u32 = + CanonicalDeserialize::deserialize_with_mode(reader.by_ref(), compress, validate)?; if num_rounds as usize > MAX_SERIALIZED_PROOF_ROUNDS { - return Err(format!( - "Dory proof round count ({num_rounds}) exceeds maximum ({MAX_SERIALIZED_PROOF_ROUNDS})" - )); + return Err(SerializationError::InvalidData); } Ok(()) } +struct RecordingReader { + reader: R, + recorded: Vec, +} + +impl RecordingReader { + fn new(reader: R) -> Self { + Self { + reader, + recorded: Vec::new(), + } + } +} + +impl Read for RecordingReader { + fn read(&mut self, buf: &mut [u8]) -> IoResult { + let count = self.reader.read(buf)?; + self.recorded.extend_from_slice(&buf[..count]); + Ok(count) + } +} + #[cfg(test)] #[expect(clippy::expect_used, reason = "tests may panic on assertion failures")] mod tests { use super::*; - use jolt_field::RandomSampling; - use jolt_openings::CommitmentScheme; + use crate::DoryScheme; + use jolt_field::{Fr, RandomSampling}; + use jolt_openings::{CommitmentScheme, CommitmentSchemeVerifier}; use jolt_poly::Polynomial; - use jolt_transcript::Transcript; + use jolt_transcript::{Blake2bTranscript, Transcript}; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; - use jolt_field::Fr; - #[test] fn dory_commitment_serde_round_trip() { let num_vars = 3; let mut rng = ChaCha20Rng::seed_from_u64(400); - let prover_setup = crate::DoryScheme::setup_prover(num_vars); + let prover_setup = DoryScheme::setup_prover(num_vars); let poly = Polynomial::::random(num_vars, &mut rng); - let (commitment, _) = crate::DoryScheme::commit(poly.evaluations(), &prover_setup); + let (commitment, _) = DoryScheme::commit(poly.evaluations(), &prover_setup); let serialized = serde_json::to_vec(&commitment).expect("serialize commitment"); let deserialized: DoryCommitment = @@ -176,24 +347,24 @@ mod tests { #[test] fn dory_verifier_setup_serde_round_trip() { let num_vars = 2; - let verifier_setup = crate::DoryScheme::setup_verifier(num_vars); + let verifier_setup = DoryScheme::setup_verifier(num_vars); let serialized = serde_json::to_vec(&verifier_setup).expect("serialize verifier setup"); let deserialized: DoryVerifierSetup = serde_json::from_slice(&serialized).expect("deserialize verifier setup"); let mut rng = ChaCha20Rng::seed_from_u64(401); - let prover_setup = crate::DoryScheme::setup_prover(num_vars); + let prover_setup = DoryScheme::setup_prover(num_vars); let poly = Polynomial::::random(num_vars, &mut rng); let point: Vec = (0..num_vars) .map(|_| ::random(&mut rng)) .collect(); let eval = poly.evaluate(&point); - let (commitment, hint) = crate::DoryScheme::commit(poly.evaluations(), &prover_setup); + let (commitment, hint) = DoryScheme::commit(poly.evaluations(), &prover_setup); - let mut prove_transcript = jolt_transcript::Blake2bTranscript::new(b"serde-vs"); - let proof = crate::DoryScheme::open( + let mut prove_transcript = Blake2bTranscript::new(b"serde-vs"); + let proof = DoryScheme::open( &poly, &point, eval, @@ -202,8 +373,8 @@ mod tests { &mut prove_transcript, ); - let mut verify_transcript = jolt_transcript::Blake2bTranscript::new(b"serde-vs"); - let result = crate::DoryScheme::verify( + let mut verify_transcript = Blake2bTranscript::new(b"serde-vs"); + let result = DoryScheme::verify( &commitment, &point, eval, @@ -222,7 +393,7 @@ mod tests { let num_vars = 2; let mut rng = ChaCha20Rng::seed_from_u64(402); - let prover_setup = crate::DoryScheme::setup_prover(num_vars); + let prover_setup = DoryScheme::setup_prover(num_vars); let poly = Polynomial::::random(num_vars, &mut rng); let point: Vec = (0..num_vars) @@ -230,19 +401,18 @@ mod tests { .collect(); let eval = poly.evaluate(&point); - let mut transcript = jolt_transcript::Blake2bTranscript::new(b"serde-bp"); - let proof = - crate::DoryScheme::open(&poly, &point, eval, &prover_setup, None, &mut transcript); + let mut transcript = Blake2bTranscript::new(b"serde-bp"); + let proof = DoryScheme::open(&poly, &point, eval, &prover_setup, None, &mut transcript); let serialized = serde_json::to_vec(&proof).expect("serialize proof"); let deserialized: DoryProof = serde_json::from_slice(&serialized).expect("deserialize proof"); let verifier_setup = DoryVerifierSetup(prover_setup.0.to_verifier_setup()); - let (commitment, _) = crate::DoryScheme::commit(poly.evaluations(), &prover_setup); + let (commitment, _) = DoryScheme::commit(poly.evaluations(), &prover_setup); - let mut verify_transcript = jolt_transcript::Blake2bTranscript::new(b"serde-bp"); - let result = crate::DoryScheme::verify( + let mut verify_transcript = Blake2bTranscript::new(b"serde-bp"); + let result = DoryScheme::verify( &commitment, &point, eval, @@ -253,21 +423,119 @@ mod tests { assert!(result.is_ok(), "deserialized proof must verify correctly"); } + #[test] + fn dory_canonical_round_trips_support_core_proof_storage() { + let num_vars = 2; + let mut rng = ChaCha20Rng::seed_from_u64(405); + + let prover_setup = DoryScheme::setup_prover(num_vars); + let mut prover_setup_bytes = Vec::new(); + prover_setup + .serialize_compressed(&mut prover_setup_bytes) + .expect("serialize prover setup"); + let prover_setup = DoryProverSetup::deserialize_compressed(&prover_setup_bytes[..]) + .expect("deserialize prover setup"); + + let verifier_setup = DoryVerifierSetup(prover_setup.0.to_verifier_setup()); + let mut verifier_setup_bytes = Vec::new(); + verifier_setup + .serialize_compressed(&mut verifier_setup_bytes) + .expect("serialize verifier setup"); + let verifier_setup = DoryVerifierSetup::deserialize_compressed(&verifier_setup_bytes[..]) + .expect("deserialize verifier setup"); + + let poly = Polynomial::::random(num_vars, &mut rng); + let point: Vec = (0..num_vars) + .map(|_| ::random(&mut rng)) + .collect(); + let eval = poly.evaluate(&point); + let (commitment, hint) = DoryScheme::commit(poly.evaluations(), &prover_setup); + + let mut commitment_bytes = Vec::new(); + commitment + .serialize_compressed(&mut commitment_bytes) + .expect("serialize commitment"); + let commitment = DoryCommitment::deserialize_compressed(&commitment_bytes[..]) + .expect("deserialize commitment"); + + let mut prove_transcript = Blake2bTranscript::new(b"canonical"); + let proof = DoryScheme::open( + &poly, + &point, + eval, + &prover_setup, + Some(hint), + &mut prove_transcript, + ); + + let mut proof_bytes = Vec::new(); + proof + .serialize_compressed(&mut proof_bytes) + .expect("serialize proof"); + let proof = DoryProof::deserialize_compressed(&proof_bytes[..]).expect("deserialize proof"); + + let mut verify_transcript = Blake2bTranscript::new(b"canonical"); + let result = DoryScheme::verify( + &commitment, + &point, + eval, + &proof, + &verifier_setup, + &mut verify_transcript, + ); + assert!( + result.is_ok(), + "canonical round-tripped Dory types must verify correctly" + ); + } + + #[test] + fn dory_proof_deserialization_preserves_following_bytes() { + let num_vars = 2; + let mut rng = ChaCha20Rng::seed_from_u64(406); + + let prover_setup = DoryScheme::setup_prover(num_vars); + let poly = Polynomial::::random(num_vars, &mut rng); + let point: Vec = (0..num_vars) + .map(|_| ::random(&mut rng)) + .collect(); + let eval = poly.evaluate(&point); + + let mut transcript = Blake2bTranscript::new(b"canonical-stream"); + let proof = DoryScheme::open(&poly, &point, eval, &prover_setup, None, &mut transcript); + + let mut bytes = Vec::new(); + vec![proof] + .serialize_compressed(&mut bytes) + .expect("serialize proof vector"); + 7_u64 + .serialize_compressed(&mut bytes) + .expect("serialize trailing field"); + + let mut cursor = Cursor::new(bytes); + let proofs = Vec::::deserialize_compressed(&mut cursor) + .expect("deserialize proof vector"); + let trailing = + u64::deserialize_compressed(&mut cursor).expect("deserialize trailing field"); + + assert_eq!(proofs.len(), 1); + assert_eq!(trailing, 7); + } + #[test] fn dory_proof_rejects_oversized_round_count() { let num_vars = 2; let mut rng = ChaCha20Rng::seed_from_u64(403); - let prover_setup = crate::DoryScheme::setup_prover(num_vars); + let prover_setup = DoryScheme::setup_prover(num_vars); let poly = Polynomial::::random(num_vars, &mut rng); let point: Vec = (0..num_vars) .map(|_| ::random(&mut rng)) .collect(); let eval = poly.evaluate(&point); - let mut transcript = jolt_transcript::Blake2bTranscript::new(b"serde-oversized"); - let proof = - crate::DoryScheme::open(&poly, &point, eval, &prover_setup, None, &mut transcript); + let mut transcript = Blake2bTranscript::new(b"serde-oversized"); + let proof = DoryScheme::open(&poly, &point, eval, &prover_setup, None, &mut transcript); let mut bytes = Vec::new(); proof diff --git a/crates/jolt-dory/tests/commit_open_verify.rs b/crates/jolt-dory/tests/commit_open_verify.rs index 1b14dae377..9e4e4c1b7e 100644 --- a/crates/jolt-dory/tests/commit_open_verify.rs +++ b/crates/jolt-dory/tests/commit_open_verify.rs @@ -1,17 +1,21 @@ //! Integration tests for the Dory commitment scheme. //! //! Public-API-only tests — no `pub(crate)` imports. Exercises commit, open, -//! verify, streaming, combine, and negative cases across transcript backends. +//! verify, source-batch commitment, combine, and negative cases across transcript backends. #![expect(clippy::expect_used, reason = "tests may panic on assertion failures")] +use std::sync::atomic::{AtomicUsize, Ordering}; + use dory::backends::arkworks::ArkG1; use jolt_dory::DoryScheme; use jolt_field::{Fr, FromPrimitiveInt, RandomSampling}; use jolt_openings::{ - AdditivelyHomomorphic, CommitmentScheme, StreamingCommitment, ZkOpeningScheme, + AdditivelyHomomorphic, AdditivelyHomomorphicVerifier, BatchCommitmentSource, CommitmentScheme, + CommitmentSchemeVerifier, CommitmentSource, OneHotEntries, OneHotIndex, OneHotRow, SourceRow, + ZkOpeningScheme, ZkOpeningSchemeVerifier, }; -use jolt_poly::{OneHotPolynomial, Polynomial}; +use jolt_poly::{MultilinearPoly, OneHotPolynomial, Polynomial}; use jolt_transcript::{Blake2bTranscript, KeccakTranscript, Transcript}; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; @@ -65,31 +69,6 @@ fn commit_open_verify_both_transcripts() { round_trip::(num_vars, 200, b"keccak-rt"); } -#[test] -fn streaming_equals_direct_various_sizes() { - for num_vars in [2usize, 4, 6] { - let sigma = num_vars.div_ceil(2); - let num_cols = 1usize << sigma; - let mut rng = ChaCha20Rng::seed_from_u64(300 + num_vars as u64); - - let prover_setup = DoryScheme::setup_prover(num_vars); - let poly = Polynomial::::random(num_vars, &mut rng); - - let (direct, _) = DoryScheme::commit(poly.evaluations(), &prover_setup); - - let mut partial = DoryScheme::begin(&prover_setup); - for row in poly.evaluations().chunks(num_cols) { - DoryScheme::feed(&mut partial, row, &prover_setup); - } - let streamed = DoryScheme::finish(partial, &prover_setup); - - assert_eq!( - direct, streamed, - "streaming and direct must match for num_vars={num_vars}" - ); - } -} - #[test] fn one_hot_commitment_matches_dense() { let num_vars = 4; @@ -115,44 +94,435 @@ fn one_hot_commitment_matches_dense() { ); } +struct DenseSource<'a> { + evaluations: &'a [Fr], +} + +impl CommitmentSource for DenseSource<'_> { + fn num_vars(&self) -> usize { + self.evaluations.len().ilog2() as usize + } + + fn evaluate(&self, point: &[Fr]) -> Fr { + Polynomial::new(self.evaluations.to_vec()).evaluate(point) + } + + fn for_each_row(&self, chunk_len: usize, mut visit: V) + where + V: for<'row> FnMut(usize, SourceRow<'row, Fr>), + { + for (row_index, row) in self.evaluations.chunks(chunk_len).enumerate() { + visit( + row_index, + SourceRow::StridedFieldElements { + values: row, + column_stride: 1, + }, + ); + } + } + + fn fold_rows(&self, left: &[Fr], chunk_len: usize) -> Vec { + let sigma = chunk_len.trailing_zeros() as usize; + let poly = Polynomial::new(self.evaluations.to_vec()); + MultilinearPoly::fold_rows(&poly, left, sigma) + } +} + +struct DenseBatch { + ids: Vec, + evaluations: Vec>, + map_rows_calls: AtomicUsize, +} + +impl DenseBatch { + fn new(evaluations: Vec>) -> Self { + Self { + ids: (0..evaluations.len()).collect(), + evaluations, + map_rows_calls: AtomicUsize::new(0), + } + } +} + +impl BatchCommitmentSource for DenseBatch { + type Id = usize; + + type Source<'a> + = DenseSource<'a> + where + Self: 'a; + + fn source_ids(&self) -> &[Self::Id] { + &self.ids + } + + fn num_vars(&self, id: Self::Id) -> usize { + self.evaluations[id].len().ilog2() as usize + } + + fn source(&self, id: Self::Id) -> Self::Source<'_> { + DenseSource { + evaluations: &self.evaluations[id], + } + } + + fn map_rows(&self, chunk_len: usize, ids: &[Self::Id], visit: V) -> Vec> + where + R: Send, + V: for<'row> Fn(Self::Id, SourceRow<'row, Fr>) -> R + Send + Sync, + { + let _ = self.map_rows_calls.fetch_add(1, Ordering::SeqCst); + let num_rows = self.evaluations[ids[0]].len() / chunk_len; + + (0..num_rows) + .map(|row_index| { + ids.iter() + .map(|&id| { + let start = row_index * chunk_len; + let end = start + chunk_len; + visit( + id, + SourceRow::StridedFieldElements { + values: &self.evaluations[id][start..end], + column_stride: 1, + }, + ) + }) + .collect() + }) + .collect() + } +} + +struct I128Batch { + ids: Vec, + rows: Vec>, + dense: Vec>, + map_rows_calls: AtomicUsize, +} + +impl I128Batch { + fn new(rows: Vec>) -> Self { + let dense = rows + .iter() + .map(|row| row.iter().map(|&value| Fr::from_i128(value)).collect()) + .collect(); + Self { + ids: (0..rows.len()).collect(), + rows, + dense, + map_rows_calls: AtomicUsize::new(0), + } + } +} + +impl BatchCommitmentSource for I128Batch { + type Id = usize; + + type Source<'a> + = DenseSource<'a> + where + Self: 'a; + + fn source_ids(&self) -> &[Self::Id] { + &self.ids + } + + fn num_vars(&self, id: Self::Id) -> usize { + self.rows[id].len().ilog2() as usize + } + + fn source(&self, id: Self::Id) -> Self::Source<'_> { + DenseSource { + evaluations: &self.dense[id], + } + } + + fn map_rows(&self, chunk_len: usize, ids: &[Self::Id], visit: V) -> Vec> + where + R: Send, + V: for<'row> Fn(Self::Id, SourceRow<'row, Fr>) -> R + Send + Sync, + { + let _ = self.map_rows_calls.fetch_add(1, Ordering::SeqCst); + let num_rows = self.rows[ids[0]].len() / chunk_len; + + (0..num_rows) + .map(|row_index| { + ids.iter() + .map(|&id| { + let start = row_index * chunk_len; + let end = start + chunk_len; + visit( + id, + SourceRow::StridedI128 { + values: &self.rows[id][start..end], + column_stride: 1, + }, + ) + }) + .collect() + }) + .collect() + } +} + #[test] -fn streaming_zk_commitment_is_blinded_and_verifies() { - let num_vars = 4usize; - let sigma = num_vars.div_ceil(2); - let num_cols = 1usize << sigma; - let mut rng = ChaCha20Rng::seed_from_u64(350); +fn commit_batch_dense_matches_direct_and_uses_shared_rows() { + let num_vars = 4; + let mut rng = ChaCha20Rng::seed_from_u64(375); + let prover_setup = DoryScheme::setup_prover(num_vars); + + let poly_a = Polynomial::::random(num_vars, &mut rng); + let poly_b = Polynomial::::random(num_vars, &mut rng); + let batch = DenseBatch::new(vec![ + poly_a.evaluations().to_vec(), + poly_b.evaluations().to_vec(), + ]); + + let results = DoryScheme::commit_batch(&batch, batch.source_ids(), &prover_setup); + assert_eq!( + batch.map_rows_calls.load(Ordering::SeqCst), + 1, + "Dory batch commitment should use one shared row traversal", + ); + + for (id, poly) in [poly_a, poly_b].into_iter().enumerate() { + let (direct, _) = DoryScheme::commit(poly.evaluations(), &prover_setup); + assert_eq!(results[id].0, direct); + + let point: Vec = (0..num_vars).map(|_| Fr::random(&mut rng)).collect(); + let eval = poly.evaluate(&point); + let mut pt = Blake2bTranscript::new(b"batch-dense"); + let proof = DoryScheme::open( + &poly, + &point, + eval, + &prover_setup, + Some(results[id].1.clone()), + &mut pt, + ); + let mut vt = Blake2bTranscript::new(b"batch-dense"); + let verifier_setup = DoryScheme::setup_verifier(num_vars); + DoryScheme::verify( + &results[id].0, + &point, + eval, + &proof, + &verifier_setup, + &mut vt, + ) + .expect("batch dense commitment hint should open and verify"); + } +} + +#[test] +fn commit_batch_i128_matches_dense_commitment() { + let num_vars = 4; + let prover_setup = DoryScheme::setup_prover(num_vars); + let batch = I128Batch::new(vec![ + vec![0, 1, -1, 7, -3, 0, 12, -8, 4, 5, -6, 0, 9, -2, 3, 1], + vec![2, -4, 0, 0, 11, -9, 5, 6, -1, 8, 0, -7, 3, 3, -2, 10], + ]); + + let results = DoryScheme::commit_batch(&batch, batch.source_ids(), &prover_setup); + assert_eq!(batch.map_rows_calls.load(Ordering::SeqCst), 1); + + for (id, dense) in batch.dense.iter().enumerate() { + let (direct, _) = DoryScheme::commit(dense, &prover_setup); + assert_eq!(results[id].0, direct); + } +} +#[test] +fn commit_batch_zk_dense_outputs_openable_hints() { + let num_vars = 4; + let mut rng = ChaCha20Rng::seed_from_u64(376); let prover_setup = DoryScheme::setup_prover(num_vars); let verifier_setup = DoryScheme::setup_verifier(num_vars); - let poly = Polynomial::::random(num_vars, &mut rng); - let point: Vec = (0..num_vars) - .map(|_| ::random(&mut rng)) - .collect(); - let eval = poly.evaluate(&point); - let mut partial = DoryScheme::begin(&prover_setup); - for row in poly.evaluations().chunks(num_cols) { - DoryScheme::feed(&mut partial, row, &prover_setup); + let poly_a = Polynomial::::random(num_vars, &mut rng); + let poly_b = Polynomial::::random(num_vars, &mut rng); + let batch = DenseBatch::new(vec![ + poly_a.evaluations().to_vec(), + poly_b.evaluations().to_vec(), + ]); + + let results = DoryScheme::commit_batch_zk(&batch, batch.source_ids(), &prover_setup); + assert_eq!(batch.map_rows_calls.load(Ordering::SeqCst), 1); + + for (id, poly) in [poly_a, poly_b].into_iter().enumerate() { + let point: Vec = (0..num_vars).map(|_| Fr::random(&mut rng)).collect(); + let eval = poly.evaluate(&point); + let mut pt = Blake2bTranscript::new(b"batch-zk-dense"); + let (proof, _, _) = DoryScheme::open_zk( + &poly, + &point, + eval, + &prover_setup, + results[id].1.clone(), + &mut pt, + ); + let mut vt = Blake2bTranscript::new(b"batch-zk-dense"); + DoryScheme::verify_zk(&results[id].0, &point, &proof, &verifier_setup, &mut vt) + .expect("batch ZK dense commitment hint should open and verify"); + } +} + +struct OneHotBatch { + ids: Vec, + log_domain_size: u8, + chunks: Vec>>>, + dense: Vec>, + map_rows_calls: AtomicUsize, +} + +impl OneHotBatch { + fn new(log_domain_size: u8, chunks: Vec>>>) -> Self { + let domain_size = 1usize << log_domain_size; + let dense = chunks + .iter() + .map(|source_chunks| { + let row_len = source_chunks[0].len(); + let trace_len = source_chunks.len() * row_len; + let mut evals = vec![Fr::from_u64(0); trace_len * domain_size]; + for (chunk_index, chunk) in source_chunks.iter().enumerate() { + assert_eq!(chunk.len(), row_len); + for (column, hot_index) in chunk.iter().enumerate() { + if let Some(hot_index) = hot_index { + evals[hot_index.get() * trace_len + chunk_index * row_len + column] = + Fr::from_u64(1); + } + } + } + evals + }) + .collect(); + Self { + ids: (0..chunks.len()).collect(), + log_domain_size, + chunks, + dense, + map_rows_calls: AtomicUsize::new(0), + } } - let (commitment, hint) = DoryScheme::finish_zk(partial, &prover_setup); +} + +impl BatchCommitmentSource for OneHotBatch { + type Id = usize; - let mut partial_again = DoryScheme::begin(&prover_setup); - for row in poly.evaluations().chunks(num_cols) { - DoryScheme::feed(&mut partial_again, row, &prover_setup); + type Source<'a> + = DenseSource<'a> + where + Self: 'a; + + fn source_ids(&self) -> &[Self::Id] { + &self.ids } - let (commitment_again, _) = DoryScheme::finish_zk(partial_again, &prover_setup); - assert_ne!( - commitment, commitment_again, - "streaming ZK commitments must use fresh blinding" - ); - let mut pt = Blake2bTranscript::new(b"stream-zk"); - let (proof, _eval_com, _blind) = - DoryScheme::open_zk(&poly, &point, eval, &prover_setup, hint, &mut pt); + fn num_vars(&self, id: Self::Id) -> usize { + self.dense[id].len().ilog2() as usize + } - let mut vt = Blake2bTranscript::new(b"stream-zk"); - DoryScheme::verify_zk(&commitment, &point, &proof, &verifier_setup, &mut vt) - .expect("streaming ZK commitment must verify"); + fn source(&self, id: Self::Id) -> Self::Source<'_> { + DenseSource { + evaluations: &self.dense[id], + } + } + + fn natural_chunk_len(&self, ids: &[Self::Id]) -> Option { + ids.first() + .and_then(|&id| self.chunks[id].first().map(Vec::len)) + } + + fn map_rows(&self, _chunk_len: usize, ids: &[Self::Id], visit: V) -> Vec> + where + R: Send, + V: for<'row> Fn(Self::Id, SourceRow<'row, Fr>) -> R + Send + Sync, + { + let _ = self.map_rows_calls.fetch_add(1, Ordering::SeqCst); + (0..self.chunks[ids[0]].len()) + .map(|chunk_index| { + ids.iter() + .map(|&id| { + visit( + id, + SourceRow::OneHot(OneHotRow { + log_domain_size: self.log_domain_size, + entries: OneHotEntries::MaybeZero(&self.chunks[id][chunk_index]), + }), + ) + }) + .collect() + }) + .collect() + } +} + +#[test] +fn commit_batch_one_hot_matches_streaming_dense_layout() { + let num_vars = 4; + let log_domain_size = 2; + let prover_setup = DoryScheme::setup_prover(num_vars); + let rows = vec![ + vec![vec![ + Some(OneHotIndex::new(2, log_domain_size).expect("valid index")), + None, + Some(OneHotIndex::new(0, log_domain_size).expect("valid index")), + Some(OneHotIndex::new(3, log_domain_size).expect("valid index")), + ]], + vec![vec![ + Some(OneHotIndex::new(1, log_domain_size).expect("valid index")), + Some(OneHotIndex::new(0, log_domain_size).expect("valid index")), + None, + Some(OneHotIndex::new(2, log_domain_size).expect("valid index")), + ]], + ]; + let batch = OneHotBatch::new(log_domain_size, rows); + + let results = DoryScheme::commit_batch(&batch, batch.source_ids(), &prover_setup); + assert_eq!(batch.map_rows_calls.load(Ordering::SeqCst), 1); + + for (id, dense) in batch.dense.iter().enumerate() { + let (direct, _) = DoryScheme::commit(dense, &prover_setup); + assert_eq!(results[id].0, direct); + } +} + +#[test] +fn commit_batch_one_hot_matches_multi_chunk_streaming_layout() { + let num_vars = 6; + let log_domain_size = 2; + let prover_setup = DoryScheme::setup_prover(num_vars); + let source_chunks = vec![vec![ + vec![ + Some(OneHotIndex::new(0, log_domain_size).expect("valid index")), + Some(OneHotIndex::new(1, log_domain_size).expect("valid index")), + None, + Some(OneHotIndex::new(3, log_domain_size).expect("valid index")), + Some(OneHotIndex::new(2, log_domain_size).expect("valid index")), + None, + Some(OneHotIndex::new(1, log_domain_size).expect("valid index")), + Some(OneHotIndex::new(0, log_domain_size).expect("valid index")), + ], + vec![ + None, + Some(OneHotIndex::new(2, log_domain_size).expect("valid index")), + Some(OneHotIndex::new(3, log_domain_size).expect("valid index")), + Some(OneHotIndex::new(0, log_domain_size).expect("valid index")), + None, + Some(OneHotIndex::new(1, log_domain_size).expect("valid index")), + Some(OneHotIndex::new(2, log_domain_size).expect("valid index")), + Some(OneHotIndex::new(3, log_domain_size).expect("valid index")), + ], + ]]; + let batch = OneHotBatch::new(log_domain_size, source_chunks); + + let results = DoryScheme::commit_batch(&batch, batch.source_ids(), &prover_setup); + assert_eq!(batch.map_rows_calls.load(Ordering::SeqCst), 1); + + let (direct, _) = DoryScheme::commit(&batch.dense[0], &prover_setup); + assert_eq!(results[0].0, direct); } #[test] diff --git a/crates/jolt-field/src/arkworks/bn254.rs b/crates/jolt-field/src/arkworks/bn254.rs index df579d70ad..6bf97997b7 100644 --- a/crates/jolt-field/src/arkworks/bn254.rs +++ b/crates/jolt-field/src/arkworks/bn254.rs @@ -7,12 +7,23 @@ use crate::{ FixedByteSize, FixedBytes, FromPrimitiveInt, Invertible, Limbs, MulPrimitiveInt, RandomSampling, ReducingBytes, RingCore, TranscriptChallenge, WithAccumulator, }; -use ark_ff::{prelude::*, PrimeField, UniformRand}; +use ark_bn254::Fr as ArkFr; +use ark_ff::{prelude::*, Field as ArkField, PrimeField, UniformRand}; +use ark_serialize::{ + CanonicalDeserialize, CanonicalSerialize, Compress, Read, SerializationError, Valid, Validate, + Write, +}; use rand_core::RngCore; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::{ + fmt::{self, Debug, Display}, + iter::{Product, Sum}, + ops::{Add, AddAssign, Div, Mul, MulAssign, Neg, Sub, SubAssign}, +}; use super::bn254_ops; -type InnerFr = ark_bn254::Fr; +type InnerFr = ArkFr; /// BN254 scalar field element. /// @@ -21,14 +32,14 @@ type InnerFr = ark_bn254::Fr; #[repr(transparent)] pub struct Fr(pub(crate) InnerFr); -impl From for Fr { +impl From for Fr { #[inline(always)] - fn from(inner: ark_bn254::Fr) -> Self { + fn from(inner: ArkFr) -> Self { Fr(inner) } } -impl From for ark_bn254::Fr { +impl From for ArkFr { #[inline(always)] fn from(wrapper: Fr) -> Self { wrapper.0 @@ -91,49 +102,49 @@ impl From for Fr { } } -impl std::fmt::Debug for Fr { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - std::fmt::Debug::fmt(&self.0, f) +impl Debug for Fr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + Debug::fmt(&self.0, f) } } -impl std::fmt::Display for Fr { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - std::fmt::Display::fmt(&self.0, f) +impl Display for Fr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + Display::fmt(&self.0, f) } } macro_rules! delegate_binop { ($Trait:ident, $method:ident) => { - impl std::ops::$Trait for Fr { + impl $Trait for Fr { type Output = Fr; #[inline(always)] fn $method(self, rhs: Fr) -> Fr { - Fr(std::ops::$Trait::$method(self.0, rhs.0)) + Fr($Trait::$method(self.0, rhs.0)) } } - impl std::ops::$Trait<&Fr> for Fr { + impl $Trait<&Fr> for Fr { type Output = Fr; #[inline(always)] fn $method(self, rhs: &Fr) -> Fr { - Fr(std::ops::$Trait::$method(self.0, &rhs.0)) + Fr($Trait::$method(self.0, &rhs.0)) } } - impl std::ops::$Trait for &Fr { + impl $Trait for &Fr { type Output = Fr; #[inline(always)] fn $method(self, rhs: Fr) -> Fr { - Fr(std::ops::$Trait::$method(self.0, rhs.0)) + Fr($Trait::$method(self.0, rhs.0)) } } - impl<'a, 'b> std::ops::$Trait<&'b Fr> for &'a Fr { + impl<'a, 'b> $Trait<&'b Fr> for &'a Fr { type Output = Fr; #[inline(always)] fn $method(self, rhs: &'b Fr) -> Fr { - Fr(std::ops::$Trait::$method(self.0, &rhs.0)) + Fr($Trait::$method(self.0, &rhs.0)) } } }; @@ -144,7 +155,7 @@ delegate_binop!(Sub, sub); delegate_binop!(Mul, mul); delegate_binop!(Div, div); -impl std::ops::Neg for Fr { +impl Neg for Fr { type Output = Fr; #[inline(always)] fn neg(self) -> Fr { @@ -152,46 +163,46 @@ impl std::ops::Neg for Fr { } } -impl std::ops::AddAssign for Fr { +impl AddAssign for Fr { #[inline(always)] fn add_assign(&mut self, rhs: Fr) { self.0.add_assign(rhs.0); } } -impl std::ops::SubAssign for Fr { +impl SubAssign for Fr { #[inline(always)] fn sub_assign(&mut self, rhs: Fr) { self.0.sub_assign(rhs.0); } } -impl std::ops::MulAssign for Fr { +impl MulAssign for Fr { #[inline(always)] fn mul_assign(&mut self, rhs: Fr) { self.0.mul_assign(rhs.0); } } -impl std::iter::Sum for Fr { +impl Sum for Fr { fn sum>(iter: I) -> Self { Fr(iter.map(|f| f.0).sum()) } } -impl<'a> std::iter::Sum<&'a Fr> for Fr { +impl<'a> Sum<&'a Fr> for Fr { fn sum>(iter: I) -> Self { Fr(iter.map(|f| f.0).sum()) } } -impl std::iter::Product for Fr { +impl Product for Fr { fn product>(iter: I) -> Self { Fr(iter.map(|f| f.0).product()) } } -impl<'a> std::iter::Product<&'a Fr> for Fr { +impl<'a> Product<&'a Fr> for Fr { fn product>(iter: I) -> Self { Fr(iter.map(|f| f.0).product()) } @@ -221,9 +232,8 @@ impl num_traits::One for Fr { } } -impl serde::Serialize for Fr { - fn serialize(&self, serializer: S) -> Result { - use ark_serialize::CanonicalSerialize; +impl Serialize for Fr { + fn serialize(&self, serializer: S) -> Result { let mut buf = [0u8; 32]; self.0 .serialize_compressed(&mut buf[..]) @@ -232,41 +242,40 @@ impl serde::Serialize for Fr { } } -impl<'de> serde::Deserialize<'de> for Fr { - fn deserialize>(deserializer: D) -> Result { - use ark_serialize::CanonicalDeserialize; +impl<'de> Deserialize<'de> for Fr { + fn deserialize>(deserializer: D) -> Result { let buf = <[u8; 32]>::deserialize(deserializer)?; let inner = InnerFr::deserialize_compressed(&buf[..]).map_err(serde::de::Error::custom)?; Ok(Fr(inner)) } } -impl ark_serialize::CanonicalSerialize for Fr { - fn serialize_with_mode( +impl CanonicalSerialize for Fr { + fn serialize_with_mode( &self, writer: W, - compress: ark_serialize::Compress, - ) -> Result<(), ark_serialize::SerializationError> { + compress: Compress, + ) -> Result<(), SerializationError> { self.0.serialize_with_mode(writer, compress) } - fn serialized_size(&self, compress: ark_serialize::Compress) -> usize { + fn serialized_size(&self, compress: Compress) -> usize { self.0.serialized_size(compress) } } -impl ark_serialize::Valid for Fr { - fn check(&self) -> Result<(), ark_serialize::SerializationError> { +impl Valid for Fr { + fn check(&self) -> Result<(), SerializationError> { self.0.check() } } -impl ark_serialize::CanonicalDeserialize for Fr { - fn deserialize_with_mode( +impl CanonicalDeserialize for Fr { + fn deserialize_with_mode( reader: R, - compress: ark_serialize::Compress, - validate: ark_serialize::Validate, - ) -> Result { + compress: Compress, + validate: Validate, + ) -> Result { InnerFr::deserialize_with_mode(reader, compress, validate).map(Fr) } } @@ -307,6 +316,13 @@ impl Fr { Limbs((self.0).0 .0) } + /// Multiplies this field element by a 125-bit challenge stored in the high + /// two Montgomery limbs used by Jolt's optimized challenge type. + #[inline(always)] + pub fn mul_by_hi_2limbs(&self, limb_lo: u64, limb_hi: u64) -> Self { + Fr(self.0.mul_by_hi_2limbs(limb_lo, limb_hi)) + } + /// Construct from the inner arkworks element. #[inline(always)] pub(crate) fn from_inner(inner: InnerFr) -> Self { @@ -319,14 +335,14 @@ impl AdditiveGroup for Fr {} impl RingCore for Fr { #[inline] fn square(&self) -> Self { - Fr(::square(&self.0)) + Fr(::square(&self.0)) } } impl Invertible for Fr { #[inline] fn inverse(&self) -> Option { - ::inverse(&self.0).map(Fr) + ::inverse(&self.0).map(Fr) } } @@ -341,7 +357,6 @@ impl CanonicalBytes for Fr { #[inline] fn to_bytes_le(&self, out: &mut [u8]) { assert_eq!(out.len(), ::NUM_BYTES); - use ark_serialize::CanonicalSerialize; self.0 .serialize_compressed(out) .expect("BN254 Fr always serializes to 32 bytes"); @@ -393,33 +408,74 @@ impl RandomSampling for Fr { } } +// The custom limb reducers are a native hot path. WASM verification uses +// arkworks' portable constructors and multiplication so proofs generated on +// native targets verify identically in the browser/Node runtime. impl FromPrimitiveInt for Fr { #[inline] fn from_u64(n: u64) -> Self { - Fr(bn254_ops::from_u64(n)) + #[cfg(target_arch = "wasm32")] + { + Fr(InnerFr::from(n)) + } + #[cfg(not(target_arch = "wasm32"))] + { + Fr(bn254_ops::from_u64(n)) + } } #[inline] fn from_i64(val: i64) -> Self { - if val.is_negative() { - -Fr(bn254_ops::from_u64(val.unsigned_abs())) - } else { - Fr(bn254_ops::from_u64(val as u64)) + #[cfg(target_arch = "wasm32")] + { + let abs = Fr(InnerFr::from(val.unsigned_abs())); + if val.is_negative() { + -abs + } else { + abs + } + } + #[cfg(not(target_arch = "wasm32"))] + { + if val.is_negative() { + -Fr(bn254_ops::from_u64(val.unsigned_abs())) + } else { + Fr(bn254_ops::from_u64(val as u64)) + } } } #[inline] fn from_i128(val: i128) -> Self { - if val.is_negative() { - -Fr(bn254_ops::from_u128(val.unsigned_abs())) - } else { - Fr(bn254_ops::from_u128(val as u128)) + #[cfg(target_arch = "wasm32")] + { + let abs = Fr(InnerFr::from(val.unsigned_abs())); + if val.is_negative() { + -abs + } else { + abs + } + } + #[cfg(not(target_arch = "wasm32"))] + { + if val.is_negative() { + -Fr(bn254_ops::from_u128(val.unsigned_abs())) + } else { + Fr(bn254_ops::from_u128(val as u128)) + } } } #[inline] fn from_u128(val: u128) -> Self { - Fr(bn254_ops::from_u128(val)) + #[cfg(target_arch = "wasm32")] + { + Fr(InnerFr::from(val)) + } + #[cfg(not(target_arch = "wasm32"))] + { + Fr(bn254_ops::from_u128(val)) + } } } @@ -432,22 +488,60 @@ impl crate::MulPow2 for Fr {} impl MulPrimitiveInt for Fr { #[inline] fn mul_u64(&self, n: u64) -> Self { - Fr(bn254_ops::mul_u64(self.0, n)) + #[cfg(target_arch = "wasm32")] + { + Fr(self.0 * InnerFr::from(n)) + } + #[cfg(not(target_arch = "wasm32"))] + { + Fr(bn254_ops::mul_u64(self.0, n)) + } } #[inline(always)] fn mul_i64(&self, n: i64) -> Self { - Fr(bn254_ops::mul_i64(self.0, n)) + #[cfg(target_arch = "wasm32")] + { + let abs = Fr(self.0 * InnerFr::from(n.unsigned_abs())); + if n.is_negative() { + -abs + } else { + abs + } + } + #[cfg(not(target_arch = "wasm32"))] + { + Fr(bn254_ops::mul_i64(self.0, n)) + } } #[inline(always)] fn mul_u128(&self, n: u128) -> Self { - Fr(bn254_ops::mul_u128(self.0, n)) + #[cfg(target_arch = "wasm32")] + { + Fr(self.0 * InnerFr::from(n)) + } + #[cfg(not(target_arch = "wasm32"))] + { + Fr(bn254_ops::mul_u128(self.0, n)) + } } #[inline] fn mul_i128(&self, n: i128) -> Self { - Fr(bn254_ops::mul_i128(self.0, n)) + #[cfg(target_arch = "wasm32")] + { + let abs = Fr(self.0 * InnerFr::from(n.unsigned_abs())); + if n.is_negative() { + -abs + } else { + abs + } + } + #[cfg(not(target_arch = "wasm32"))] + { + Fr(bn254_ops::mul_i128(self.0, n)) + } } } diff --git a/crates/jolt-field/src/arkworks/bn254_ops.rs b/crates/jolt-field/src/arkworks/bn254_ops.rs index 3a1d061791..0203165d4a 100644 --- a/crates/jolt-field/src/arkworks/bn254_ops.rs +++ b/crates/jolt-field/src/arkworks/bn254_ops.rs @@ -2,11 +2,14 @@ //! //! Low-level field arithmetic (Montgomery/Barrett reduction, scalar multiplication, //! precomputed lookup tables). -use ark_bn254::FrConfig; +use core::cmp::Ordering; + +use ark_bn254::{Fr as ArkFr, FrConfig}; use ark_ff::{BigInt, Fp, MontConfig}; +#[cfg(not(target_arch = "wasm32"))] use num_traits::Zero; -type Fr = ark_bn254::Fr; +type Fr = ArkFr; /// a + b * c + carry → (result, new carry) #[inline(always)] @@ -36,6 +39,7 @@ const N: usize = 4; const MODULUS: [u64; N] = >::MODULUS.0; const INV: u64 = >::INV; +#[cfg(not(target_arch = "wasm32"))] const R: BigInt = >::R; const MODULUS_HAS_SPARE_BIT: bool = MODULUS[N - 1] >> 63 == 0; @@ -109,11 +113,13 @@ const BARRETT_MU: u64 = { }; /// 16384-entry lookup table mapping small integers to their Montgomery form. +#[cfg(not(target_arch = "wasm32"))] const PRECOMP_TABLE_SIZE: usize = 1 << 14; /// `PRECOMP_TABLE[i]` = Montgomery form of `i` for BN254 Fr. /// /// Uses `Fp::new()` which converts standard form → Montgomery form at compile time. +#[cfg(not(target_arch = "wasm32"))] static PRECOMP_TABLE: [Fr; PRECOMP_TABLE_SIZE] = { let mut table: [Fr; PRECOMP_TABLE_SIZE] = [Fp::new_unchecked(BigInt([0u64; N])); PRECOMP_TABLE_SIZE]; @@ -163,16 +169,16 @@ fn barrett_cond_subtract(r_tmp: BigInt<5>) -> BigInt { let r_n: [u64; N] = [r_tmp.0[0], r_tmp.0[1], r_tmp.0[2], r_tmp.0[3]]; - if compare_4(r_n, m2_lo) != core::cmp::Ordering::Less { + if compare_4(r_n, m2_lo) != Ordering::Less { // r_tmp >= 2p - if compare_4(r_n, m3_lo) != core::cmp::Ordering::Less { + if compare_4(r_n, m3_lo) != Ordering::Less { // r_tmp >= 3p → subtract 3p BigInt(sub_4(r_n, m3_lo)) } else { // 2p <= r_tmp < 3p → subtract 2p BigInt(sub_4(r_n, m2_lo)) } - } else if compare_4(r_n, MODULUS) != core::cmp::Ordering::Less { + } else if compare_4(r_n, MODULUS) != Ordering::Less { // p <= r_tmp < 2p → subtract p BigInt(sub_4(r_n, MODULUS)) } else { @@ -183,19 +189,19 @@ fn barrett_cond_subtract(r_tmp: BigInt<5>) -> BigInt { /// Compare two 4-limb numbers (big-endian comparison) #[inline(always)] -fn compare_4(a: [u64; N], b: [u64; N]) -> core::cmp::Ordering { +fn compare_4(a: [u64; N], b: [u64; N]) -> Ordering { let mut i = N; while i > 0 { i -= 1; if a[i] != b[i] { return if a[i] > b[i] { - core::cmp::Ordering::Greater + Ordering::Greater } else { - core::cmp::Ordering::Less + Ordering::Less }; } } - core::cmp::Ordering::Equal + Ordering::Equal } /// Subtract two 4-limb numbers: a - b. Caller guarantees a >= b. @@ -309,9 +315,9 @@ pub(crate) fn from_montgomery_reduce(unreduced: BigInt) -> Fr // Final conditional subtraction let needs_sub = if MODULUS_HAS_SPARE_BIT { - compare_4(result.0 .0, MODULUS) != core::cmp::Ordering::Less + compare_4(result.0 .0, MODULUS) != Ordering::Less } else { - carry != 0 || compare_4(result.0 .0, MODULUS) != core::cmp::Ordering::Less + carry != 0 || compare_4(result.0 .0, MODULUS) != Ordering::Less }; if needs_sub { result.0 = BigInt(sub_4(result.0 .0, MODULUS)); @@ -320,6 +326,7 @@ pub(crate) fn from_montgomery_reduce(unreduced: BigInt) -> Fr } /// Multiply BigInt<4> by u64, producing BigInt<5>. +#[cfg(not(target_arch = "wasm32"))] #[inline(always)] fn bigint4_mul_u64(a: &BigInt, b: u64) -> BigInt<5> { let mut res = BigInt::<5>([0u64; 5]); @@ -332,6 +339,7 @@ fn bigint4_mul_u64(a: &BigInt, b: u64) -> BigInt<5> { } /// Multiply BigInt<4> by u128, producing BigInt<6>. +#[cfg(not(target_arch = "wasm32"))] #[inline(always)] fn bigint4_mul_u128(a: &BigInt, b: u128) -> BigInt<6> { if b == 0 { @@ -360,6 +368,7 @@ fn bigint4_mul_u128(a: &BigInt, b: u128) -> BigInt<6> { } /// Barrett reduce BigInt<5> → Fr (N+1 → field element) +#[cfg(not(target_arch = "wasm32"))] #[inline(always)] fn from_unchecked_nplus1(element: BigInt<5>) -> Fr { let r = barrett_reduce_5_to_4(element); @@ -367,6 +376,7 @@ fn from_unchecked_nplus1(element: BigInt<5>) -> Fr { } /// Barrett reduce BigInt<6> → Fr via two rounds +#[cfg(not(target_arch = "wasm32"))] #[inline(always)] fn from_unchecked_nplus2(element: BigInt<6>) -> Fr { // Round 1: reduce top 5 limbs (indices 1..6) @@ -386,6 +396,7 @@ fn from_unchecked_nplus2(element: BigInt<6>) -> Fr { } /// Multiply a field element by u64. +#[cfg(not(target_arch = "wasm32"))] #[inline(always)] pub(crate) fn mul_u64(a: Fr, b: u64) -> Fr { if b == 0 || Zero::is_zero(&a) { @@ -399,6 +410,7 @@ pub(crate) fn mul_u64(a: Fr, b: u64) -> Fr { } /// Multiply a field element by i64. +#[cfg(not(target_arch = "wasm32"))] #[inline(always)] pub(crate) fn mul_i64(a: Fr, b: i64) -> Fr { let abs = b.unsigned_abs(); @@ -411,6 +423,7 @@ pub(crate) fn mul_i64(a: Fr, b: i64) -> Fr { } /// Multiply a field element by u128. +#[cfg(not(target_arch = "wasm32"))] #[inline(always)] pub(crate) fn mul_u128(a: Fr, b: u128) -> Fr { if b >> 64 == 0 { @@ -422,6 +435,7 @@ pub(crate) fn mul_u128(a: Fr, b: u128) -> Fr { } /// Multiply a field element by i128. +#[cfg(not(target_arch = "wasm32"))] #[inline(always)] pub(crate) fn mul_i128(a: Fr, b: i128) -> Fr { if b == 0 || Zero::is_zero(&a) { @@ -445,6 +459,7 @@ pub(crate) fn mul_i128(a: Fr, b: i128) -> Fr { } /// Convert u64 → Fr using precomp table for small values, mul_u64(R, n) otherwise. +#[cfg(not(target_arch = "wasm32"))] #[inline(always)] pub(crate) fn from_u64(n: u64) -> Fr { if (n as usize) < PRECOMP_TABLE_SIZE { @@ -455,6 +470,7 @@ pub(crate) fn from_u64(n: u64) -> Fr { } /// Convert u128 → Fr using precomp table for small values, mul_u128(R, n) otherwise. +#[cfg(not(target_arch = "wasm32"))] #[inline(always)] pub(crate) fn from_u128(n: u128) -> Fr { if n < PRECOMP_TABLE_SIZE as u128 { diff --git a/crates/jolt-hyperkzg/benches/hyperkzg.rs b/crates/jolt-hyperkzg/benches/hyperkzg.rs index 27c587e741..d61b633599 100644 --- a/crates/jolt-hyperkzg/benches/hyperkzg.rs +++ b/crates/jolt-hyperkzg/benches/hyperkzg.rs @@ -1,11 +1,11 @@ -use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion}; use jolt_crypto::Bn254; use jolt_field::{Fr, RandomSampling}; use jolt_hyperkzg::{HyperKZGProverSetup, HyperKZGScheme, HyperKZGVerifierSetup}; -use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme}; +use jolt_openings::{AdditivelyHomomorphicVerifier, CommitmentScheme, CommitmentSchemeVerifier}; use jolt_poly::Polynomial; -use jolt_transcript::Transcript; +use jolt_transcript::{Blake2bTranscript, Transcript}; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; @@ -16,7 +16,7 @@ fn make_setup(max_degree: usize) -> (HyperKZGProverSetup, HyperKZGVerifie let g1 = Bn254::g1_generator(); let g2 = Bn254::g2_generator(); let pk = TestScheme::setup(&mut rng, max_degree, g1, g2); - let vk = TestScheme::verifier_setup(&pk); + let vk = TestScheme::prover_to_verifier_setup(&pk); (pk, vk) } @@ -35,7 +35,7 @@ fn bench_commit(c: &mut Criterion) { Polynomial::::random(nv, &mut rng) }, |poly| TestScheme::commit(poly.evaluations(), &pk), - criterion::BatchSize::SmallInput, + BatchSize::SmallInput, ); }, ); @@ -61,7 +61,7 @@ fn bench_open(c: &mut Criterion) { (poly, point, eval) }, |(poly, point, eval)| { - let mut transcript = jolt_transcript::Blake2bTranscript::new(b"bench-open"); + let mut transcript = Blake2bTranscript::new(b"bench-open"); ::open( &poly, &point, @@ -71,7 +71,7 @@ fn bench_open(c: &mut Criterion) { &mut transcript, ) }, - criterion::BatchSize::SmallInput, + BatchSize::SmallInput, ); }, ); @@ -95,8 +95,7 @@ fn bench_verify(c: &mut Criterion) { let point: Vec = (0..nv).map(|_| Fr::random(&mut rng)).collect(); let eval = poly.evaluate(&point); let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - let mut transcript = - jolt_transcript::Blake2bTranscript::new(b"bench-verify"); + let mut transcript = Blake2bTranscript::new(b"bench-verify"); let proof = ::open( &poly, &point, @@ -108,9 +107,8 @@ fn bench_verify(c: &mut Criterion) { (commitment, point, eval, proof) }, |(commitment, point, eval, proof)| { - let mut transcript = - jolt_transcript::Blake2bTranscript::new(b"bench-verify"); - ::verify( + let mut transcript = Blake2bTranscript::new(b"bench-verify"); + ::verify( &commitment, &point, eval, @@ -119,7 +117,7 @@ fn bench_verify(c: &mut Criterion) { &mut transcript, ) }, - criterion::BatchSize::SmallInput, + BatchSize::SmallInput, ); }, ); diff --git a/crates/jolt-hyperkzg/fuzz/fuzz_targets/commit_open_verify.rs b/crates/jolt-hyperkzg/fuzz/fuzz_targets/commit_open_verify.rs index 51c8079b54..ab6b302377 100644 --- a/crates/jolt-hyperkzg/fuzz/fuzz_targets/commit_open_verify.rs +++ b/crates/jolt-hyperkzg/fuzz/fuzz_targets/commit_open_verify.rs @@ -5,7 +5,7 @@ use jolt_crypto::Bn254; use jolt_field::{Field, Fr}; use jolt_hyperkzg::HyperKZGScheme; -use jolt_openings::CommitmentScheme; +use jolt_openings::{CommitmentScheme, CommitmentSchemeVerifier}; use jolt_poly::Polynomial; use jolt_transcript::{Blake2bTranscript, Transcript}; use libfuzzer_sys::fuzz_target; @@ -27,7 +27,7 @@ fuzz_target!(|data: &[u8]| { let g1 = Bn254::g1_generator(); let g2 = Bn254::g2_generator(); let pk = TestScheme::setup(&mut rng, n, g1, g2); - let vk = TestScheme::verifier_setup(&pk); + let vk = TestScheme::prover_to_verifier_setup(&pk); let poly = Polynomial::::random(num_vars, &mut rng); let point: Vec = (0..num_vars).map(|_| Fr::random(&mut rng)).collect(); @@ -40,6 +40,6 @@ fuzz_target!(|data: &[u8]| { ::open(&poly, &point, eval, &pk, None, &mut pt); let mut vt = Blake2bTranscript::new(b"fuzz"); - ::verify(&commitment, &point, eval, &proof, &vk, &mut vt) + ::verify(&commitment, &point, eval, &proof, &vk, &mut vt) .expect("valid proof must verify"); }); diff --git a/crates/jolt-hyperkzg/fuzz/fuzz_targets/tampered_proof.rs b/crates/jolt-hyperkzg/fuzz/fuzz_targets/tampered_proof.rs index 1363e2487c..2deb6b742b 100644 --- a/crates/jolt-hyperkzg/fuzz/fuzz_targets/tampered_proof.rs +++ b/crates/jolt-hyperkzg/fuzz/fuzz_targets/tampered_proof.rs @@ -8,7 +8,7 @@ use jolt_crypto::{Bn254, JoltGroup}; use jolt_field::{Field, Fr}; use jolt_hyperkzg::HyperKZGScheme; -use jolt_openings::CommitmentScheme; +use jolt_openings::{CommitmentScheme, CommitmentSchemeVerifier}; use jolt_poly::Polynomial; use jolt_transcript::{Blake2bTranscript, Transcript}; use libfuzzer_sys::fuzz_target; @@ -29,7 +29,7 @@ fuzz_target!(|data: &[u8]| { let g1 = Bn254::g1_generator(); let g2 = Bn254::g2_generator(); let pk = TestScheme::setup(&mut rng, n, g1, g2); - let vk = TestScheme::verifier_setup(&pk); + let vk = TestScheme::prover_to_verifier_setup(&pk); let poly = Polynomial::::random(num_vars, &mut rng); let point: Vec = (0..num_vars).map(|_| Fr::random(&mut rng)).collect(); @@ -78,7 +78,7 @@ fuzz_target!(|data: &[u8]| { } let mut vt = Blake2bTranscript::new(b"fuzz-tamper"); - let result = ::verify( + let result = ::verify( &commitment, &point, eval, diff --git a/crates/jolt-hyperkzg/fuzz/fuzz_targets/wrong_eval.rs b/crates/jolt-hyperkzg/fuzz/fuzz_targets/wrong_eval.rs index 4c15d1af2f..51f1aa2b0f 100644 --- a/crates/jolt-hyperkzg/fuzz/fuzz_targets/wrong_eval.rs +++ b/crates/jolt-hyperkzg/fuzz/fuzz_targets/wrong_eval.rs @@ -8,7 +8,7 @@ use jolt_crypto::Bn254; use jolt_field::{Field, Fr}; use jolt_hyperkzg::HyperKZGScheme; -use jolt_openings::CommitmentScheme; +use jolt_openings::{CommitmentScheme, CommitmentSchemeVerifier}; use jolt_poly::Polynomial; use jolt_transcript::{Blake2bTranscript, Transcript}; use libfuzzer_sys::fuzz_target; @@ -29,7 +29,7 @@ fuzz_target!(|data: &[u8]| { let g1 = Bn254::g1_generator(); let g2 = Bn254::g2_generator(); let pk = TestScheme::setup(&mut rng, n, g1, g2); - let vk = TestScheme::verifier_setup(&pk); + let vk = TestScheme::prover_to_verifier_setup(&pk); let poly = Polynomial::::random(num_vars, &mut rng); let point: Vec = (0..num_vars).map(|_| Fr::random(&mut rng)).collect(); @@ -47,7 +47,7 @@ fuzz_target!(|data: &[u8]| { ::open(&poly, &point, eval, &pk, None, &mut pt); let mut vt = Blake2bTranscript::new(b"fuzz-wrong-eval"); - let result = ::verify( + let result = ::verify( &commitment, &point, wrong_eval, diff --git a/crates/jolt-hyperkzg/src/scheme.rs b/crates/jolt-hyperkzg/src/scheme.rs index 68b7a4fd14..bb75c2089e 100644 --- a/crates/jolt-hyperkzg/src/scheme.rs +++ b/crates/jolt-hyperkzg/src/scheme.rs @@ -12,8 +12,11 @@ use std::marker::PhantomData; use jolt_crypto::{Commitment, DeriveSetup, JoltGroup, PairingGroup, PedersenSetup}; use jolt_field::{FromPrimitiveInt, RandomSampling}; -use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme, OpeningsError}; -use jolt_poly::Polynomial; +use jolt_openings::{ + homomorphic_prove_batch, homomorphic_verify_batch, materialize_source_evaluations, + AdditivelyHomomorphic, AdditivelyHomomorphicVerifier, CommitmentScheme, + CommitmentSchemeVerifier, CommitmentSource, OpeningClaim, OpeningsError, ProverClaim, +}; use jolt_transcript::{AppendToTranscript, Label, LabelWithCount, Transcript}; use num_traits::{One, Zero}; use rayon::prelude::*; @@ -246,16 +249,60 @@ impl Commitment for HyperKZGScheme

{ type Output = HyperKZGCommitment

; } -impl CommitmentScheme for HyperKZGScheme

+impl CommitmentSchemeVerifier for HyperKZGScheme

where P::ScalarField: AppendToTranscript, P::G1: AppendToTranscript, { type Field = P::ScalarField; type Proof = HyperKZGProof

; - type ProverSetup = HyperKZGProverSetup

; + type BatchProof = Vec>; type VerifierSetup = HyperKZGVerifierSetup

; - type Polynomial = Polynomial; + + fn verify( + commitment: &Self::Output, + point: &[Self::Field], + eval: Self::Field, + proof: &Self::Proof, + setup: &Self::VerifierSetup, + transcript: &mut impl Transcript, + ) -> Result<(), OpeningsError> { + Self::verify(setup, commitment, point, &eval, proof, transcript) + .map_err(|_| OpeningsError::VerificationFailed) + } + + fn verify_batch( + claims: Vec>, + proof: &Self::BatchProof, + setup: &Self::VerifierSetup, + transcript: &mut impl Transcript, + ) -> Result<(), OpeningsError> { + homomorphic_verify_batch::(claims, proof, setup, transcript) + } + + fn bind_opening_inputs( + transcript: &mut impl Transcript, + point: &[Self::Field], + eval: &Self::Field, + ) { + transcript.append(&LabelWithCount( + b"hyperkzg_opening_point", + point.len() as u64, + )); + for p in point { + p.append_to_transcript(transcript); + } + transcript.append(&Label(b"hyperkzg_opening_eval")); + eval.append_to_transcript(transcript); + } +} + +impl CommitmentScheme for HyperKZGScheme

+where + P::ScalarField: AppendToTranscript, + P::G1: AppendToTranscript, +{ + type ProverSetup = HyperKZGProverSetup

; type OpeningHint = (); type SetupParams = (usize, P::G1, P::G2); @@ -265,70 +312,55 @@ where let mut rng = rand_core::OsRng; let max_degree = 1usize << max_num_vars; let prover = HyperKZGScheme::setup(&mut rng, max_degree, g1, g2); - let verifier = Self::verifier_setup(&prover); + let verifier = Self::prover_to_verifier_setup(&prover); (prover, verifier) } - fn verifier_setup(prover_setup: &Self::ProverSetup) -> Self::VerifierSetup { + fn prover_to_verifier_setup(prover_setup: &Self::ProverSetup) -> Self::VerifierSetup { HyperKZGVerifierSetup::from(prover_setup) } - fn commit + ?Sized>( - poly: &S, + fn commit + ?Sized>( + source: &S, setup: &Self::ProverSetup, ) -> (Self::Output, Self::OpeningHint) { // HyperKZG always works on dense evaluations. - let mut evaluations = Vec::with_capacity(1 << poly.num_vars()); - poly.for_each_row(poly.num_vars(), &mut |_, row| { - evaluations.extend_from_slice(row); - }); + let evaluations = materialize_source_evaluations(source); let point = kzg::kzg_commit::

(&evaluations, setup) .expect("SRS must be large enough for the polynomial"); (HyperKZGCommitment { point }, ()) } - fn open( - poly: &Self::Polynomial, + fn open( + poly: &S, point: &[Self::Field], _eval: Self::Field, setup: &Self::ProverSetup, _hint: Option, transcript: &mut impl Transcript, - ) -> Self::Proof { - Self::open(setup, poly.evaluations(), point, transcript) + ) -> Self::Proof + where + S: CommitmentSource + ?Sized, + { + let evaluations = materialize_source_evaluations(poly); + Self::open(setup, &evaluations, point, transcript) .expect("HyperKZG open should not fail with valid inputs") } - fn verify( - commitment: &Self::Output, - point: &[Self::Field], - eval: Self::Field, - proof: &Self::Proof, - setup: &Self::VerifierSetup, + fn prove_batch( + claims: Vec>, + hints: Vec, + setup: &Self::ProverSetup, transcript: &mut impl Transcript, - ) -> Result<(), OpeningsError> { - Self::verify(setup, commitment, point, &eval, proof, transcript) - .map_err(|_| OpeningsError::VerificationFailed) - } - - fn bind_opening_inputs( - transcript: &mut impl Transcript, - point: &[Self::Field], - eval: &Self::Field, - ) { - transcript.append(&LabelWithCount( - b"hyperkzg_opening_point", - point.len() as u64, - )); - for p in point { - p.append_to_transcript(transcript); - } - transcript.append(&Label(b"hyperkzg_opening_eval")); - eval.append_to_transcript(transcript); + ) -> Self::BatchProof + where + S: CommitmentSource, + { + homomorphic_prove_batch::(claims, hints, setup, transcript) } } -impl AdditivelyHomomorphic for HyperKZGScheme

+impl AdditivelyHomomorphicVerifier for HyperKZGScheme

where P::ScalarField: AppendToTranscript, P::G1: AppendToTranscript, @@ -342,10 +374,20 @@ where } } +impl AdditivelyHomomorphic for HyperKZGScheme

+where + P::ScalarField: AppendToTranscript, + P::G1: AppendToTranscript, +{ + fn combine_hints(hints: Vec, scalars: &[Self::Field]) -> Self::OpeningHint { + assert_eq!(hints.len(), scalars.len()); + } +} + #[cfg(test)] mod tests { use super::*; - use jolt_crypto::Bn254; + use jolt_crypto::{Bn254, Bn254G1, Pedersen, VectorCommitment}; use jolt_field::Fr; use jolt_poly::Polynomial; use jolt_transcript::Blake2bTranscript; @@ -359,7 +401,7 @@ mod tests { let g1 = Bn254::g1_generator(); let g2 = Bn254::g2_generator(); let prover = TestScheme::setup(&mut rng, max_degree, g1, g2); - let verifier = TestScheme::verifier_setup(&prover); + let verifier = TestScheme::prover_to_verifier_setup(&prover); (prover, verifier) } @@ -387,7 +429,7 @@ mod tests { ); let mut verifier_transcript = Blake2bTranscript::new(b"test"); - let result = ::verify( + let result = ::verify( &commitment, &point, eval, @@ -424,7 +466,7 @@ mod tests { ); let mut verifier_transcript = Blake2bTranscript::new(b"test-bad"); - let result = ::verify( + let result = ::verify( &commitment, &point, wrong_eval, @@ -513,7 +555,7 @@ mod tests { proof.v[0].clone_from(&v1); let mut verifier_transcript = Blake2bTranscript::new(b"test-tamper"); - let result = ::verify( + let result = ::verify( &commitment, &point, eval, @@ -601,7 +643,7 @@ mod tests { ::open(&poly, &point, eval, &pk, None, &mut pt); let mut vt = Blake2bTranscript::new(b"rand-test"); - ::verify( + ::verify( &commitment, &point, eval, @@ -615,16 +657,14 @@ mod tests { #[test] fn extract_vc_setup_produces_valid_pedersen() { - use jolt_crypto::{Pedersen, VectorCommitment}; - let n = 1 << 4; let (pk, _vk) = test_setup(n); let capacity = 5; - let vc_setup = PedersenSetup::::derive(&pk, capacity); + let vc_setup = PedersenSetup::::derive(&pk, capacity); assert_eq!( - as VectorCommitment>::capacity(&vc_setup), + as VectorCommitment>::capacity(&vc_setup), capacity, ); @@ -662,7 +702,14 @@ mod tests { let proof = ::open(&poly, &point, eval, &pk, None, &mut pt); let mut vt = Blake2bTranscript::new(b"trivial"); - ::verify(&commitment, &point, eval, &proof, &vk, &mut vt) - .expect("trivial polynomial should verify"); + ::verify( + &commitment, + &point, + eval, + &proof, + &vk, + &mut vt, + ) + .expect("trivial polynomial should verify"); } } diff --git a/crates/jolt-hyperkzg/src/types.rs b/crates/jolt-hyperkzg/src/types.rs index f6abd36374..ab67818a90 100644 --- a/crates/jolt-hyperkzg/src/types.rs +++ b/crates/jolt-hyperkzg/src/types.rs @@ -2,6 +2,8 @@ //! //! All types are generic over `P: PairingGroup` — no arkworks leakage. +use std::fmt::{Debug, Formatter, Result as FmtResult}; + use jolt_crypto::{HomomorphicCommitment, JoltGroup, PairingGroup}; use serde::{Deserialize, Serialize}; @@ -27,8 +29,8 @@ impl Clone for HyperKZGCommitment

{ } } -impl std::fmt::Debug for HyperKZGCommitment

{ - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl Debug for HyperKZGCommitment

{ + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { f.debug_struct("HyperKZGCommitment") .field("point", &self.point) .finish() diff --git a/crates/jolt-hyperkzg/tests/commit_open_verify.rs b/crates/jolt-hyperkzg/tests/commit_open_verify.rs index 52a8376880..1f7992694b 100644 --- a/crates/jolt-hyperkzg/tests/commit_open_verify.rs +++ b/crates/jolt-hyperkzg/tests/commit_open_verify.rs @@ -5,7 +5,7 @@ use jolt_crypto::Bn254; use jolt_field::{Fr, FromPrimitiveInt, RandomSampling}; use jolt_hyperkzg::{HyperKZGProverSetup, HyperKZGScheme, HyperKZGVerifierSetup}; -use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme}; +use jolt_openings::{AdditivelyHomomorphicVerifier, CommitmentScheme, CommitmentSchemeVerifier}; use jolt_poly::Polynomial; use jolt_transcript::{Blake2bTranscript, Transcript}; use rand_chacha::ChaCha20Rng; @@ -18,7 +18,7 @@ fn make_setup(max_degree: usize) -> (HyperKZGProverSetup, HyperKZGVerifie let g1 = Bn254::g1_generator(); let g2 = Bn254::g2_generator(); let pk = KzgPCS::setup(&mut rng, max_degree, g1, g2); - let vk = KzgPCS::verifier_setup(&pk); + let vk = KzgPCS::prover_to_verifier_setup(&pk); (pk, vk) } @@ -36,7 +36,7 @@ fn commit_open_verify( let proof = ::open(poly, point, eval, pk, None, &mut t_p); let mut t_v = Blake2bTranscript::new(label); - ::verify(&commitment, point, eval, &proof, vk, &mut t_v) + ::verify(&commitment, point, eval, &proof, vk, &mut t_v) .expect("verification should succeed"); } @@ -108,7 +108,7 @@ fn wrong_eval_rejected() { // Verifier checks with wrong eval let mut t_v = Blake2bTranscript::new(b"kzg-wrong"); - let result = ::verify( + let result = ::verify( &commitment, &point, wrong_eval, @@ -132,7 +132,7 @@ fn homomorphic_sum() { let (com_a, ()) = ::commit(a.evaluations(), &pk); let (com_b, ()) = ::commit(b.evaluations(), &pk); - let combined_com = ::combine( + let combined_com = ::combine( &[com_a, com_b], &[Fr::from_u64(1), Fr::from_u64(1)], ); @@ -145,8 +145,15 @@ fn homomorphic_sum() { let proof = ::open(&sum_poly, &point, eval, &pk, None, &mut t_p); let mut t_v = Blake2bTranscript::new(b"kzg-homo"); - ::verify(&combined_com, &point, eval, &proof, &vk, &mut t_v) - .expect("homomorphic sum must verify"); + ::verify( + &combined_com, + &point, + eval, + &proof, + &vk, + &mut t_v, + ) + .expect("homomorphic sum must verify"); } /// combine with arbitrary scalars: s_a·C_a + s_b·C_b == commit(s_a·a + s_b·b). @@ -162,7 +169,8 @@ fn homomorphic_weighted_combination() { let (com_a, ()) = ::commit(a.evaluations(), &pk); let (com_b, ()) = ::commit(b.evaluations(), &pk); - let combined_com = ::combine(&[com_a, com_b], &[s_a, s_b]); + let combined_com = + ::combine(&[com_a, com_b], &[s_a, s_b]); let weighted_poly = a * s_a + b * s_b; let point: Vec = (0..nv).map(|_| Fr::random(&mut rng)).collect(); @@ -173,8 +181,15 @@ fn homomorphic_weighted_combination() { ::open(&weighted_poly, &point, eval, &pk, None, &mut t_p); let mut t_v = Blake2bTranscript::new(b"kzg-weighted"); - ::verify(&combined_com, &point, eval, &proof, &vk, &mut t_v) - .expect("weighted combination must verify"); + ::verify( + &combined_com, + &point, + eval, + &proof, + &vk, + &mut t_v, + ) + .expect("weighted combination must verify"); } // Deterministic setup @@ -187,8 +202,8 @@ fn deterministic_setup_from_secret() { let pk1 = KzgPCS::setup_from_secret(beta, 16, g1, g2); let pk2 = KzgPCS::setup_from_secret(beta, 16, g1, g2); - let _vk1 = KzgPCS::verifier_setup(&pk1); - let vk2 = KzgPCS::verifier_setup(&pk2); + let _vk1 = KzgPCS::prover_to_verifier_setup(&pk1); + let vk2 = KzgPCS::prover_to_verifier_setup(&pk2); // Same setup yields same commitments let poly = Polynomial::new(vec![Fr::from_u64(1), Fr::from_u64(2)]); @@ -205,7 +220,7 @@ fn deterministic_setup_from_secret() { let mut t = Blake2bTranscript::new(b"det-setup"); let proof = ::open(&poly, &point, eval, &pk1, None, &mut t); let mut t = Blake2bTranscript::new(b"det-setup"); - ::verify(&com1, &point, eval, &proof, &vk2, &mut t) + ::verify(&com1, &point, eval, &proof, &vk2, &mut t) .expect("cross-setup verification must work"); } diff --git a/crates/jolt-openings/src/claims.rs b/crates/jolt-openings/src/claims.rs index d4b95bf0bd..fb267df29c 100644 --- a/crates/jolt-openings/src/claims.rs +++ b/crates/jolt-openings/src/claims.rs @@ -1,8 +1,10 @@ -//! Stateless claim types for PCS operations. +//! Stateless claim and result types for PCS operations. use jolt_field::Field; use jolt_poly::Polynomial; +use crate::schemes::{CommitmentSchemeVerifier, ZkOpeningScheme}; + /// Prover-side opening claim: polynomial, evaluation point, and claimed value. #[derive(Clone, Debug)] pub struct ProverClaim> { @@ -13,8 +15,179 @@ pub struct ProverClaim> { /// Verifier-side opening claim: commitment, point, and claimed value. #[derive(Clone, Debug)] -pub struct VerifierClaim { - pub commitment: C, +pub struct OpeningClaim +where + F: Field, + PCS: CommitmentSchemeVerifier, +{ + pub commitment: PCS::Output, pub point: Vec, pub eval: F, } + +/// Opening point pair for protocols whose public point order differs from the +/// backend's proof point order. +/// +/// `public` is the point that belongs to the surrounding protocol transcript +/// and output relation. `proof` is the coordinate order consumed by the PCS +/// proof algorithm. Most schemes set both fields equal; Dory uses `proof` for +/// its private row-major opening order while Jolt keeps `public` in protocol +/// order. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BatchOpeningPoint { + pub public: Vec, + pub proof: Vec, +} + +impl BatchOpeningPoint { + /// Builds a point whose public and proof coordinates are identical. + pub fn same(point: Vec) -> Self { + Self { + public: point.clone(), + proof: point, + } + } +} + +/// Prover-side raw term in a source-backed batch opening. +/// +/// `eval` is the source's raw evaluation at `point.public`. `eval_scale` +/// describes how that raw evaluation contributes to the PCS output relation. +/// For current Jolt/Dory Stage 8, dense and advice sources use nontrivial +/// embedding factors while RA sources use one. +#[derive(Clone, Debug)] +pub struct ProverBatchOpeningTerm { + pub claim_id: ClaimId, + pub source_id: SourceId, + pub point: BatchOpeningPoint, + pub eval: F, + pub eval_scale: F, +} + +/// Verifier-side raw term in a source-backed batch opening. +#[derive(Clone, Debug)] +pub struct VerifierBatchOpeningTerm +where + F: Field, + PCS: CommitmentSchemeVerifier, +{ + pub claim_id: ClaimId, + pub source_id: SourceId, + pub commitment: PCS::Output, + pub point: BatchOpeningPoint, + pub eval: F, + pub eval_scale: F, +} + +/// A linear coefficient applied to a committed source. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LinearSourceTerm { + pub source_id: SourceId, + pub coefficient: F, +} + +/// The value opened by a source-backed batch-opening proof. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BatchOutputValue { + Public(F), + Hidden(HidingCommitment), +} + +impl BatchOutputValue { + /// Returns the scalar when this is a transparent output. + pub fn as_public(&self) -> Option<&F> { + match self { + Self::Public(value) => Some(value), + Self::Hidden(_) => None, + } + } + + /// Returns the hiding commitment when this is a ZK output. + pub fn as_hidden(&self) -> Option<&HidingCommitment> { + match self { + Self::Public(_) => None, + Self::Hidden(commitment) => Some(commitment), + } + } +} + +/// One output created by a source-backed batch-opening proof. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OpenedBatchOutput { + pub point: Vec, + pub value: BatchOutputValue, +} + +/// Expression describing how a PCS output is derived from raw claim terms. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum BatchOutputExpression { + /// Linear relation `output = Σ coefficient_i * claim_i`. + Linear(Vec<(ClaimId, F)>), +} + +/// Relation between one opened PCS output and the raw claims supplied by the +/// surrounding protocol. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BatchOutputRelation { + pub output_index: usize, + pub expression: BatchOutputExpression, +} + +impl BatchOutputRelation { + /// Returns the linear terms when this is a linear output relation. + pub fn linear_terms(&self) -> Option<&[(ClaimId, F)]> { + match &self.expression { + BatchOutputExpression::Linear(terms) => Some(terms), + } + } +} + +/// Public metadata returned by source-backed batch opening. +/// +/// The proof itself remains `PCS::BatchProof`; this structure tells protocol +/// code which output values the PCS opened and how those outputs relate to the +/// raw claims it supplied. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BatchOpeningPublic { + pub outputs: Vec>, + pub relations: Vec>, +} + +impl BatchOpeningPublic { + /// Returns the only linear relation when the batch opening produced exactly + /// one linear output relation. + pub fn single_linear_relation(&self) -> Option<&BatchOutputRelation> { + let [relation] = self.relations.as_slice() else { + return None; + }; + Some(relation) + } +} + +/// Prover-only witnesses for ZK source-backed batch-opening outputs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ZkBatchOpeningWitness { + pub output_values: Vec, + pub output_blinds: Vec, +} + +/// Transparent prover result for source-backed batch opening. +#[derive(Clone, Debug)] +pub struct BatchOpeningProverResult +where + PCS: CommitmentSchemeVerifier, +{ + pub proof: PCS::BatchProof, + pub public: BatchOpeningPublic, +} + +/// ZK prover result for source-backed batch opening. +#[derive(Clone, Debug)] +pub struct ZkBatchOpeningProverResult +where + PCS: ZkOpeningScheme, +{ + pub proof: PCS::BatchProof, + pub public: BatchOpeningPublic, + pub witness: ZkBatchOpeningWitness, +} diff --git a/crates/jolt-openings/src/homomorphic.rs b/crates/jolt-openings/src/homomorphic.rs new file mode 100644 index 0000000000..12f4586ffd --- /dev/null +++ b/crates/jolt-openings/src/homomorphic.rs @@ -0,0 +1,337 @@ +//! Homomorphic batched-opening helper via random linear combination. + +use std::iter::successors; + +use jolt_crypto::HomomorphicCommitment; +use jolt_field::Field; +use jolt_transcript::{AppendToTranscript, LabelWithCount, Transcript}; + +use crate::claims::{OpeningClaim, ProverClaim}; +use crate::error::OpeningsError; +use crate::schemes::{ + AdditivelyHomomorphic, AdditivelyHomomorphicVerifier, CommitmentScheme, + CommitmentSchemeVerifier, +}; +use crate::sources::{materialize_source_evaluations, CommitmentSource}; + +/// Groups prover claims by point, RLC-combines each group, and opens one proof +/// per group. +#[tracing::instrument(skip_all, name = "homomorphic_prove_batch")] +pub fn homomorphic_prove_batch( + claims: Vec>, + hints: Vec, + setup: &PCS::ProverSetup, + transcript: &mut T, +) -> Vec +where + PCS: AdditivelyHomomorphic, + PCS::Output: HomomorphicCommitment, + S: CommitmentSource, + T: Transcript, +{ + assert_eq!( + claims.len(), + hints.len(), + "one opening hint is required for each prover claim", + ); + if claims.is_empty() { + return Vec::new(); + } + + if claims.len() == 1 { + let mut claims = claims.into_iter(); + let mut hints = hints.into_iter(); + let Some(claim) = claims.next() else { + unreachable!("single claim exists after len check"); + }; + let Some(hint) = hints.next() else { + unreachable!("single hint exists after len check"); + }; + return vec![PCS::open( + &claim.polynomial, + &claim.point, + claim.eval, + setup, + Some(hint), + transcript, + )]; + } + + bind_batch_claims::(&claims, transcript); + + let groups = group_prover_claims_by_point::(claims.into_iter().zip(hints).collect()); + let mut proofs = Vec::with_capacity(groups.len()); + for (point, group_claims) in groups { + let rho: PCS::Field = transcript.challenge(); + let powers = rho_powers(rho, group_claims.len()); + + let mut eval_slices = Vec::with_capacity(group_claims.len()); + let mut evals = Vec::with_capacity(group_claims.len()); + let mut hints = Vec::with_capacity(group_claims.len()); + for (claim, hint) in &group_claims { + eval_slices.push(source_evaluations(&claim.polynomial)); + evals.push(claim.eval); + hints.push(hint.clone()); + } + let eval_slices: Vec<&[PCS::Field]> = eval_slices.iter().map(Vec::as_slice).collect(); + + let combined_evals = rlc_combine(&eval_slices, rho); + let combined_eval = rlc_combine_scalars(&evals, rho); + let combined_hint = PCS::combine_hints(hints, &powers); + proofs.push(PCS::open( + &combined_evals, + &point, + combined_eval, + setup, + Some(combined_hint), + transcript, + )); + } + proofs +} + +/// Groups verifier claims by point, RLC-combines each group, and verifies one +/// proof per group. +#[tracing::instrument(skip_all, name = "homomorphic_verify_batch")] +pub fn homomorphic_verify_batch( + claims: Vec>, + proofs: &[PCS::Proof], + setup: &PCS::VerifierSetup, + transcript: &mut T, +) -> Result<(), OpeningsError> +where + PCS: AdditivelyHomomorphicVerifier, + PCS::Output: HomomorphicCommitment, + T: Transcript, +{ + if claims.is_empty() { + if proofs.is_empty() { + return Ok(()); + } + return Err(OpeningsError::VerificationFailed); + } + + if claims.len() == 1 { + let [proof] = proofs else { + return Err(OpeningsError::VerificationFailed); + }; + let mut claims = claims.into_iter(); + let Some(claim) = claims.next() else { + unreachable!("single claim exists after len check"); + }; + return PCS::verify( + &claim.commitment, + &claim.point, + claim.eval, + proof, + setup, + transcript, + ); + } + + bind_batch_claims::(&claims, transcript); + + let groups = group_opening_claims_by_point::(claims); + if groups.len() != proofs.len() { + return Err(OpeningsError::VerificationFailed); + } + + for ((point, group_claims), proof) in groups.into_iter().zip(proofs.iter()) { + let rho: PCS::Field = transcript.challenge(); + let powers = rho_powers(rho, group_claims.len()); + + let commitments: Vec = group_claims + .iter() + .map(|claim| claim.commitment.clone()) + .collect(); + let evals: Vec = group_claims.iter().map(|claim| claim.eval).collect(); + + let combined_commitment = PCS::combine(&commitments, &powers); + let combined_eval = rlc_combine_scalars(&evals, rho); + PCS::verify( + &combined_commitment, + &point, + combined_eval, + proof, + setup, + transcript, + )?; + } + Ok(()) +} + +/// result[i] = p_1[i] + ρ · p_2[i] + ρ² · p_3[i] + ... . +#[expect( + clippy::expect_used, + reason = "empty polynomials is an API contract violation" +)] +#[tracing::instrument(skip_all, name = "rlc_combine")] +pub fn rlc_combine(polynomials: &[&[F]], rho: F) -> Vec { + let (last, rest) = polynomials + .split_last() + .expect("rlc_combine requires at least one polynomial"); + let len = last.len(); + + let mut result = last.to_vec(); + for p in rest.iter().rev() { + assert_eq!(p.len(), len); + for (r, &val) in result.iter_mut().zip(p.iter()) { + *r = *r * rho + val; + } + } + result +} + +/// v_1 + ρ · v_2 + ρ² · v_3 + ... . +pub fn rlc_combine_scalars(evals: &[F], rho: F) -> F { + assert!(!evals.is_empty(), "need at least one evaluation"); + let mut result = F::zero(); + for &v in evals.iter().rev() { + result = result * rho + v; + } + result +} + +fn bind_batch_claims(claims: &[C], transcript: &mut T) +where + F: Field, + C: ClaimEval, + T: Transcript, +{ + transcript.append(&LabelWithCount(b"rlc_claims", claims.len() as u64)); + for claim in claims { + claim.eval().append_to_transcript(transcript); + } +} + +trait ClaimEval { + fn eval(&self) -> F; +} + +impl ClaimEval for ProverClaim +where + F: Field, +{ + fn eval(&self) -> F { + self.eval + } +} + +impl ClaimEval for OpeningClaim +where + F: Field, + PCS: CommitmentSchemeVerifier, +{ + fn eval(&self) -> F { + self.eval + } +} + +fn rho_powers(rho: F, n: usize) -> Vec { + successors(Some(F::from_u64(1)), |prev| Some(*prev * rho)) + .take(n) + .collect() +} + +fn source_evaluations(source: &S) -> Vec +where + F: Field, + S: CommitmentSource + ?Sized, +{ + materialize_source_evaluations(source) +} + +type ProverPointGroup = Vec<(Vec, Vec>)>; + +type ProverClaimWithHint = (ProverClaim, ::OpeningHint); + +fn group_prover_claims_by_point( + claims: Vec>, +) -> ProverPointGroup +where + PCS: CommitmentScheme, + S: CommitmentSource, +{ + let mut groups: ProverPointGroup = Vec::new(); + for (claim, hint) in claims { + if let Some((_, group)) = groups.iter_mut().find(|(point, _)| *point == claim.point) { + group.push((claim, hint)); + } else { + let point = claim.point.clone(); + groups.push((point, vec![(claim, hint)])); + } + } + groups +} + +type OpeningPointGroup = Vec<(Vec, Vec>)>; + +fn group_opening_claims_by_point( + claims: Vec>, +) -> OpeningPointGroup +where + PCS: CommitmentSchemeVerifier, +{ + let mut groups: OpeningPointGroup = Vec::new(); + for claim in claims { + if let Some((_, group)) = groups.iter_mut().find(|(point, _)| *point == claim.point) { + group.push(claim); + } else { + let point = claim.point.clone(); + groups.push((point, vec![claim])); + } + } + groups +} + +#[cfg(test)] +mod tests { + use super::*; + use jolt_field::{Fr, FromPrimitiveInt, RandomSampling}; + use jolt_poly::Polynomial; + use rand_chacha::rand_core::SeedableRng; + use rand_chacha::ChaCha20Rng; + + #[test] + fn rlc_combine_single_polynomial_is_identity() { + let evals: Vec = (0..4).map(|i| Fr::from_u64(i + 1)).collect(); + let rho = Fr::from_u64(7); + let result = rlc_combine(&[&evals], rho); + assert_eq!(result, evals); + } + + #[test] + fn rlc_combine_two_polynomials() { + let p1: Vec = (1..=4).map(Fr::from_u64).collect(); + let p2: Vec = (5..=8).map(Fr::from_u64).collect(); + let rho = Fr::from_u64(3); + let result = rlc_combine(&[&p1, &p2], rho); + for i in 0..4 { + assert_eq!(result[i], p1[i] + rho * p2[i], "mismatch at index {i}"); + } + } + + #[test] + fn rlc_combine_scalars_consistent_with_rlc_combine() { + let mut rng = ChaCha20Rng::seed_from_u64(555); + let num_vars = 3; + let rho = Fr::from_u64(7); + + let p1 = Polynomial::::random(num_vars, &mut rng); + let p2 = Polynomial::::random(num_vars, &mut rng); + let p3 = Polynomial::::random(num_vars, &mut rng); + + let point: Vec = (0..num_vars).map(|_| Fr::random(&mut rng)).collect(); + + let eval1 = p1.evaluate(&point); + let eval2 = p2.evaluate(&point); + let eval3 = p3.evaluate(&point); + + let combined = rlc_combine(&[p1.evaluations(), p2.evaluations(), p3.evaluations()], rho); + let combined_poly = Polynomial::new(combined); + let result_via_poly = combined_poly.evaluate(&point); + let result_via_scalars = rlc_combine_scalars(&[eval1, eval2, eval3], rho); + + assert_eq!(result_via_poly, result_via_scalars); + } +} diff --git a/crates/jolt-openings/src/lib.rs b/crates/jolt-openings/src/lib.rs index 1a66e5b36e..f893afeb27 100644 --- a/crates/jolt-openings/src/lib.rs +++ b/crates/jolt-openings/src/lib.rs @@ -1,42 +1,55 @@ -//! PCS traits and opening reduction for the Jolt zkVM. +//! PCS traits and homomorphic batch openings for the Jolt zkVM. //! -//! Abstract interfaces for polynomial commitment schemes (PCS) and a reduction -//! framework for batching opening claims. Protocol code is written generically -//! over the PCS with zero implementation leakage. -//! -//! # Design -//! -//! - **Stateless.** No accumulators. Claims are plain data ([`ProverClaim`], -//! [`VerifierClaim`]) collected by the caller in `Vec`s. -//! - **Reduction is separate from proving.** [`reduce_prover`] / -//! [`reduce_verifier`] transform claims (many → fewer) via RLC. -//! The PCS opens the reduced claims. -//! - **No batching in PCS traits.** Batching is a reduction concern, not a -//! PCS property. +//! The crate owns the abstract polynomial-commitment boundary. Protocol code +//! asks to commit and open sources; PCS backends decide whether to materialize, +//! stream, parallelize by row, or use a backend-specific schedule. //! //! # Trait Hierarchy //! //! ```text -//! Commitment (jolt-crypto: Output type) -//! │ -//! CommitmentScheme (+ Field, Proof, commit/open/verify) -//! ╱ ╲ -//! AdditivelyHomomorphic ZkOpeningScheme -//! (+ combine) (+ commit_zk/open_zk/verify_zk) -//! │ -//! StreamingCommitment -//! (+ begin/feed/finish) +//! CommitmentSchemeVerifier verify / verify_batch +//! │ +//! CommitmentScheme commit / commit_batch / open / prove_batch +//! LinearOpeningScheme linear source-backed batch openings +//! +//! VerifierSetupFromPublicParams derives verifier setup from public params +//! +//! AdditivelyHomomorphicVerifier combine commitments +//! │ +//! AdditivelyHomomorphic combine opening hints +//! +//! ZkOpeningSchemeVerifier verify_zk +//! │ +//! ZkOpeningScheme commit_zk / commit_batch_zk / open_zk +//! ZkLinearOpeningScheme ZK linear source-backed batch openings //! ``` mod claims; mod error; +mod homomorphic; #[cfg(any(test, feature = "test-utils"))] pub mod mock; -mod reduction; mod schemes; +mod sources; -pub use claims::{ProverClaim, VerifierClaim}; +pub use claims::{ + BatchOpeningPoint, BatchOpeningProverResult, BatchOpeningPublic, BatchOutputExpression, + BatchOutputRelation, BatchOutputValue, LinearSourceTerm, OpenedBatchOutput, OpeningClaim, + ProverBatchOpeningTerm, ProverClaim, VerifierBatchOpeningTerm, ZkBatchOpeningProverResult, + ZkBatchOpeningWitness, +}; pub use error::OpeningsError; -pub use reduction::{reduce_prover, reduce_verifier, rlc_combine, rlc_combine_scalars}; - -pub use schemes::{AdditivelyHomomorphic, CommitmentScheme, StreamingCommitment, ZkOpeningScheme}; +pub use homomorphic::{ + homomorphic_prove_batch, homomorphic_verify_batch, rlc_combine, rlc_combine_scalars, +}; +pub use schemes::{ + AdditivelyHomomorphic, AdditivelyHomomorphicVerifier, CommitmentScheme, + CommitmentSchemeVerifier, EvaluationCommitmentProver, EvaluationCommitmentScheme, + LinearOpeningScheme, LinearOpeningSchemeVerifier, VerifierSetupFromPublicParams, + ZkLinearOpeningScheme, ZkLinearOpeningSchemeVerifier, ZkOpeningScheme, ZkOpeningSchemeVerifier, +}; +pub use sources::{ + materialize_source_evaluations, BatchCommitmentSource, BatchOpeningSource, CommitmentSource, + LinearCombinationOpeningSource, MaterializedLinearCombination, OneHotEntries, OneHotIndex, + OneHotRow, SourceId, SourceRow, +}; diff --git a/crates/jolt-openings/src/mock.rs b/crates/jolt-openings/src/mock.rs index 8ff531493c..cda60b53b6 100644 --- a/crates/jolt-openings/src/mock.rs +++ b/crates/jolt-openings/src/mock.rs @@ -10,8 +10,15 @@ use serde::{Deserialize, Serialize}; use jolt_crypto::HomomorphicCommitment; +use crate::claims::{OpeningClaim, ProverClaim}; use crate::error::OpeningsError; -use crate::schemes::{AdditivelyHomomorphic, CommitmentScheme, ZkOpeningScheme}; +use crate::homomorphic::{homomorphic_prove_batch, homomorphic_verify_batch}; +use crate::schemes::{ + AdditivelyHomomorphic, AdditivelyHomomorphicVerifier, CommitmentScheme, + CommitmentSchemeVerifier, LinearOpeningScheme, LinearOpeningSchemeVerifier, + VerifierSetupFromPublicParams, ZkOpeningScheme, ZkOpeningSchemeVerifier, +}; +use crate::sources::{materialize_source_evaluations, CommitmentSource}; #[derive(Clone, Debug)] pub struct MockCommitmentScheme(PhantomData); @@ -46,44 +53,11 @@ impl Commitment for MockCommitmentScheme { type Output = MockCommitment; } -impl CommitmentScheme for MockCommitmentScheme { +impl CommitmentSchemeVerifier for MockCommitmentScheme { type Field = F; type Proof = MockProof; - type ProverSetup = (); + type BatchProof = Vec>; type VerifierSetup = (); - type Polynomial = Polynomial; - type OpeningHint = (); - type SetupParams = (); - - fn setup(_params: Self::SetupParams) -> ((), ()) { - ((), ()) - } - - fn verifier_setup(_prover_setup: &()) {} - - fn commit + ?Sized>( - poly: &P, - _setup: &Self::ProverSetup, - ) -> (Self::Output, ()) { - let mut evaluations = Vec::with_capacity(1 << poly.num_vars()); - poly.for_each_row(poly.num_vars(), &mut |_, row| { - evaluations.extend_from_slice(row); - }); - (MockCommitment { evaluations }, ()) - } - - fn open( - poly: &Self::Polynomial, - _point: &[Self::Field], - _eval: Self::Field, - _setup: &Self::ProverSetup, - _hint: Option<()>, - _transcript: &mut impl Transcript, - ) -> Self::Proof { - MockProof { - evaluations: poly.evaluations().to_vec(), - } - } fn verify( commitment: &Self::Output, @@ -115,6 +89,73 @@ impl CommitmentScheme for MockCommitmentScheme { _eval: &Self::Field, ) { } + + fn verify_batch( + claims: Vec>, + proof: &Self::BatchProof, + setup: &Self::VerifierSetup, + transcript: &mut impl Transcript, + ) -> Result<(), OpeningsError> { + homomorphic_verify_batch::(claims, proof, setup, transcript) + } +} + +impl VerifierSetupFromPublicParams for MockCommitmentScheme { + type PublicParams = (); + + fn verifier_setup_from_public_params(_params: Self::PublicParams) -> Self::VerifierSetup {} +} + +impl CommitmentScheme for MockCommitmentScheme { + type ProverSetup = (); + type OpeningHint = (); + type SetupParams = (); + + fn setup(_params: Self::SetupParams) -> ((), ()) { + ((), ()) + } + + fn prover_to_verifier_setup(_prover_setup: &()) {} + + fn commit + ?Sized>( + source: &S, + _setup: &Self::ProverSetup, + ) -> (Self::Output, ()) { + ( + MockCommitment { + evaluations: materialize_source_evaluations(source), + }, + (), + ) + } + + fn open( + poly: &S, + _point: &[Self::Field], + _eval: Self::Field, + _setup: &Self::ProverSetup, + _hint: Option<()>, + _transcript: &mut impl Transcript, + ) -> Self::Proof + where + S: CommitmentSource + ?Sized, + { + MockProof { + evaluations: materialize_source_evaluations(poly), + } + } + + fn prove_batch( + claims: Vec>, + hints: Vec, + setup: &Self::ProverSetup, + transcript: &mut impl Transcript, + ) -> Self::BatchProof + where + S: CommitmentSource, + { + homomorphic_prove_batch::(claims, hints, setup, transcript) + } } impl HomomorphicCommitment for MockCommitment { @@ -132,7 +173,7 @@ impl HomomorphicCommitment for MockCommitment { } } -impl AdditivelyHomomorphic for MockCommitmentScheme { +impl AdditivelyHomomorphicVerifier for MockCommitmentScheme { fn combine(commitments: &[Self::Output], scalars: &[Self::Field]) -> Self::Output { assert_eq!(commitments.len(), scalars.len()); let len = commitments.first().map_or(0, |c| c.evaluations.len()); @@ -150,6 +191,16 @@ impl AdditivelyHomomorphic for MockCommitmentScheme { } } +impl AdditivelyHomomorphic for MockCommitmentScheme { + fn combine_hints(hints: Vec, scalars: &[Self::Field]) -> Self::OpeningHint { + assert_eq!(hints.len(), scalars.len()); + } +} + +impl LinearOpeningSchemeVerifier for MockCommitmentScheme {} + +impl LinearOpeningScheme for MockCommitmentScheme {} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(bound = "")] pub struct MockHidingCommitment { @@ -162,46 +213,83 @@ impl AppendToTranscript for MockHidingCommitment { } } -impl ZkOpeningScheme for MockCommitmentScheme { +impl ZkOpeningSchemeVerifier for MockCommitmentScheme { type HidingCommitment = MockHidingCommitment; + + fn verify_zk( + commitment: &Self::Output, + _point: &[Self::Field], + proof: &Self::Proof, + _setup: &Self::VerifierSetup, + _transcript: &mut impl Transcript, + ) -> Result<(), OpeningsError> { + if commitment.evaluations != proof.evaluations { + return Err(OpeningsError::CommitmentMismatch { + expected: format!("len={}", commitment.evaluations.len()), + actual: format!("len={}", proof.evaluations.len()), + }); + } + Ok(()) + } + + fn verify_batch_zk( + claims: Vec>, + proof: &Self::BatchProof, + setup: &Self::VerifierSetup, + transcript: &mut impl Transcript, + ) -> Result<(), OpeningsError> { + Self::verify_batch(claims, proof, setup, transcript) + } + + fn bind_zk_opening_inputs( + transcript: &mut impl Transcript, + point: &[Self::Field], + hiding_commitment: &Self::HidingCommitment, + ) { + Self::bind_opening_inputs(transcript, point, &hiding_commitment.eval); + } +} + +impl ZkOpeningScheme for MockCommitmentScheme { type Blind = (); - fn commit_zk + ?Sized>( - poly: &P, + fn commit_zk + ?Sized>( + source: &S, setup: &Self::ProverSetup, ) -> (Self::Output, Self::OpeningHint) { - Self::commit(poly, setup) + Self::commit(source, setup) } - fn open_zk( - poly: &Self::Polynomial, + fn open_zk( + poly: &S, _point: &[Self::Field], eval: Self::Field, _setup: &Self::ProverSetup, _hint: Self::OpeningHint, _transcript: &mut impl Transcript, - ) -> (Self::Proof, Self::HidingCommitment, Self::Blind) { + ) -> (Self::Proof, Self::HidingCommitment, Self::Blind) + where + S: CommitmentSource + ?Sized, + { let proof = MockProof { - evaluations: poly.evaluations().to_vec(), + evaluations: materialize_source_evaluations(poly), }; let eval_commitment = MockHidingCommitment { eval }; (proof, eval_commitment, ()) } - fn verify_zk( - commitment: &Self::Output, - _point: &[Self::Field], - proof: &Self::Proof, - _setup: &Self::VerifierSetup, - _transcript: &mut impl Transcript, - ) -> Result<(), OpeningsError> { - if commitment.evaluations != proof.evaluations { - return Err(OpeningsError::CommitmentMismatch { - expected: format!("len={}", commitment.evaluations.len()), - actual: format!("len={}", proof.evaluations.len()), - }); - } - Ok(()) + fn prove_batch_zk( + claims: Vec>, + hints: Vec, + setup: &Self::ProverSetup, + transcript: &mut impl Transcript, + ) -> (Self::BatchProof, Self::HidingCommitment, Self::Blind) + where + S: CommitmentSource, + { + let eval = claims.first().map_or_else(F::zero, |claim| claim.eval); + let proof = Self::prove_batch(claims, hints, setup, transcript); + (proof, MockHidingCommitment { eval }, ()) } } @@ -209,9 +297,14 @@ impl ZkOpeningScheme for MockCommitmentScheme { #[expect(clippy::expect_used, reason = "tests may panic on assertion failures")] mod tests { use super::*; - use crate::{reduce_prover, reduce_verifier, ProverClaim, VerifierClaim}; + use crate::{ + BatchOpeningPoint, BatchOpeningSource, BatchOutputExpression, CommitmentSource, + LinearCombinationOpeningSource, MaterializedLinearCombination, OneHotEntries, OneHotIndex, + OneHotRow, OpeningClaim, ProverBatchOpeningTerm, ProverClaim, SourceRow, + VerifierBatchOpeningTerm, + }; use jolt_field::{Fr, FromPrimitiveInt, RandomSampling}; - use jolt_poly::Polynomial; + use jolt_poly::{MultilinearPoly, Polynomial}; use jolt_transcript::Blake2bTranscript; use rand_chacha::rand_core::SeedableRng; use rand_chacha::ChaCha20Rng; @@ -306,6 +399,192 @@ mod tests { assert_eq!(c_sum_direct, c_sum_combined); } + #[test] + fn strided_source_rows_materialize_skipped_columns() { + struct TestSource { + rows: Vec>, + dense: Polynomial, + } + + impl CommitmentSource for TestSource { + fn num_vars(&self) -> usize { + self.dense.num_vars() + } + + fn evaluate(&self, point: &[Fr]) -> Fr { + self.dense.evaluate(point) + } + + fn natural_chunk_len(&self) -> Option { + Some(8) + } + + fn for_each_row(&self, _chunk_len: usize, mut visit: V) + where + V: for<'row> FnMut(usize, SourceRow<'row, Fr>), + { + for (row_index, row) in self.rows.iter().enumerate() { + visit( + row_index, + SourceRow::StridedU64 { + values: row, + column_stride: 4, + }, + ); + } + } + + fn fold_rows(&self, left: &[Fr], chunk_len: usize) -> Vec { + let sigma = chunk_len.trailing_zeros() as usize; + MultilinearPoly::fold_rows(&self.dense, left, sigma) + } + } + + let rows = vec![vec![3, 5], vec![7, 11]]; + let mut dense = vec![Fr::from_u64(0); 16]; + dense[0] = Fr::from_u64(3); + dense[4] = Fr::from_u64(5); + dense[8] = Fr::from_u64(7); + dense[12] = Fr::from_u64(11); + + let source = TestSource { + rows, + dense: Polynomial::new(dense.clone()), + }; + + let (commitment, ()) = MockPCS::commit(&source, &()); + assert_eq!(commitment.evaluations, dense); + } + + #[test] + fn one_hot_source_rows_materialize_hot_coordinate_major() { + struct TestSource { + entries: Vec>, + dense: Polynomial, + } + + impl CommitmentSource for TestSource { + fn num_vars(&self) -> usize { + self.dense.num_vars() + } + + fn evaluate(&self, point: &[Fr]) -> Fr { + self.dense.evaluate(point) + } + + fn natural_chunk_len(&self) -> Option { + Some(self.entries.len()) + } + + fn for_each_row(&self, _chunk_len: usize, mut visit: V) + where + V: for<'row> FnMut(usize, SourceRow<'row, Fr>), + { + visit( + 0, + SourceRow::OneHot(OneHotRow { + log_domain_size: 2, + entries: OneHotEntries::MaybeZero(&self.entries), + }), + ); + } + + fn fold_rows(&self, left: &[Fr], chunk_len: usize) -> Vec { + let sigma = chunk_len.trailing_zeros() as usize; + MultilinearPoly::fold_rows(&self.dense, left, sigma) + } + } + + let entries = vec![ + Some(OneHotIndex::new(1, 2).expect("valid index")), + None, + Some(OneHotIndex::new(3, 2).expect("valid index")), + None, + ]; + let mut dense = vec![Fr::from_u64(0); 16]; + dense[4] = Fr::from_u64(1); + dense[14] = Fr::from_u64(1); + + let source = TestSource { + entries, + dense: Polynomial::new(dense.clone()), + }; + + let (commitment, ()) = MockPCS::commit(&source, &()); + assert_eq!(commitment.evaluations, dense); + } + + #[test] + fn one_hot_source_rows_materialize_multi_row_hot_coordinate_major() { + struct TestSource { + chunks: Vec>>, + dense: Polynomial, + } + + impl CommitmentSource for TestSource { + fn num_vars(&self) -> usize { + self.dense.num_vars() + } + + fn evaluate(&self, point: &[Fr]) -> Fr { + self.dense.evaluate(point) + } + + fn natural_chunk_len(&self) -> Option { + self.chunks.first().map(Vec::len) + } + + fn for_each_row(&self, _chunk_len: usize, mut visit: V) + where + V: for<'row> FnMut(usize, SourceRow<'row, Fr>), + { + for (row_index, chunk) in self.chunks.iter().enumerate() { + visit( + row_index, + SourceRow::OneHot(OneHotRow { + log_domain_size: 2, + entries: OneHotEntries::MaybeZero(chunk), + }), + ); + } + } + + fn fold_rows(&self, left: &[Fr], chunk_len: usize) -> Vec { + let sigma = chunk_len.trailing_zeros() as usize; + MultilinearPoly::fold_rows(&self.dense, left, sigma) + } + } + + let chunks = vec![ + vec![ + Some(OneHotIndex::new(1, 2).expect("valid index")), + None, + Some(OneHotIndex::new(3, 2).expect("valid index")), + None, + ], + vec![ + Some(OneHotIndex::new(0, 2).expect("valid index")), + Some(OneHotIndex::new(2, 2).expect("valid index")), + None, + Some(OneHotIndex::new(1, 2).expect("valid index")), + ], + ]; + let mut dense = vec![Fr::from_u64(0); 32]; + dense[4] = Fr::from_u64(1); + dense[8] = Fr::from_u64(1); + dense[15] = Fr::from_u64(1); + dense[21] = Fr::from_u64(1); + dense[26] = Fr::from_u64(1); + + let source = TestSource { + chunks, + dense: Polynomial::new(dense.clone()), + }; + + let (commitment, ()) = MockPCS::commit(&source, &()); + assert_eq!(commitment.evaluations, dense); + } + fn prove_and_verify( prover_polys: &[(Polynomial, Vec)], verifier_evals: Option<&[Fr]>, @@ -323,48 +602,19 @@ mod tests { let (commitment, ()) = MockPCS::commit(poly.evaluations(), &()); let v_eval = verifier_evals.map_or(eval, |overrides| overrides[i]); - verifier_claims.push(VerifierClaim { + verifier_claims.push(OpeningClaim:: { commitment, point: point.clone(), eval: v_eval, }); } - // Prover: reduce + open let mut transcript_p = Blake2bTranscript::new(b"e2e-test"); - let reduced_prover = reduce_prover(prover_claims, &mut transcript_p); - let proofs: Vec<_> = reduced_prover - .iter() - .map(|claim| { - MockPCS::open( - &claim.polynomial, - &claim.point, - claim.eval, - &(), - None, - &mut transcript_p, - ) - }) - .collect(); + let hints = vec![(); prover_claims.len()]; + let proof = MockPCS::prove_batch(prover_claims, hints, &(), &mut transcript_p); - // Verifier: reduce + verify let mut transcript_v = Blake2bTranscript::new(b"e2e-test"); - let reduced_verifier = reduce_verifier::(verifier_claims, &mut transcript_v)?; - - assert_eq!(reduced_verifier.len(), proofs.len()); - - for (claim, proof) in reduced_verifier.iter().zip(proofs.iter()) { - MockPCS::verify( - &claim.commitment, - &claim.point, - claim.eval, - proof, - &(), - &mut transcript_v, - )?; - } - - Ok(()) + MockPCS::verify_batch(verifier_claims, &proof, &(), &mut transcript_v) } #[test] @@ -455,8 +705,131 @@ mod tests { ]; let mut transcript = Blake2bTranscript::new(b"grouping"); - let reduced = reduce_prover(claims, &mut transcript); - assert_eq!(reduced.len(), 2, "two distinct points → two reduced claims"); + let hints = vec![(); claims.len()]; + let proofs = MockPCS::prove_batch(claims, hints, &(), &mut transcript); + assert_eq!(proofs.len(), 2, "two distinct points → two batch proofs"); + } + + #[test] + fn source_backed_opening_returns_public_relations() { + struct TestOpeningBatch { + polynomials: Vec>, + hints: Vec<()>, + } + + impl BatchOpeningSource for TestOpeningBatch { + type Id = usize; + type Source<'a> + = &'a Polynomial + where + Self: 'a; + + fn source(&self, id: Self::Id) -> Self::Source<'_> { + &self.polynomials[id] + } + + fn opening_hint(&self, id: Self::Id) -> &() { + &self.hints[id] + } + } + + impl LinearCombinationOpeningSource for TestOpeningBatch { + type LinearCombination<'a> + = MaterializedLinearCombination + where + Self: 'a; + + fn linear_combination<'a>( + &'a mut self, + terms: &[crate::LinearSourceTerm], + ) -> Self::LinearCombination<'a> { + MaterializedLinearCombination::new(self, terms) + } + } + + let mut rng = ChaCha20Rng::seed_from_u64(450); + let p1 = Polynomial::::random(3, &mut rng); + let p2 = Polynomial::::random(3, &mut rng); + let point: Vec = (0..3).map(|_| Fr::random(&mut rng)).collect(); + let eval1 = p1.evaluate(&point); + let eval2 = p2.evaluate(&point); + let scale1 = Fr::from_u64(5); + let scale2 = Fr::from_u64(7); + + let mut batch = TestOpeningBatch { + polynomials: vec![p1.clone(), p2.clone()], + hints: vec![(), ()], + }; + let (c1, ()) = MockPCS::commit(&p1, &()); + let (c2, ()) = MockPCS::commit(&p2, &()); + + let prover_terms = vec![ + ProverBatchOpeningTerm { + claim_id: 10u8, + source_id: 0usize, + point: BatchOpeningPoint::same(point.clone()), + eval: eval1, + eval_scale: scale1, + }, + ProverBatchOpeningTerm { + claim_id: 11u8, + source_id: 1usize, + point: BatchOpeningPoint::same(point.clone()), + eval: eval2, + eval_scale: scale2, + }, + ]; + + let verifier_terms = vec![ + VerifierBatchOpeningTerm:: { + claim_id: 10u8, + source_id: 0usize, + commitment: c1, + point: BatchOpeningPoint::same(point.clone()), + eval: eval1, + eval_scale: scale1, + }, + VerifierBatchOpeningTerm:: { + claim_id: 11u8, + source_id: 1usize, + commitment: c2, + point: BatchOpeningPoint::same(point), + eval: eval2, + eval_scale: scale2, + }, + ]; + + let mut prover_transcript = Blake2bTranscript::new(b"source-backed"); + let prover_result = + MockPCS::prove_batch_opening(prover_terms, &mut batch, &(), &mut prover_transcript); + + let mut verifier_transcript = Blake2bTranscript::new(b"source-backed"); + let verifier_public = MockPCS::verify_batch_opening( + verifier_terms, + &prover_result.proof, + &(), + &mut verifier_transcript, + ) + .expect("source-backed mock proof should verify"); + + assert_eq!(prover_result.public, verifier_public); + assert_eq!(verifier_public.outputs.len(), 2); + assert_eq!( + verifier_public.outputs[0].value.as_public(), + Some(&(eval1 * scale1)), + ); + assert_eq!( + verifier_public.outputs[1].value.as_public(), + Some(&(eval2 * scale2)), + ); + assert!(matches!( + &verifier_public.relations[0].expression, + BatchOutputExpression::Linear(terms) if terms == &vec![(10u8, scale1)] + )); + assert!(matches!( + &verifier_public.relations[1].expression, + BatchOutputExpression::Linear(terms) if terms == &vec![(11u8, scale2)] + )); } #[test] diff --git a/crates/jolt-openings/src/reduction.rs b/crates/jolt-openings/src/reduction.rs deleted file mode 100644 index 9cd5402312..0000000000 --- a/crates/jolt-openings/src/reduction.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! Opening claim reduction via random linear combination (RLC). - -use jolt_field::Field; -use jolt_transcript::{AppendToTranscript, LabelWithCount, Transcript}; - -use crate::claims::{ProverClaim, VerifierClaim}; -use crate::error::OpeningsError; -use crate::schemes::AdditivelyHomomorphic; -use jolt_crypto::HomomorphicCommitment; - -/// Groups claims by point, draws ρ per group, combines: p = Σ ρ^i · p_i. -#[tracing::instrument(skip_all, name = "reduce_prover")] -pub fn reduce_prover>( - claims: Vec>, - transcript: &mut T, -) -> Vec> { - if claims.is_empty() { - return Vec::new(); - } - - transcript.append(&LabelWithCount(b"rlc_claims", claims.len() as u64)); - for claim in &claims { - claim.eval.append_to_transcript(transcript); - } - - let groups = group_prover_claims_by_point(claims); - let mut reduced = Vec::with_capacity(groups.len()); - - for (point, group_claims) in groups { - let rho: F = transcript.challenge(); - - let eval_slices: Vec<&[F]> = group_claims - .iter() - .map(|c| c.polynomial.evaluations()) - .collect(); - let evals: Vec = group_claims.iter().map(|c| c.eval).collect(); - - let combined_evals = rlc_combine(&eval_slices, rho); - let combined_eval = rlc_combine_scalars(&evals, rho); - - reduced.push(ProverClaim { - polynomial: combined_evals.into(), - point, - eval: combined_eval, - }); - } - - reduced -} - -/// Groups claims by point, draws ρ per group, combines: C = Σ ρ^i · C_i. -#[expect( - clippy::type_complexity, - reason = "PCS associated types drive return shape" -)] -#[tracing::instrument(skip_all, name = "reduce_verifier")] -pub fn reduce_verifier( - claims: Vec>, - transcript: &mut T, -) -> Result>, OpeningsError> -where - PCS: AdditivelyHomomorphic, - PCS::Output: HomomorphicCommitment, - T: Transcript, -{ - if claims.is_empty() { - return Ok(Vec::new()); - } - - transcript.append(&LabelWithCount(b"rlc_claims", claims.len() as u64)); - for claim in &claims { - claim.eval.append_to_transcript(transcript); - } - - let groups = group_verifier_claims_by_point(claims); - let mut reduced = Vec::with_capacity(groups.len()); - - for (point, group_claims) in groups { - let rho: PCS::Field = transcript.challenge(); - - let commitments: Vec = - group_claims.iter().map(|c| c.commitment.clone()).collect(); - let evals: Vec = group_claims.iter().map(|c| c.eval).collect(); - - let powers = rho_powers(rho, commitments.len()); - let combined_commitment = PCS::combine(&commitments, &powers); - let combined_eval = rlc_combine_scalars(&evals, rho); - - reduced.push(VerifierClaim { - commitment: combined_commitment, - point, - eval: combined_eval, - }); - } - - Ok(reduced) -} - -/// result[i] = p_1[i] + ρ · p_2[i] + ρ² · p_3[i] + ... (Horner evaluation). -#[expect( - clippy::expect_used, - reason = "empty polynomials is an API contract violation" -)] -#[tracing::instrument(skip_all, name = "rlc_combine")] -pub fn rlc_combine(polynomials: &[&[F]], rho: F) -> Vec { - let (last, rest) = polynomials - .split_last() - .expect("rlc_combine requires at least one polynomial"); - let len = last.len(); - - let mut result = last.to_vec(); - for p in rest.iter().rev() { - assert_eq!(p.len(), len); - for (r, &val) in result.iter_mut().zip(p.iter()) { - *r = *r * rho + val; - } - } - result -} - -/// v_1 + ρ · v_2 + ρ² · v_3 + ... (Horner evaluation). -pub fn rlc_combine_scalars(evals: &[F], rho: F) -> F { - assert!(!evals.is_empty(), "need at least one evaluation"); - let mut result = F::zero(); - for &v in evals.iter().rev() { - result = result * rho + v; - } - result -} - -fn rho_powers(rho: F, n: usize) -> Vec { - std::iter::successors(Some(F::from_u64(1)), |prev| Some(*prev * rho)) - .take(n) - .collect() -} - -type PointGroup = Vec<(Vec, Vec>)>; -type VerifierPointGroup = Vec<(Vec, Vec>)>; - -fn group_prover_claims_by_point(claims: Vec>) -> PointGroup { - let mut groups: PointGroup = Vec::new(); - for claim in claims { - if let Some((_, group)) = groups.iter_mut().find(|(point, _)| *point == claim.point) { - group.push(claim); - } else { - let point = claim.point.clone(); - groups.push((point, vec![claim])); - } - } - groups -} - -fn group_verifier_claims_by_point( - claims: Vec>, -) -> VerifierPointGroup { - let mut groups: VerifierPointGroup = Vec::new(); - for claim in claims { - if let Some((_, group)) = groups.iter_mut().find(|(point, _)| *point == claim.point) { - group.push(claim); - } else { - let point = claim.point.clone(); - groups.push((point, vec![claim])); - } - } - groups -} - -#[cfg(test)] -mod tests { - use super::*; - use jolt_field::{Fr, FromPrimitiveInt, RandomSampling}; - use jolt_poly::Polynomial; - - #[test] - fn rlc_combine_single_polynomial_is_identity() { - let evals: Vec = (0..4).map(|i| Fr::from_u64(i + 1)).collect(); - let rho = Fr::from_u64(7); - let result = rlc_combine(&[&evals], rho); - assert_eq!(result, evals); - } - - #[test] - fn rlc_combine_two_polynomials() { - let p1: Vec = (1..=4).map(Fr::from_u64).collect(); - let p2: Vec = (5..=8).map(Fr::from_u64).collect(); - let rho = Fr::from_u64(3); - let result = rlc_combine(&[&p1, &p2], rho); - for i in 0..4 { - assert_eq!(result[i], p1[i] + rho * p2[i], "mismatch at index {i}"); - } - } - - #[test] - fn rlc_combine_three_polynomials_horner() { - let p1 = [Fr::from_u64(1)]; - let p2 = [Fr::from_u64(2)]; - let p3 = [Fr::from_u64(3)]; - let rho = Fr::from_u64(5); - let result = rlc_combine(&[&p1[..], &p2[..], &p3[..]], rho); - assert_eq!(result[0], Fr::from_u64(86)); - } - - #[test] - fn rlc_combine_scalars_matches_manual() { - let evals: Vec = vec![Fr::from_u64(10), Fr::from_u64(20), Fr::from_u64(30)]; - let rho = Fr::from_u64(2); - assert_eq!(rlc_combine_scalars(&evals, rho), Fr::from_u64(170)); - } - - #[test] - fn rlc_combine_scalars_single() { - assert_eq!( - rlc_combine_scalars(&[Fr::from_u64(42)], Fr::from_u64(999)), - Fr::from_u64(42), - ); - } - - #[test] - fn rlc_combine_scalars_consistent_with_rlc_combine() { - use rand_chacha::rand_core::SeedableRng; - use rand_chacha::ChaCha20Rng; - - let mut rng = ChaCha20Rng::seed_from_u64(555); - let num_vars = 3; - let rho = Fr::from_u64(7); - - let p1 = Polynomial::::random(num_vars, &mut rng); - let p2 = Polynomial::::random(num_vars, &mut rng); - let p3 = Polynomial::::random(num_vars, &mut rng); - - let point: Vec = (0..num_vars).map(|_| Fr::random(&mut rng)).collect(); - - let eval1 = p1.evaluate(&point); - let eval2 = p2.evaluate(&point); - let eval3 = p3.evaluate(&point); - - let combined = rlc_combine(&[p1.evaluations(), p2.evaluations(), p3.evaluations()], rho); - let combined_poly = Polynomial::new(combined); - let result_via_poly = combined_poly.evaluate(&point); - let result_via_scalars = rlc_combine_scalars(&[eval1, eval2, eval3], rho); - - assert_eq!(result_via_poly, result_via_scalars); - } - - #[test] - fn group_prover_claims_same_point() { - let point = vec![Fr::from_u64(1), Fr::from_u64(2)]; - let claims = vec![ - ProverClaim { - polynomial: Polynomial::new(vec![Fr::from_u64(10)]), - point: point.clone(), - eval: Fr::from_u64(10), - }, - ProverClaim { - polynomial: Polynomial::new(vec![Fr::from_u64(20)]), - point: point.clone(), - eval: Fr::from_u64(20), - }, - ]; - let groups = group_prover_claims_by_point(claims); - assert_eq!(groups.len(), 1); - assert_eq!(groups[0].1.len(), 2); - } - - #[test] - fn group_prover_claims_different_points() { - let claims = vec![ - ProverClaim { - polynomial: Polynomial::new(vec![Fr::from_u64(10)]), - point: vec![Fr::from_u64(1)], - eval: Fr::from_u64(10), - }, - ProverClaim { - polynomial: Polynomial::new(vec![Fr::from_u64(20)]), - point: vec![Fr::from_u64(2)], - eval: Fr::from_u64(20), - }, - ]; - let groups = group_prover_claims_by_point(claims); - assert_eq!(groups.len(), 2); - } -} diff --git a/crates/jolt-openings/src/schemes.rs b/crates/jolt-openings/src/schemes.rs index 24cb699e0b..8af62a726f 100644 --- a/crates/jolt-openings/src/schemes.rs +++ b/crates/jolt-openings/src/schemes.rs @@ -1,100 +1,233 @@ //! Polynomial commitment scheme (PCS) trait hierarchy. //! -//! - [`CommitmentScheme`] — commit, open, verify for multilinear polynomials. -//! - [`AdditivelyHomomorphic`] — linear combination of commitments. -//! - [`StreamingCommitment`] — chunked commitment without full materialization. -//! - [`ZkOpeningScheme`] — zero-knowledge commitments and opening proofs. +//! The base verifier/prover traits expose single openings, ordinary batch +//! openings, and source-based commitment. Extension traits add homomorphic and +//! ZK operations only for schemes and protocols that need them. use std::fmt::Debug; use jolt_crypto::{Commitment, HomomorphicCommitment}; use jolt_field::Field; -use jolt_poly::MultilinearPoly; use jolt_transcript::{AppendToTranscript, Transcript}; use serde::{de::DeserializeOwned, Serialize}; +use crate::claims::{ + BatchOpeningProverResult, BatchOpeningPublic, BatchOutputExpression, BatchOutputRelation, + BatchOutputValue, OpenedBatchOutput, OpeningClaim, ProverBatchOpeningTerm, ProverClaim, + VerifierBatchOpeningTerm, ZkBatchOpeningProverResult, +}; use crate::error::OpeningsError; +use crate::sources::{ + BatchCommitmentSource, CommitmentSource, LinearCombinationOpeningSource, SourceId, +}; -/// Commit to f: F^n -> F, then prove f(r) = v for verifier-chosen r. -pub trait CommitmentScheme: Commitment + Clone + Send + Sync + 'static { +/// Verifier-side interface for a polynomial commitment scheme. +pub trait CommitmentSchemeVerifier: Commitment + Clone + Send + Sync + 'static { type Field: Field; type Proof: Clone + Send + Sync + Serialize + DeserializeOwned; - type ProverSetup: Clone + Send + Sync; + type BatchProof: Clone + Send + Sync + Serialize + DeserializeOwned; type VerifierSetup: Clone + Send + Sync + Serialize + DeserializeOwned; - type Polynomial: MultilinearPoly + From>; + /// Verifies one opening proof. + fn verify( + commitment: &Self::Output, + point: &[Self::Field], + eval: Self::Field, + proof: &Self::Proof, + setup: &Self::VerifierSetup, + transcript: &mut impl Transcript, + ) -> Result<(), OpeningsError>; + + /// Verifies an ordinary batch-opening proof. + fn verify_batch( + claims: Vec>, + proof: &Self::BatchProof, + setup: &Self::VerifierSetup, + transcript: &mut impl Transcript, + ) -> Result<(), OpeningsError>; - /// Auxiliary data from commit reused during opening (e.g. Dory row commitments). - type OpeningHint: Clone + Send + Sync + Default; + /// Binds one transparent opening input to the Fiat-Shamir transcript. + fn bind_opening_inputs( + transcript: &mut impl Transcript, + point: &[Self::Field], + eval: &Self::Field, + ); +} + +/// Verifier setup derivable from public parameters without prover setup. +/// +/// Transparent schemes such as Dory can build verifier setup from a size +/// parameter alone. Structured-reference-string schemes such as KZG generally +/// cannot: their verifier setup contains trapdoor-derived elements generated +/// during setup, so verifier-only code should receive the verifier setup as an +/// input rather than pretending it can derive it from public generators. +/// +/// This is separate from [`CommitmentSchemeVerifier`] so verifier code can stay +/// generic over schemes that require a supplied verifier key. +pub trait VerifierSetupFromPublicParams: CommitmentSchemeVerifier { + type PublicParams; + /// Builds verifier setup directly from public parameters. + fn verifier_setup_from_public_params(params: Self::PublicParams) -> Self::VerifierSetup; +} + +/// Prover-side interface for a polynomial commitment scheme. +pub trait CommitmentScheme: CommitmentSchemeVerifier { + type ProverSetup: Clone + Send + Sync; + type OpeningHint: Clone + Send + Sync + Default; type SetupParams; + /// Builds prover and verifier setup. fn setup(params: Self::SetupParams) -> (Self::ProverSetup, Self::VerifierSetup); - fn verifier_setup(prover_setup: &Self::ProverSetup) -> Self::VerifierSetup; + /// Derives verifier setup from prover setup. + fn prover_to_verifier_setup(prover_setup: &Self::ProverSetup) -> Self::VerifierSetup; - fn commit + ?Sized>( - poly: &P, + /// Commits to one source. + fn commit + ?Sized>( + source: &S, setup: &Self::ProverSetup, ) -> (Self::Output, Self::OpeningHint); - fn open( - poly: &Self::Polynomial, + /// Commits to a batch of sources. + fn commit_batch>( + batch: &B, + ids: &[B::Id], + setup: &Self::ProverSetup, + ) -> Vec<(Self::Output, Self::OpeningHint)> { + ids.iter() + .map(|&id| { + let source = batch.source(id); + Self::commit(&source, setup) + }) + .collect() + } + + /// Proves one opening. + fn open( + polynomial: &S, point: &[Self::Field], eval: Self::Field, setup: &Self::ProverSetup, hint: Option, transcript: &mut impl Transcript, - ) -> Self::Proof; + ) -> Self::Proof + where + S: CommitmentSource + ?Sized; - fn verify( - commitment: &Self::Output, - point: &[Self::Field], - eval: Self::Field, - proof: &Self::Proof, + /// Proves an ordinary batch opening. + fn prove_batch( + claims: Vec>, + hints: Vec, + setup: &Self::ProverSetup, + transcript: &mut impl Transcript, + ) -> Self::BatchProof + where + S: CommitmentSource; +} + +/// Verifier-side interface for linear source-backed batch openings. +pub trait LinearOpeningSchemeVerifier: CommitmentSchemeVerifier { + /// Verifies a linear source-backed batch opening and returns the PCS output + /// relation created by that verification. + /// + /// The default implementation verifies the raw terms through ordinary + /// [`CommitmentSchemeVerifier::verify_batch`] and exposes one public output + /// per raw term. Schemes with native linear-source fusion should override + /// this method so the PCS owns its batching challenge and output relation. + fn verify_batch_opening( + terms: Vec>, + proof: &Self::BatchProof, setup: &Self::VerifierSetup, transcript: &mut impl Transcript, - ) -> Result<(), OpeningsError>; + ) -> Result, OpeningsError> + where + Self: Sized, + SourceIdT: SourceId, + { + let claims = terms + .iter() + .map(|term| OpeningClaim { + commitment: term.commitment.clone(), + point: term.point.proof.clone(), + eval: term.eval, + }) + .collect(); + Self::verify_batch(claims, proof, setup, transcript)?; - fn bind_opening_inputs( + let public = transparent_public_from_terms( + terms + .into_iter() + .map(|term| (term.claim_id, term.point.public, term.eval, term.eval_scale)), + ); + bind_transparent_batch_outputs::(&public, transcript); + Ok(public) + } +} + +/// Prover-side interface for linear source-backed batch openings. +pub trait LinearOpeningScheme: CommitmentScheme + LinearOpeningSchemeVerifier { + /// Proves a linear source-backed batch opening and returns the PCS output + /// relation. + /// + /// The default implementation routes each raw term through ordinary + /// [`CommitmentScheme::prove_batch`]. Production backends that can preserve + /// streaming fusion should override this method. + fn prove_batch_opening( + terms: Vec>, + source_batch: &mut B, + setup: &Self::ProverSetup, transcript: &mut impl Transcript, - point: &[Self::Field], - eval: &Self::Field, - ); + ) -> BatchOpeningProverResult + where + Self: Sized, + B: LinearCombinationOpeningSource, + { + let claims = terms + .iter() + .map(|term| ProverClaim { + polynomial: source_batch.source(term.source_id), + point: term.point.proof.clone(), + eval: term.eval, + }) + .collect(); + let hints = terms + .iter() + .map(|term| source_batch.opening_hint(term.source_id).clone()) + .collect(); + let proof = Self::prove_batch(claims, hints, setup, transcript); + + let public = transparent_public_from_terms( + terms + .into_iter() + .map(|term| (term.claim_id, term.point.public, term.eval, term.eval_scale)), + ); + bind_transparent_batch_outputs::(&public, transcript); + BatchOpeningProverResult { proof, public } + } } -/// C = Σ s_i · C_i. -pub trait AdditivelyHomomorphic: CommitmentScheme +/// Verifier-side additive combination of commitments. +pub trait AdditivelyHomomorphicVerifier: CommitmentSchemeVerifier where Self::Output: HomomorphicCommitment, { + /// Computes `Σ scalars[i] * commitments[i]`. fn combine(commitments: &[Self::Output], scalars: &[Self::Field]) -> Self::Output; - - fn combine_hints( - _hints: Vec, - _scalars: &[Self::Field], - ) -> Self::OpeningHint { - Self::OpeningHint::default() - } } -/// Incremental commitment without full materialization. -pub trait StreamingCommitment: CommitmentScheme { - type PartialCommitment: Clone + Send + Sync; - - fn begin(setup: &Self::ProverSetup) -> Self::PartialCommitment; - - fn feed( - partial: &mut Self::PartialCommitment, - chunk: &[Self::Field], - setup: &Self::ProverSetup, - ); - - fn finish(partial: Self::PartialCommitment, setup: &Self::ProverSetup) -> Self::Output; +/// Prover-side additive combination of commitment hints. +pub trait AdditivelyHomomorphic: CommitmentScheme + AdditivelyHomomorphicVerifier +where + Self::Output: HomomorphicCommitment, +{ + /// Computes the hint corresponding to the same linear combination as + /// [`AdditivelyHomomorphicVerifier::combine`]. + fn combine_hints(hints: Vec, scalars: &[Self::Field]) -> Self::OpeningHint; } -/// Opening proofs that hide the evaluation behind a commitment. -pub trait ZkOpeningScheme: CommitmentScheme { +/// Verifier-side interface for openings that hide evaluations. +pub trait ZkOpeningSchemeVerifier: CommitmentSchemeVerifier { type HidingCommitment: Clone + Debug + Eq @@ -105,30 +238,174 @@ pub trait ZkOpeningScheme: CommitmentScheme { + DeserializeOwned + AppendToTranscript; + fn verify_zk( + commitment: &Self::Output, + point: &[Self::Field], + proof: &Self::Proof, + setup: &Self::VerifierSetup, + transcript: &mut impl Transcript, + ) -> Result<(), OpeningsError>; + + /// Verifies a ZK batch-opening proof. + fn verify_batch_zk( + claims: Vec>, + proof: &Self::BatchProof, + setup: &Self::VerifierSetup, + transcript: &mut impl Transcript, + ) -> Result<(), OpeningsError>; + + /// Binds one ZK opening input to the Fiat-Shamir transcript. + /// + /// The evaluation is hidden, so the transcript receives the opening point + /// and the hiding commitment to the evaluation instead of the scalar value. + fn bind_zk_opening_inputs( + transcript: &mut impl Transcript, + point: &[Self::Field], + hiding_commitment: &Self::HidingCommitment, + ); +} + +/// Prover-side interface for openings that hide evaluations. +pub trait ZkOpeningScheme: CommitmentScheme + ZkOpeningSchemeVerifier { type Blind: Clone + Send + Sync; - /// Commit in the scheme's ZK/hiding mode. - fn commit_zk + ?Sized>( - poly: &P, + /// Commits in the scheme's ZK/hiding mode. + fn commit_zk + ?Sized>( + source: &S, setup: &Self::ProverSetup, ) -> (Self::Output, Self::OpeningHint); - /// Open a ZK/hiding commitment using the opening hint returned by + /// Commits to a batch of sources in the scheme's ZK/hiding mode. + fn commit_batch_zk>( + batch: &B, + ids: &[B::Id], + setup: &Self::ProverSetup, + ) -> Vec<(Self::Output, Self::OpeningHint)> { + ids.iter() + .map(|&id| { + let source = batch.source(id); + Self::commit_zk(&source, setup) + }) + .collect() + } + + /// Opens a ZK/hiding commitment using the hint returned by /// [`commit_zk`](Self::commit_zk). - fn open_zk( - poly: &Self::Polynomial, + fn open_zk( + polynomial: &S, point: &[Self::Field], eval: Self::Field, setup: &Self::ProverSetup, hint: Self::OpeningHint, transcript: &mut impl Transcript, - ) -> (Self::Proof, Self::HidingCommitment, Self::Blind); + ) -> (Self::Proof, Self::HidingCommitment, Self::Blind) + where + S: CommitmentSource + ?Sized; - fn verify_zk( - commitment: &Self::Output, - point: &[Self::Field], - proof: &Self::Proof, + /// Proves a ZK batch opening. + fn prove_batch_zk( + claims: Vec>, + hints: Vec, + setup: &Self::ProverSetup, + transcript: &mut impl Transcript, + ) -> (Self::BatchProof, Self::HidingCommitment, Self::Blind) + where + S: CommitmentSource; +} + +/// Verifier-side interface for ZK linear source-backed batch openings. +pub trait ZkLinearOpeningSchemeVerifier: ZkOpeningSchemeVerifier { + /// Verifies a ZK linear source-backed batch opening and returns the public + /// output relation produced by the PCS. + fn verify_batch_opening_zk( + terms: Vec>, + proof: &Self::BatchProof, setup: &Self::VerifierSetup, transcript: &mut impl Transcript, - ) -> Result<(), OpeningsError>; + ) -> Result, OpeningsError> + where + Self: Sized, + SourceIdT: SourceId; +} + +/// Prover-side interface for ZK linear source-backed batch openings. +pub trait ZkLinearOpeningScheme: ZkOpeningScheme + ZkLinearOpeningSchemeVerifier { + /// Proves a ZK linear source-backed batch opening and returns both public + /// output metadata and prover-only hidden-output witnesses. + fn prove_batch_opening_zk( + terms: Vec>, + source_batch: &mut B, + setup: &Self::ProverSetup, + transcript: &mut impl Transcript, + ) -> ZkBatchOpeningProverResult + where + Self: Sized, + B: LinearCombinationOpeningSource; +} + +fn transparent_public_from_terms(terms: I) -> BatchOpeningPublic +where + F: Field, + I: IntoIterator, F, F)>, +{ + let mut outputs = Vec::new(); + let mut relations = Vec::new(); + + for (claim_id, point, eval, eval_scale) in terms { + let output_index = outputs.len(); + outputs.push(OpenedBatchOutput { + point, + value: BatchOutputValue::Public(eval * eval_scale), + }); + relations.push(BatchOutputRelation { + output_index, + expression: BatchOutputExpression::Linear(vec![(claim_id, eval_scale)]), + }); + } + + BatchOpeningPublic { outputs, relations } +} + +fn bind_transparent_batch_outputs( + public: &BatchOpeningPublic, + transcript: &mut impl Transcript, +) where + PCS: CommitmentSchemeVerifier, +{ + for output in &public.outputs { + if let BatchOutputValue::Public(eval) = &output.value { + PCS::bind_opening_inputs(transcript, &output.point, eval); + } + } +} + +/// Verifier-side hooks for schemes whose ZK openings bind a hidden evaluation. +/// +/// Jolt's BlindFold integration needs to absorb the commitment to the hidden +/// evaluation and use the same commitment generators inside its verifier R1CS. +/// Schemes without this Dory-style evaluation commitment should not implement +/// this extension trait. +pub trait EvaluationCommitmentScheme: ZkOpeningSchemeVerifier +where + G: Clone + Send + Sync + 'static, +{ + /// Extracts the hidden evaluation commitment from a batch proof. + fn batch_eval_commitment(proof: &Self::BatchProof) -> Option; + + /// Returns the verifier-side generators used by the hidden evaluation + /// commitment relation. + fn eval_commitment_gens_verifier(setup: &Self::VerifierSetup) -> Option<(G, G)>; +} + +/// Prover-side hooks for schemes whose ZK openings bind a hidden evaluation. +pub trait EvaluationCommitmentProver: EvaluationCommitmentScheme + ZkOpeningScheme +where + G: Clone + Send + Sync + 'static, +{ + /// Returns the prover-side generators used by the hidden evaluation + /// commitment relation. + fn eval_commitment_gens(setup: &Self::ProverSetup) -> Option<(G, G)>; + + /// Returns Pedersen generators derived from the PCS setup for BlindFold. + fn zk_generators(setup: &Self::ProverSetup, count: usize) -> Option<(Vec, G)>; } diff --git a/crates/jolt-openings/src/sources.rs b/crates/jolt-openings/src/sources.rs new file mode 100644 index 0000000000..73c44fb771 --- /dev/null +++ b/crates/jolt-openings/src/sources.rs @@ -0,0 +1,700 @@ +//! Source abstractions for commitment backends. +//! +//! A source describes committed data and the traversal shapes a backend may +//! exploit. It does not prescribe the backend's commitment algorithm or +//! parallel schedule. + +use std::iter::repeat_n; + +use jolt_field::Field; +use jolt_poly::{MultilinearPoly, OneHotPolynomial, Polynomial, RlcSource}; + +use crate::claims::LinearSourceTerm; + +/// Stable identifier for a committed source inside a batch commitment source. +/// +/// In the Dory/Jolt trace path this can be a logical committed polynomial id. +/// In a packed PCS path this can instead identify a packed witness group. The +/// id names what the PCS commits to; it does not have to be one logical Jolt +/// polynomial. +pub trait SourceId: Copy + Eq + Ord + Send + Sync + 'static {} + +impl SourceId for T where T: Copy + Eq + Ord + Send + Sync + 'static {} + +/// A compact coordinate into a one-hot domain. +/// +/// The value is the hot basis-vector index `k` in `e_k`. The surrounding +/// [`OneHotRow`] carries the domain size, so this type only stores the +/// coordinate. Current Jolt one-hot chunks have at most `2^8` entries. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(transparent)] +pub struct OneHotIndex(u8); + +impl OneHotIndex { + /// Creates a one-hot coordinate when `index < 2^log_domain_size`. + pub fn new(index: u8, log_domain_size: u8) -> Option { + (log_domain_size <= 8 && (index as usize) < (1usize << log_domain_size)) + .then_some(Self(index)) + } + + /// Returns the coordinate as an array/vector index. + pub fn get(self) -> usize { + self.0 as usize + } +} + +/// A row of one-hot entries, one entry per trace column in the current chunk. +/// +/// `log_domain_size` says that every hot coordinate lives in a one-hot domain +/// of size `2^log_domain_size`. The entries record whether each trace column +/// has a required hot coordinate or may be zero. Dory consumes this as the +/// current streaming one-hot chunk shape: it builds one row commitment per hot +/// coordinate, with columns contributing to the row for their hot coordinate. +pub struct OneHotRow<'a> { + pub log_domain_size: u8, + pub entries: OneHotEntries<'a>, +} + +/// Per-column one-hot data for a [`OneHotRow`]. +/// +/// This enum avoids forcing all one-hot rows through `Option`. Rows such as +/// instruction and bytecode RA have one hot coordinate for every trace column. +/// Rows such as RAM RA can have no committed address for a column after address +/// remapping, so they need the zero-or-one representation. +pub enum OneHotEntries<'a> { + /// Every trace column contributes exactly one one-hot basis vector. + /// + /// Entry `indices[col] = k` means column `col` contributes `e_k`. + OnePerColumn(&'a [OneHotIndex]), + + /// Each trace column contributes either zero or one one-hot basis vector. + /// + /// Entry `indices[col] = Some(k)` means column `col` contributes `e_k`. + /// Entry `indices[col] = None` means column `col` contributes zero. + MaybeZero(&'a [Option]), +} + +impl OneHotEntries<'_> { + /// Number of trace columns represented by this row. + pub fn len(&self) -> usize { + match self { + Self::OnePerColumn(indices) => indices.len(), + Self::MaybeZero(indices) => indices.len(), + } + } + + /// Returns `true` when this row has no trace columns. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// A borrowed row view of a polynomial source. +/// +/// This is a traversal hint, not the core polynomial abstraction. Backends that +/// can exploit row structure consume these rows directly. Backends that do not +/// care about the encoding may interpret the row as field evaluations. +pub enum SourceRow<'a, F> { + /// A row whose entries occupy evenly spaced columns. + /// + /// `column_stride` is the distance between consecutive occupied columns in + /// the backend's row view. For example, values with stride `4` occupy + /// columns `0, 4, 8, ...`; all intervening columns are zero. + /// Use stride `1` for a dense row. + StridedFieldElements { + values: &'a [F], + column_stride: usize, + }, + + /// A row of signed integers embedded canonically into the field. + /// + /// This preserves small-scalar MSM paths without first materializing field + /// elements. + /// Use stride `1` for a dense row. + StridedI128 { + values: &'a [i128], + column_stride: usize, + }, + + /// A row of unsigned 64-bit integers embedded canonically into the field. + /// + /// This preserves the common compact-polynomial benchmark and commitment + /// path without paying the cost of first converting every row entry into a + /// full-width field element. + /// Use stride `1` for a dense row. + StridedU64 { + values: &'a [u64], + column_stride: usize, + }, + + /// A streaming one-hot chunk whose entries are one-hot vectors over a small + /// domain. + /// + /// This is included for Jolt's RA commitments, where Dory can preserve the + /// existing grouped-addition path without materializing a dense `{0,1}` + /// table. Backends that do not exploit this shape can expand it explicitly + /// in the same hot-coordinate-major order. + OneHot(OneHotRow<'a>), +} + +/// A single polynomial-like object that a PCS can commit to and open. +/// +/// The source owns semantic operations: evaluate at a point, traverse rows, and +/// fold rows for opening-time vector/matrix products. It may be materialized or +/// lazy; for example, it can be backed by an execution trace. +pub trait CommitmentSource: Send + Sync { + /// Number of multilinear variables in the source. + fn num_vars(&self) -> usize; + + /// Evaluates the source at a multilinear point. + fn evaluate(&self, point: &[F]) -> F; + + /// Preferred row length for commitment traversal, when the source has one. + /// + /// This is a source traversal fact, not a PCS-specific partition. Dory + /// interprets it as its row width and derives its private matrix split + /// internally. Other backends may ignore it or use it as a tiling hint. + fn natural_chunk_len(&self) -> Option { + None + } + + /// Visits row-shaped chunks of the source using `chunk_len` columns. + /// + /// The borrowed row only has to remain valid for the duration of the visit + /// call, which lets trace-backed sources allocate temporary row buffers and + /// avoid ownership wrappers such as `Cow`. + fn for_each_row(&self, chunk_len: usize, visit: V) + where + V: for<'row> FnMut(usize, SourceRow<'row, F>); + + /// Maps row-shaped chunks of the source into owned backend results. + /// + /// This is the performance-oriented companion to + /// [`for_each_row`](Self::for_each_row). The default implementation is a + /// sequential traversal, which is sufficient for lazy sources that produce + /// temporary row buffers. Materialized sources can override this method to + /// parallelize over borrowed row chunks without first copying them into an + /// owned staging buffer. + fn map_rows(&self, chunk_len: usize, visit: V) -> Vec + where + R: Send, + V: for<'row> Fn(usize, SourceRow<'row, F>) -> R + Send + Sync, + { + let mut rows = Vec::new(); + self.for_each_row(chunk_len, |row_index, row| { + rows.push(visit(row_index, row)); + }); + rows + } + + /// Whether the whole source is a unit-valued one-hot polynomial. + /// + /// This preserves existing commitment fast paths that only need hot + /// coordinates instead of row materialization. Sources that expose richer + /// per-row one-hot structure can use [`SourceRow::OneHot`] instead. + fn is_one_hot(&self) -> bool { + false + } + + /// Visits the hot flat indices when [`is_one_hot`](Self::is_one_hot) is true. + /// + /// Backends use this for current Dory-style one-hot commitment, where each + /// hot index maps directly to one SRS basis addition. Non-one-hot sources + /// may leave the default empty traversal. + fn for_each_one(&self, _visit: V) + where + V: FnMut(usize), + { + } + + /// Folds rows against the left-side weights used by opening algorithms. + fn fold_rows(&self, left: &[F], chunk_len: usize) -> Vec; +} + +impl CommitmentSource for &S +where + F: Field, + S: CommitmentSource + ?Sized, +{ + fn num_vars(&self) -> usize { + (**self).num_vars() + } + + fn evaluate(&self, point: &[F]) -> F { + (**self).evaluate(point) + } + + fn natural_chunk_len(&self) -> Option { + (**self).natural_chunk_len() + } + + fn for_each_row(&self, chunk_len: usize, visit: V) + where + V: for<'row> FnMut(usize, SourceRow<'row, F>), + { + (**self).for_each_row(chunk_len, visit); + } + + fn map_rows(&self, chunk_len: usize, visit: V) -> Vec + where + R: Send, + V: for<'row> Fn(usize, SourceRow<'row, F>) -> R + Send + Sync, + { + (**self).map_rows(chunk_len, visit) + } + + fn is_one_hot(&self) -> bool { + (**self).is_one_hot() + } + + fn for_each_one(&self, visit: V) + where + V: FnMut(usize), + { + (**self).for_each_one(visit); + } + + fn fold_rows(&self, left: &[F], chunk_len: usize) -> Vec { + (**self).fold_rows(left, chunk_len) + } +} + +fn chunk_len_to_sigma(chunk_len: usize) -> usize { + assert!( + chunk_len.is_power_of_two(), + "commitment source chunk length ({chunk_len}) must be a power of two", + ); + chunk_len.trailing_zeros() as usize +} + +fn multilinear_num_vars(source: &T) -> usize +where + F: Field, + T: MultilinearPoly + ?Sized, +{ + MultilinearPoly::num_vars(source) +} + +fn multilinear_evaluate(source: &T, point: &[F]) -> F +where + F: Field, + T: MultilinearPoly + ?Sized, +{ + MultilinearPoly::evaluate(source, point) +} + +fn multilinear_for_each_row(source: &T, chunk_len: usize, mut visit: V) +where + F: Field, + T: MultilinearPoly + ?Sized, + V: for<'row> FnMut(usize, SourceRow<'row, F>), +{ + let sigma = chunk_len_to_sigma(chunk_len); + MultilinearPoly::for_each_row(source, sigma, &mut |row_index, row| { + visit( + row_index, + SourceRow::StridedFieldElements { + values: row, + column_stride: 1, + }, + ); + }); +} + +fn multilinear_is_one_hot(source: &T) -> bool +where + F: Field, + T: MultilinearPoly + ?Sized, +{ + MultilinearPoly::is_one_hot(source) +} + +fn multilinear_for_each_one(source: &T, mut visit: V) +where + F: Field, + T: MultilinearPoly + ?Sized, + V: FnMut(usize), +{ + MultilinearPoly::for_each_one(source, &mut visit); +} + +fn multilinear_fold_rows(source: &T, left: &[F], chunk_len: usize) -> Vec +where + F: Field, + T: MultilinearPoly + ?Sized, +{ + let sigma = chunk_len_to_sigma(chunk_len); + MultilinearPoly::fold_rows(source, left, sigma) +} + +macro_rules! impl_commitment_source_for_multilinear { + ($ty:ty) => { + impl CommitmentSource for $ty { + fn num_vars(&self) -> usize { + multilinear_num_vars(self) + } + + fn evaluate(&self, point: &[F]) -> F { + multilinear_evaluate(self, point) + } + + fn for_each_row(&self, chunk_len: usize, visit: V) + where + V: for<'row> FnMut(usize, SourceRow<'row, F>), + { + multilinear_for_each_row(self, chunk_len, visit); + } + + fn is_one_hot(&self) -> bool { + multilinear_is_one_hot(self) + } + + fn for_each_one(&self, visit: V) + where + V: FnMut(usize), + { + multilinear_for_each_one(self, visit); + } + + fn fold_rows(&self, left: &[F], chunk_len: usize) -> Vec { + multilinear_fold_rows(self, left, chunk_len) + } + } + }; +} + +impl_commitment_source_for_multilinear!(Polynomial); +impl_commitment_source_for_multilinear!(Vec); +impl_commitment_source_for_multilinear!([F]); + +impl CommitmentSource for RlcSource +where + F: Field, + S: MultilinearPoly, +{ + fn num_vars(&self) -> usize { + multilinear_num_vars(self) + } + + fn evaluate(&self, point: &[F]) -> F { + multilinear_evaluate(self, point) + } + + fn for_each_row(&self, chunk_len: usize, visit: V) + where + V: for<'row> FnMut(usize, SourceRow<'row, F>), + { + multilinear_for_each_row(self, chunk_len, visit); + } + + fn is_one_hot(&self) -> bool { + multilinear_is_one_hot(self) + } + + fn for_each_one(&self, visit: V) + where + V: FnMut(usize), + { + multilinear_for_each_one(self, visit); + } + + fn fold_rows(&self, left: &[F], chunk_len: usize) -> Vec { + multilinear_fold_rows(self, left, chunk_len) + } +} + +impl CommitmentSource for OneHotPolynomial { + fn num_vars(&self) -> usize { + multilinear_num_vars::(self) + } + + fn evaluate(&self, point: &[F]) -> F { + multilinear_evaluate(self, point) + } + + fn for_each_row(&self, chunk_len: usize, visit: V) + where + V: for<'row> FnMut(usize, SourceRow<'row, F>), + { + multilinear_for_each_row(self, chunk_len, visit); + } + + fn is_one_hot(&self) -> bool { + multilinear_is_one_hot::(self) + } + + fn for_each_one(&self, visit: V) + where + V: FnMut(usize), + { + multilinear_for_each_one::(self, visit); + } + + fn fold_rows(&self, left: &[F], chunk_len: usize) -> Vec { + multilinear_fold_rows(self, left, chunk_len) + } +} + +/// Materializes a commitment source into its canonical multilinear-evaluation vector. +/// +/// This helper is the shared fallback for PCS backends that do not have a native +/// streaming path for a source. It centralizes `SourceRow` expansion so all +/// materializing backends agree on strided rows and one-hot layout. One-hot rows +/// are accumulated across chunks and flushed in hot-index-major order, matching +/// the polynomial layout used by Jolt's one-hot committed sources. +pub fn materialize_source_evaluations(source: &S) -> Vec +where + F: Field, + S: CommitmentSource + ?Sized, +{ + fn flush_one_hot( + evaluations: &mut Vec, + pending: &mut Option<(usize, Vec>>)>, + ) { + let Some((domain_size, chunks)) = pending.take() else { + return; + }; + + let trace_len = chunks.iter().map(Vec::len).sum::(); + let start = evaluations.len(); + evaluations.resize(start + trace_len * domain_size, F::zero()); + + let mut chunk_offset = 0; + for chunk in chunks { + for (column, hot_index) in chunk.iter().enumerate() { + if let Some(hot_index) = hot_index { + evaluations[start + hot_index * trace_len + chunk_offset + column] = + F::from_u64(1); + } + } + chunk_offset += chunk.len(); + } + } + + let mut evaluations = Vec::with_capacity(1usize << source.num_vars()); + let mut one_hot_chunks = None; + let chunk_len = source + .natural_chunk_len() + .unwrap_or_else(|| 1usize << source.num_vars()); + source.for_each_row(chunk_len, |_, row| match row { + SourceRow::StridedFieldElements { + values, + column_stride, + } => { + flush_one_hot(&mut evaluations, &mut one_hot_chunks); + for value in values { + evaluations.push(*value); + evaluations.extend(repeat_n(F::zero(), column_stride.saturating_sub(1))); + } + } + SourceRow::StridedI128 { + values, + column_stride, + } => { + flush_one_hot(&mut evaluations, &mut one_hot_chunks); + for value in values { + evaluations.push(F::from_i128(*value)); + evaluations.extend(repeat_n(F::zero(), column_stride.saturating_sub(1))); + } + } + SourceRow::StridedU64 { + values, + column_stride, + } => { + flush_one_hot(&mut evaluations, &mut one_hot_chunks); + for value in values { + evaluations.push(F::from_u64(*value)); + evaluations.extend(repeat_n(F::zero(), column_stride.saturating_sub(1))); + } + } + SourceRow::OneHot(row) => { + let domain_size = 1usize << row.log_domain_size; + let chunk = match row.entries { + OneHotEntries::OnePerColumn(indices) => { + indices.iter().map(|index| Some(index.get())).collect() + } + OneHotEntries::MaybeZero(indices) => indices + .iter() + .map(|index| index.map(OneHotIndex::get)) + .collect(), + }; + + match &mut one_hot_chunks { + Some((existing_domain_size, chunks)) => { + assert_eq!( + *existing_domain_size, domain_size, + "one source changed one-hot domain size during materialization", + ); + chunks.push(chunk); + } + None => { + one_hot_chunks = Some((domain_size, vec![chunk])); + } + } + } + }); + flush_one_hot(&mut evaluations, &mut one_hot_chunks); + evaluations +} + +/// A batch of committed sources that can share one traversal. +/// +/// This is the no-regression traversal hook for current CycleMajor Dory +/// commitment. The default PCS implementation can ignore it and commit sources +/// one at a time through [`source`](Self::source). +pub trait BatchCommitmentSource: Send + Sync { + type Id: SourceId; + + /// Borrowed single-source adapter for a source in this batch. + type Source<'a>: CommitmentSource + 'a + where + Self: 'a; + + /// All source ids this batch can expose, in natural protocol order. + fn source_ids(&self) -> &[Self::Id]; + + /// Number of multilinear variables in the selected source. + fn num_vars(&self, id: Self::Id) -> usize; + + /// Preferred shared row length for committing the selected sources. + fn natural_chunk_len(&self, _ids: &[Self::Id]) -> Option { + None + } + + /// Returns a single-source view for backends that do not use batch traversal. + fn source(&self, id: Self::Id) -> Self::Source<'_>; + + /// Maps a row visitor over many sources while sharing source traversal. + /// + /// The returned vector is row-major: `output[row_index][id_index]`. + fn map_rows(&self, chunk_len: usize, ids: &[Self::Id], visit: V) -> Vec> + where + R: Send, + V: for<'row> Fn(Self::Id, SourceRow<'row, F>) -> R + Send + Sync; +} + +/// A batch of already-committed sources available for opening. +/// +/// Commitment and opening have different traversal needs. Commitment wants to +/// stream rows for many sources at once. Opening needs a registry that can +/// recover individual committed sources and borrow their backend-owned opening +/// hints. Algebraic combinations of several sources are separate capabilities +/// rather than part of this generic registry. +pub trait BatchOpeningSource: Send + Sync { + type Id: SourceId; + + /// Borrowed single-source adapter for a source in this batch. + type Source<'a>: CommitmentSource + 'a + where + Self: 'a; + + /// Returns a single-source view for the selected committed source. + fn source(&self, id: Self::Id) -> Self::Source<'_>; + + /// Borrows the backend-owned opening hint produced with this source's + /// commitment. + /// + /// The borrow is deliberate: production Dory hints contain row commitments, + /// and source-backed Stage 8 opening must combine those hints without + /// cloning the row-commitment vectors in the hot path. + fn opening_hint(&self, id: Self::Id) -> &OpeningHint; +} + +/// Opening-source capability for linear combinations of committed sources. +/// +/// This is the natural capability for homomorphic/RLC-style batch openings: +/// after a PCS samples batching coefficients, the prover can expose the source +/// `Σ coefficient_i * source_i` as an ordinary [`CommitmentSource`]. Backends +/// with a different batching strategy can ignore this trait and provide their +/// own PCS-specific opening extension instead. +pub trait LinearCombinationOpeningSource: + BatchOpeningSource +{ + /// Source representing the requested linear combination. + type LinearCombination<'a>: CommitmentSource + 'a + where + Self: 'a; + + /// Builds a source for `Σ terms[i].coefficient * source(terms[i].source_id)`. + /// + /// The mutable receiver lets streaming implementations move one-shot state + /// such as advice polynomials into the combined source without interior + /// mutability or hidden caches. + fn linear_combination<'a>( + &'a mut self, + terms: &[LinearSourceTerm], + ) -> Self::LinearCombination<'a>; +} + +/// Fallback linear-combination source for callers without a streaming path. +/// +/// This helper eagerly materializes each input source, combines the evaluations, +/// and then exposes the result as a normal [`CommitmentSource`]. It is useful +/// for tests and simple backends. Hot streaming paths should usually implement +/// [`LinearCombinationOpeningSource`] with a borrowed or one-shot source that +/// preserves their native traversal. +pub struct MaterializedLinearCombination { + evaluations: Vec, +} + +impl MaterializedLinearCombination { + /// Eagerly materializes a linear combination using individual source views. + pub fn new(source_batch: &B, terms: &[LinearSourceTerm]) -> Self + where + B: BatchOpeningSource, + { + let Some((first, rest)) = terms.split_first() else { + return Self { + evaluations: Vec::new(), + }; + }; + + let mut evaluations = materialize_source_evaluations(&source_batch.source(first.source_id)); + for value in &mut evaluations { + *value *= first.coefficient; + } + + for term in rest { + let next = materialize_source_evaluations(&source_batch.source(term.source_id)); + assert_eq!( + evaluations.len(), + next.len(), + "cannot linearly combine sources with different materialized lengths", + ); + for (acc, value) in evaluations.iter_mut().zip(next) { + *acc += term.coefficient * value; + } + } + + Self { evaluations } + } +} + +impl CommitmentSource for MaterializedLinearCombination { + fn num_vars(&self) -> usize { + if self.evaluations.is_empty() { + 0 + } else { + assert!( + self.evaluations.len().is_power_of_two(), + "materialized linear-combination source length must be a power of two", + ); + self.evaluations.len().trailing_zeros() as usize + } + } + + fn evaluate(&self, point: &[F]) -> F { + Polynomial::new(self.evaluations.clone()).evaluate(point) + } + + fn for_each_row(&self, chunk_len: usize, visit: V) + where + V: for<'row> FnMut(usize, SourceRow<'row, F>), + { + multilinear_for_each_row::(&self.evaluations, chunk_len, visit); + } + + fn fold_rows(&self, left: &[F], chunk_len: usize) -> Vec { + multilinear_fold_rows::(&self.evaluations, left, chunk_len) + } +} diff --git a/crates/jolt-openings/tests/reduction.rs b/crates/jolt-openings/tests/reduction.rs index 38f5e5ff09..8054474b70 100644 --- a/crates/jolt-openings/tests/reduction.rs +++ b/crates/jolt-openings/tests/reduction.rs @@ -1,7 +1,7 @@ -//! Integration tests for the opening reduction pipeline. +//! Integration tests for the homomorphic batch opening pipeline. //! //! These tests exercise the public API only — no internal imports. -//! They verify that the full reduce → open → verify pipeline works +//! They verify that the full prove_batch → verify_batch pipeline works //! end-to-end with MockCommitmentScheme across both transcript backends. //! //! Requires: `cargo nextest run -p jolt-openings --features test-utils` @@ -13,9 +13,9 @@ reason = "tests may panic on assertion failures" )] -use jolt_field::{Fr, FromPrimitiveInt, RandomSampling, ReducingBytes}; +use jolt_field::{Fr, FromPrimitiveInt, RandomSampling}; use jolt_openings::mock::MockCommitmentScheme; -use jolt_openings::{reduce_prover, reduce_verifier, CommitmentScheme, ProverClaim, VerifierClaim}; +use jolt_openings::{CommitmentScheme, CommitmentSchemeVerifier, OpeningClaim, ProverClaim}; use jolt_poly::Polynomial; use jolt_transcript::{Blake2bTranscript, KeccakTranscript, Transcript}; use rand_chacha::ChaCha20Rng; @@ -23,7 +23,7 @@ use rand_core::SeedableRng; type MockPCS = MockCommitmentScheme; -/// Full reduce → open → verify pipeline. +/// Full prove_batch → verify_batch pipeline. fn reduce_open_verify>( polys: &[Polynomial], points: &[Vec], @@ -42,47 +42,20 @@ fn reduce_open_verify>( eval, }); let (commitment, ()) = MockPCS::commit(poly.evaluations(), &()); - verifier_claims.push(VerifierClaim { + verifier_claims.push(OpeningClaim:: { commitment, point: point.clone(), eval, }); } - // Prover side let mut transcript_p = T::new(label); - let reduced_p = reduce_prover(prover_claims, &mut transcript_p); - let proofs: Vec<_> = reduced_p - .iter() - .map(|c| { - MockPCS::open( - &c.polynomial, - &c.point, - c.eval, - &(), - None, - &mut transcript_p, - ) - }) - .collect(); + let hints = vec![(); prover_claims.len()]; + let proof = MockPCS::prove_batch(prover_claims, hints, &(), &mut transcript_p); - // Verifier side let mut transcript_v = T::new(label); - let reduced_v = reduce_verifier::(verifier_claims, &mut transcript_v) - .expect("reduction should succeed"); - - assert_eq!(reduced_v.len(), proofs.len()); - for (claim, proof) in reduced_v.iter().zip(proofs.iter()) { - MockPCS::verify( - &claim.commitment, - &claim.point, - claim.eval, - proof, - &(), - &mut transcript_v, - ) + MockPCS::verify_batch(verifier_claims, &proof, &(), &mut transcript_v) .expect("verification should succeed"); - } } #[test] @@ -156,12 +129,16 @@ fn mixed_shared_and_distinct_points() { #[test] fn empty_claims_is_noop() { let mut transcript_p = Blake2bTranscript::new(b"empty"); - let reduced = reduce_prover::(Vec::new(), &mut transcript_p); - assert!(reduced.is_empty()); + let proof = MockPCS::prove_batch( + Vec::>>::new(), + Vec::new(), + &(), + &mut transcript_p, + ); + assert!(proof.is_empty()); let mut transcript_v = Blake2bTranscript::new(b"empty"); - let reduced_v = reduce_verifier::(Vec::new(), &mut transcript_v).unwrap(); - assert!(reduced_v.is_empty()); + MockPCS::verify_batch(Vec::new(), &proof, &(), &mut transcript_v).unwrap(); } #[test] @@ -192,12 +169,12 @@ fn tampered_eval_detected() { let (com_a, ()) = MockPCS::commit(poly_a.evaluations(), &()); let (com_b, ()) = MockPCS::commit(poly_b.evaluations(), &()); let verifier_claims = vec![ - VerifierClaim { + OpeningClaim:: { commitment: com_a, point: point.clone(), eval: eval_a, }, - VerifierClaim { + OpeningClaim:: { commitment: com_b, point: point.clone(), eval: eval_b + Fr::from_u64(1), // tampered @@ -205,40 +182,12 @@ fn tampered_eval_detected() { ]; let mut transcript_p = Blake2bTranscript::new(b"tampered"); - let reduced_p = reduce_prover(prover_claims, &mut transcript_p); - let proofs: Vec<_> = reduced_p - .iter() - .map(|c| { - MockPCS::open( - &c.polynomial, - &c.point, - c.eval, - &(), - None, - &mut transcript_p, - ) - }) - .collect(); + let hints = vec![(); prover_claims.len()]; + let proof = MockPCS::prove_batch(prover_claims, hints, &(), &mut transcript_p); let mut transcript_v = Blake2bTranscript::new(b"tampered"); - let reduced_v = reduce_verifier::(verifier_claims, &mut transcript_v) - .expect("reduction itself should succeed"); - - let mut any_failed = false; - for (claim, proof) in reduced_v.iter().zip(proofs.iter()) { - if MockPCS::verify( - &claim.commitment, - &claim.point, - claim.eval, - proof, - &(), - &mut transcript_v, - ) - .is_err() - { - any_failed = true; - } - } + let any_failed = + MockPCS::verify_batch(verifier_claims, &proof, &(), &mut transcript_v).is_err(); assert!( any_failed, "tampered evaluation must cause verification failure" diff --git a/specs/jolt-openings-crate.md b/specs/jolt-openings-crate.md new file mode 100644 index 0000000000..8b917f2392 --- /dev/null +++ b/specs/jolt-openings-crate.md @@ -0,0 +1,551 @@ +# Spec: `jolt-openings` Crate API Cutover + +| Field | Value | +|-------------|--------------| +| Author(s) | @quangvdao | +| Created | 2026-05-12 | +| Status | active | +| PR | [#1521](https://github.com/a16z/jolt/pull/1521) | + +## Summary + +This PR makes the extracted PCS crates the canonical place for polynomial +commitment and opening APIs, without migrating legacy `jolt-core` in this PR. + +The earlier integration experiment cut `jolt-core` over to the new trait family. +That was useful for finding the right abstraction boundary, especially around +streaming commitment and Stage 8 opening fusion. The merge target is narrower: +ship the crate-level API and concrete backend implementations now, while leaving +`jolt-core` unchanged as the end-to-end reference implementation. + +The intended consumer for the new API is the upcoming Bolt-generated +prover/verifier split, such as PR [#1514](https://github.com/a16z/jolt/pull/1514). +Generated prover and verifier crates should use these abstractions directly when +they land, rather than porting through the old in-core PCS trait family. + +## Intent + +### Goals + +1. Make `crates/jolt-openings` the backend-neutral PCS/opening API. +2. Make `crates/jolt-dory` implement that API while preserving the existing + Dory streaming and ZK behavior. +3. Keep `crates/jolt-hyperkzg` aligned with the same base trait family. +4. Provide source-backed commitment and opening abstractions that can express + current Jolt/Dory performance behavior without baking Dory matrix vocabulary + into generic traits. +5. Preserve legacy `jolt-core` as the old implementation for reference tests + and protocol-parity checks. +6. Document the intended generated-prover/generated-verifier integration path. + +### Non-Goals + +1. No `jolt-core` cutover in this PR. +2. No SDK, example, transpiler, CLI, or extractor churn caused by a legacy + `jolt-core` migration. +3. No proof-format or transcript change in legacy `jolt-core`. +4. No Akita or Hachi integration. +5. No generic source partition type whose real values are Dory-specific. +6. No permanent compatibility shim around the old in-core PCS traits. + +If a future PR makes a protocol-breaking change, it must either be implemented +on both the legacy `jolt-core` reference path and the generated-role path, or be +explicitly staged behind a separate compatibility plan. + +## Source Of Truth + +PR [#1467](https://github.com/a16z/jolt/pull/1467), branch +`quang/pcs-prover-verifier-split`, is the starting point for the abstract PCS +split. Current `main` remains the source of truth for legacy `jolt-core` +protocol behavior. + +Port or adapt from #1467: + +1. `crates/jolt-openings/src/schemes.rs`: verifier/prover split and extension + traits. +2. `crates/jolt-openings/src/sources.rs`: backend-neutral commitment and opening + source traits. +3. `crates/jolt-openings/src/homomorphic.rs`: ordinary homomorphic batch helper. +4. `crates/jolt-openings/src/claims.rs`: prover/verifier claim and batch-output + vocabulary. +5. `crates/jolt-openings/src/mock.rs`: test PCS under the split trait family. +6. `crates/jolt-dory/src/scheme.rs`: Dory implementation of the extracted + traits. +7. Focused tests and benches for the new crate-level API. + +Preserve from current `main`: + +1. Legacy `jolt-core` PCS implementation and proof flow. +2. Existing end-to-end reference behavior. +3. Current Dory proof hardening and ZK opening semantics. + +## Design + +### Architecture + +After this PR, the extracted crate dependency direction is: + +```text +jolt-field +jolt-poly +jolt-transcript +jolt-crypto + | + v +jolt-openings + | + +--> jolt-dory + | + +--> jolt-hyperkzg + | + +--> future PCS adapters +``` + +`jolt-openings` owns the abstract PCS API, source traits, batch-opening +claim/result vocabulary, and generic ordinary homomorphic batch helper. +Concrete PCS crates own backend-specific setup, commitment, proof, transcript, +hint, batching, and ZK details. + +Legacy `jolt-core` stays outside this cutover. It remains the old end-to-end +reference implementation until generated prover/verifier crates are ready to +consume the extracted API directly. + +### Trait Layers + +`jolt-openings` defines a verifier-first trait hierarchy: + +```rust +pub trait CommitmentSchemeVerifier: Commitment + Clone + Send + Sync + 'static { + type Field: Field; + type Proof; + type BatchProof; + type VerifierSetup; + + fn verify(...); + fn verify_batch(...); + fn bind_opening_inputs(...); +} + +pub trait CommitmentScheme: CommitmentSchemeVerifier { + type ProverSetup; + type OpeningHint; + type SetupParams; + + fn setup(...); + fn project_verifier_setup(...); + fn commit + ?Sized>(...); + fn commit_batch>(...); + fn open + ?Sized>(...); + fn prove_batch>(...); +} +``` + +Single-claim `open` and `verify` belong on the base PCS traits because they are +semantic PCS operations, not homomorphic-only operations. + +Verifier setup construction is split out: + +```rust +pub trait PublicVerifierSetup: CommitmentSchemeVerifier { + type PublicParams; + + fn verifier_setup(params: Self::PublicParams) -> Self::VerifierSetup; +} +``` + +This keeps KZG-style schemes honest: verifier setup may contain +trapdoor-derived elements and cannot always be reconstructed from public +generators alone. + +### Source Traversal + +`CommitmentSource` describes how a polynomial-like object can be evaluated and +traversed. It does not describe a backend partition. + +```rust +pub trait CommitmentSource: Send + Sync { + fn num_vars(&self) -> usize; + fn evaluate(&self, point: &[F]) -> F; + fn natural_chunk_len(&self) -> Option { None } + fn for_each_row(&self, chunk_len: usize, visit: V) + where + V: for<'row> FnMut(usize, SourceRow<'row, F>); + fn map_rows(&self, chunk_len: usize, visit: V) -> Vec + where + R: Send, + V: for<'row> Fn(usize, SourceRow<'row, F>) -> R + Send + Sync; + fn fold_rows(&self, left: &[F], chunk_len: usize) -> Vec; +} +``` + +The key point is `natural_chunk_len`. A source may say, “this is the row length I +can stream efficiently.” Dory privately interprets that as its row width and +derives its own matrix split. Another backend may ignore it, use it as a tile +size, or choose a different schedule. + +`SourceRow` supports field rows, compact integer rows, strided rows, and one-hot +rows so Dory can preserve the existing fast paths without forcing every caller +to materialize field elements. + +### Batch Sources + +Commitment batching is a source registry plus a shared traversal hint: + +```rust +pub trait BatchCommitmentSource { + type Id: SourceId; + type Source<'a>: CommitmentSource + 'a + where + Self: 'a; + + fn source(&self, id: Self::Id) -> Self::Source<'_>; + fn natural_chunk_len(&self, ids: &[Self::Id]) -> Option { ... } +} +``` + +Opening batching has two layers: + +```rust +pub trait BatchOpeningSource: Send + Sync { + type Id: SourceId; + type Source<'a>: CommitmentSource + 'a + where + Self: 'a; + + fn source(&self, id: Self::Id) -> Self::Source<'_>; + fn opening_hint(&self, id: Self::Id) -> &OpeningHint; +} + +pub trait LinearCombinationOpeningSource: + BatchOpeningSource +{ + type LinearCombination<'a>: CommitmentSource + 'a + where + Self: 'a; + + fn linear_combination<'a>( + &'a mut self, + terms: &[LinearSourceTerm], + ) -> Self::LinearCombination<'a>; +} +``` + +The base opening source does not assume linear combination. The linear extension +is the extra capability needed by homomorphic/RLC-style PCS implementations such +as Dory. This leaves room for future schemes whose native batching is +concatenation, quotienting, folding, GPU tiling, or something else. + +### Batch Opening Output + +Source-backed batch opening returns the proof plus a public relation between the +PCS output and raw protocol claims. + +```rust +pub trait LinearOpeningScheme: CommitmentScheme + LinearOpeningSchemeVerifier { + fn prove_batch_opening( + terms: Vec>, + source_batch: &mut B, + setup: &Self::ProverSetup, + transcript: &mut impl Transcript, + ) -> BatchOpeningProverResult + where + B: LinearCombinationOpeningSource; +} +``` + +In transparent mode, the output value is public. In ZK mode, the output may be a +hiding commitment plus prover-only value/blinding witnesses. + +This is the abstraction generated Stage 8 should use: generated code supplies +raw opening terms and a source batch; the PCS owns the fusion challenge schedule, +source fusion, proof construction, and output-relation metadata. + +### Ordinary Homomorphic Batching + +The ordinary homomorphic batch helper implements the standard group-by-point RLC +protocol for schemes whose commitments and hints can be linearly combined. + +Singleton batches are deliberately special: a one-claim batch calls `open` / +`verify` directly and wraps the result in `PCS::BatchProof`. It does not absorb +`rlc_claims` or draw a batch challenge. + +For multi-claim batches, the prover and verifier: + +1. absorb the claim count under `rlc_claims`; +2. absorb all claimed evaluations in the same order; +3. group claims by opening point; +4. draw one RLC challenge per point group; +5. combine polynomials, commitments, evaluations, and hints with the same + challenge powers; and +6. produce or verify one `PCS::Proof` per opening-point group. + +For Dory this means `PCS::BatchProof = Vec`, with one proof per +opening-point group. + +### Dory + +`DoryScheme` implements the extracted traits while keeping Dory-specific details +inside `crates/jolt-dory`: + +1. `CommitmentSchemeVerifier` +2. `PublicVerifierSetup` +3. `CommitmentScheme` +4. `AdditivelyHomomorphicVerifier` +5. `AdditivelyHomomorphic` +6. `ZkOpeningSchemeVerifier` +7. `ZkOpeningScheme` +8. `LinearOpeningSchemeVerifier` +9. `LinearOpeningScheme` +10. `ZkLinearOpeningSchemeVerifier` +11. `ZkLinearOpeningScheme` + +Dory-specific facts remain private to Dory: + +1. `sigma` / `nu` +2. row commitments +3. row-major proof coordinate order +4. transcript adapter details +5. bounded proof deserialization +6. ZK evaluation commitment plumbing + +`DoryScheme::BatchProof = Vec`. The ordinary homomorphic batch helper +returns one `DoryProof` per opening-point group. A singleton batch is therefore a +single-element vector, not a different proof type. + +### Bolt / Generated Roles + +Generated prover/verifier crates should integrate at the crate boundary, not by +first porting through legacy `jolt-core`. + +Generated commitment code should build a `BatchCommitmentSource` from its +witness provider: + +```rust +let commitments = PCS::commit_batch(&witness_sources, &source_ids, &prover_setup); +``` + +Generated Stage 8 code should collect raw opening terms and invoke the linear +opening extension only when the selected PCS advertises that capability: + +```rust +let result = PCS::prove_batch_opening( + opening_terms, + &mut opening_sources, + &prover_setup, + transcript, +); +``` + +Verifier code should call the matching verifier extension and use the returned +relation to bind the generated protocol's output constraints: + +```rust +let public = PCS::verify_batch_opening( + verifier_terms, + &proof.joint_opening_proof, + &verifier_setup, + transcript, +)?; +``` + +The generated pipeline owns protocol-specific ids, claim ordering, transcript +placement, and constraint binding. `jolt-openings` owns only the PCS-level source +and proof API. + +The abstraction should stay strategy-oblivious above the PCS boundary. Bolt's +oracle buffers map to `CommitmentSource`; oracle families and +`compute.pcs_commit_batch` map to `BatchCommitmentSource` plus +`PCS::commit_batch`; opening obligations map to raw opening terms plus +`BatchOpeningSource`; and RLC-style opening obligations additionally require +`LinearCombinationOpeningSource`. + +A `SourceId` names a committed source, not necessarily one logical Jolt +polynomial. Dory can use one source id per logical polynomial. A future packed +scheme can use one source id for a packed witness group and let its adapter route +logical polynomial openings into packed-source points. + +### Generated ZK Boundary + +ZK openings need more than a verifier call. They also need the hiding commitment +to the opened value and prover-only witness data such as the hidden scalar and +blinding. + +The source-backed ZK API returns this as: + +1. `BatchOpeningPublic` containing `Hidden(y_com)`-style public outputs; +2. `ZkBatchOpeningWitness` containing prover-only output values and blinds; and +3. a relation describing how the PCS output is derived from raw opening terms. + +Generated ZK code should use that relation to bind its proof constraints. The +PCS should not mention BlindFold or any specific proof system by name; it should +only expose the commitment/opening facts the surrounding protocol needs. + +Legacy `jolt-core` proof serialization is intentionally unchanged in this PR. +When generated-role proofs consume this API, their opening proof storage should +use the scheme-defined `PCS::BatchProof` rather than assuming that every batch is +one `PCS::Proof`. + +## Invariants + +1. `jolt-openings` does not depend on `jolt-core`, `jolt-dory`, `dory`, + `common`, `tracer`, `jolt-sdk`, Akita, or Hachi. +2. Generic traits do not expose Dory's matrix shape, `sigma`, `nu`, or a generic + associated partition type. +3. Source traversal APIs are allowed to expose backend-neutral facts such as + `natural_chunk_len`. +4. `StreamingCommitment` is not part of the canonical public API. +5. `BatchOpeningSource` is a source/hint registry. Linear fusion is only on + `LinearCombinationOpeningSource`. +6. Dory hints carry enough backend-owned information to replay the commitment + traversal at opening time. +7. Dory source-backed commitment/opening must preserve current streaming + behavior and avoid materializing full trace-sized field tables on hot paths. +8. Prover and verifier batch helpers bind the same transcript data in the same + order. +9. Legacy `jolt-core` remains unchanged by this PR. +10. Protocol-specific ids, claim ordering, transcript placement, and constraint + binding stay outside `jolt-openings`. +11. The PR introduces no Akita dependency. + +## Acceptance Criteria + +- [x] `crates/jolt-openings/src/schemes.rs` defines the verifier/prover split. +- [x] `PublicVerifierSetup` is separate from the base verifier trait. +- [x] Base traits expose single `open` / `verify`. +- [x] Base traits expose ordinary `prove_batch` / `verify_batch`. +- [x] `crates/jolt-openings/src/sources.rs` defines source and batch-source + traits with `natural_chunk_len`. +- [x] `crates/jolt-openings/src/sources.rs` separates `BatchOpeningSource` from + `LinearCombinationOpeningSource`. +- [x] `crates/jolt-openings/src/claims.rs` defines raw batch-opening terms and + output-relation metadata. +- [x] `crates/jolt-openings/src/homomorphic.rs` provides ordinary homomorphic + batch helpers. +- [x] `crates/jolt-openings/src/mock.rs` implements the split traits for tests. +- [x] `crates/jolt-dory` implements the split trait family. +- [x] `DoryScheme::BatchProof = Vec`. +- [x] Dory ordinary batch opening returns one proof per opening-point group. +- [x] Dory source-backed commitment uses source traversal hints rather than a + public shaped trait. +- [x] Dory source-backed linear opening owns fusion and returns output-relation + metadata. +- [x] `crates/jolt-hyperkzg` compiles against the same base trait family. +- [x] The final diff contains no `jolt-core` source changes. +- [x] Focused crate tests and clippy pass. + +## Testing Strategy + +Focused `jolt-openings` tests should validate: + +1. single-claim commit/open/verify; +2. multi-claim batches with shared and distinct points; +3. tampered-evaluation rejection; +4. RLC polynomial/scalar consistency; +5. prover/verifier transcript sync for the batch helper; +6. source-backed opening output relations; and +7. ZK source-backed output metadata with prover-only witnesses kept private. + +Focused `jolt-dory` tests should validate: + +1. transparent and ZK round trips; +2. homomorphic commitment and hint combination; +3. single-claim and multi-claim `prove_batch` / `verify_batch`; +4. source-batch commitment equivalence to direct commitment; +5. proof deserialization hardening; +6. non-default `natural_chunk_len` replay through `DoryHint`; and +7. source-backed transparent and ZK batch-opening output relations. + +Focused `jolt-hyperkzg` tests should validate that the base trait family remains +usable by a non-Dory backend, including verifier setup that is not publicly +derivable. + +Dependency checks should verify that `jolt-openings` has no dependency on +`jolt-core`, `jolt-dory`, `dory`, `tracer`, or Akita, and that backend crates +depend on `jolt-openings` rather than the reverse. + +## Performance + +This PR is scoped to crate-level API and backend behavior, but the performance +contract is still important because these traits are intended for generated +provers. + +The API must preserve two Dory hot paths: + +1. source-batch commitment should allow one scan over source rows while producing + many Dory commitments; and +2. source-backed linear opening should allow RLC-style fusion without forcing + callers to materialize all committed polynomials as dense field tables. + +The closure-based row visitors are generic over the visitor type. Rust +monomorphizes the concrete closure at the call site, like `Iterator::map` or +Rayon closures; the higher-ranked row lifetime only says the visitor accepts a +short-lived borrowed row. It does not require dynamic dispatch or heap-allocated +trait objects. + +Performance-sensitive invariants: + +1. keep row order and row encodings stable; +2. keep compact integer and one-hot row paths available; +3. keep Dory row-commitment hints reusable at opening time; +4. keep backend-owned parallelism in commitment, folding, and hint combination; +5. avoid materializing trace-sized field tables on Dory streaming paths; and +6. benchmark generated-role integration against legacy `jolt-core` before + replacing any end-to-end path. + +## Validation + +Required before merge: + +```bash +cargo fmt -q --check +cargo check -p jolt-openings -q --features test-utils +cargo check -p jolt-dory -q +cargo check -p jolt-hyperkzg -q +cargo nextest run -p jolt-openings --cargo-quiet --features test-utils +cargo nextest run -p jolt-dory --cargo-quiet +cargo nextest run -p jolt-hyperkzg --cargo-quiet +cargo clippy -p jolt-openings -q --features test-utils --all-targets -- -D warnings +cargo clippy -p jolt-dory -q --all-targets -- -D warnings +cargo clippy -p jolt-hyperkzg -q --all-targets -- -D warnings +``` + +Useful reference checks, but not required by this PR's scope because legacy +`jolt-core` is intentionally untouched: + +```bash +cargo nextest run -p jolt-core muldiv --cargo-quiet --features host +cargo nextest run -p jolt-core muldiv --cargo-quiet --features host,zk +``` + +## Alternatives Considered + +1. **Full legacy `jolt-core` cutover in this PR.** + Rejected for merge scope. The experiment found useful trait boundaries, but + the long-term integration target is the generated prover/verifier split, and + legacy `jolt-core` should stay as a reference implementation for now. + +2. **Keep only the old `reduce_prover` / `reduce_verifier` API.** + Rejected because it leaves batching as external orchestration and forces + future non-Dory schemes into homomorphic assumptions. + +3. **Put single-claim `open` and `verify` only on homomorphic extensions.** + Rejected because singleton openings are semantic PCS operations. + +4. **Make source partitioning an associated type on `CommitmentSource`.** + Rejected because it would either encode Dory's matrix split generically or + force a universal partition enum that tries to predict future backends. + +5. **Let Stage 8 pre-fuse raw claims and call singleton opening.** + Rejected for future generated-role integration because it keeps the protocol + layer responsible for PCS fusion challenges and output-relation bookkeeping. + +## Future Work + +1. Wire Bolt-generated prover/verifier crates directly to the source-backed + opening API. +2. Add reference tests comparing legacy `jolt-core` outputs against the + generated-role implementation. +3. If a later protocol-breaking change lands, mirror it on both sides or stage a + dedicated compatibility plan. +4. Revisit whether non-linear native batch APIs deserve their own extension + traits once a concrete non-Dory backend needs them.