From bea32e1c57127f0d8b97291a8500e6c68c751cdf Mon Sep 17 00:00:00 2001 From: Markos Georghiades <53157953+Markos-The-G@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:06:25 -0400 Subject: [PATCH 1/2] feat: add HyperKZG zero-knowledge openings --- Cargo.toml | 11 + crates/jolt-hyperkzg/Cargo.toml | 12 +- crates/jolt-hyperkzg/README.md | 31 +- crates/jolt-hyperkzg/benches/hyperkzg.rs | 4 +- .../benches/hyperkzg_zk_profile.rs | 514 ++++++ .../fuzz/fuzz_targets/commit_open_verify.rs | 2 +- .../fuzz/fuzz_targets/tampered_proof.rs | 16 +- .../fuzz/fuzz_targets/wrong_eval.rs | 2 +- crates/jolt-hyperkzg/src/error.rs | 76 +- crates/jolt-hyperkzg/src/kzg.rs | 72 +- crates/jolt-hyperkzg/src/lib.rs | 15 +- crates/jolt-hyperkzg/src/scheme.rs | 960 ++++++----- crates/jolt-hyperkzg/src/setup.rs | 434 +++++ crates/jolt-hyperkzg/src/types.rs | 173 +- .../jolt-hyperkzg/tests/commit_open_verify.rs | 207 +-- crates/jolt-hyperkzg/tests/common/mod.rs | 16 + crates/jolt-hyperkzg/tests/homomorphism.rs | 118 ++ crates/jolt-hyperkzg/tests/setup.rs | 149 ++ crates/jolt-hyperkzg/tests/soundness.rs | 149 ++ crates/jolt-hyperkzg/tests/zk_homomorphism.rs | 101 ++ crates/jolt-hyperkzg/tests/zk_opening.rs | 71 + crates/jolt-hyperkzg/tests/zk_soundness.rs | 273 ++++ crates/jolt-hyperkzg/tests/zk_srs.rs | 97 ++ crates/jolt-hyperkzg/tests/zk_statistical.rs | 540 +++++++ specs/hyperkzg-zk.md | 1428 +++++++++++++++++ 25 files changed, 4899 insertions(+), 572 deletions(-) create mode 100644 crates/jolt-hyperkzg/benches/hyperkzg_zk_profile.rs create mode 100644 crates/jolt-hyperkzg/src/setup.rs create mode 100644 crates/jolt-hyperkzg/tests/common/mod.rs create mode 100644 crates/jolt-hyperkzg/tests/homomorphism.rs create mode 100644 crates/jolt-hyperkzg/tests/setup.rs create mode 100644 crates/jolt-hyperkzg/tests/soundness.rs create mode 100644 crates/jolt-hyperkzg/tests/zk_homomorphism.rs create mode 100644 crates/jolt-hyperkzg/tests/zk_opening.rs create mode 100644 crates/jolt-hyperkzg/tests/zk_soundness.rs create mode 100644 crates/jolt-hyperkzg/tests/zk_srs.rs create mode 100644 crates/jolt-hyperkzg/tests/zk_statistical.rs create mode 100644 specs/hyperkzg-zk.md diff --git a/Cargo.toml b/Cargo.toml index b85f195b4b..6b0e64eb2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,9 +34,14 @@ members = [ "crates/jolt-sumcheck", "crates/jolt-openings", "crates/jolt-verifier", + "crates/jolt-prover", + "crates/jolt-witness", + "crates/jolt-backends", "crates/jolt-dory", + "crates/jolt-dory-assist-verifier", "crates/jolt-hyrax", "crates/jolt-hyperkzg", + "crates/jolt-wrapper-verifier", "crates/jolt-riscv", "crates/jolt-transcript", "crates/jolt-profiling", @@ -388,9 +393,15 @@ jolt-blindfold = { path = "./crates/jolt-blindfold" } jolt-claims = { path = "./crates/jolt-claims" } jolt-crypto = { path = "./crates/jolt-crypto" } jolt-field = { path = "./crates/jolt-field" } +jolt-hyperkzg = { path = "./crates/jolt-hyperkzg" } jolt-openings = { path = "./crates/jolt-openings" } jolt-hyrax = { path = "./crates/jolt-hyrax" } +jolt-dory-assist-verifier = { path = "./crates/jolt-dory-assist-verifier" } +jolt-wrapper-verifier = { path = "./crates/jolt-wrapper-verifier" } jolt-verifier = { path = "./crates/jolt-verifier" } +jolt-prover = { path = "./crates/jolt-prover" } +jolt-witness = { path = "./crates/jolt-witness" } +jolt-backends = { path = "./crates/jolt-backends" } jolt-poly = { path = "./crates/jolt-poly" } jolt-r1cs = { path = "./crates/jolt-r1cs" } jolt-transcript = { path = "./crates/jolt-transcript" } diff --git a/crates/jolt-hyperkzg/Cargo.toml b/crates/jolt-hyperkzg/Cargo.toml index 9986c5f33f..0ea11949cc 100644 --- a/crates/jolt-hyperkzg/Cargo.toml +++ b/crates/jolt-hyperkzg/Cargo.toml @@ -3,7 +3,7 @@ name = "jolt-hyperkzg" version = "0.1.0" edition = "2021" license = "MIT" -description = "HyperKZG multilinear polynomial commitment scheme for the Jolt zkVM" +description = "HyperKZG Gemini-style multilinear polynomial commitment scheme for the Jolt zkVM" [lints] workspace = true @@ -15,6 +15,7 @@ jolt-poly = { path = "../jolt-poly" } jolt-transcript = { path = "../jolt-transcript" } jolt-openings = { path = "../jolt-openings" } serde = { workspace = true, features = ["derive"] } +bincode = { workspace = true } tracing.workspace = true num-traits = { workspace = true } rayon = { workspace = true } @@ -22,14 +23,23 @@ thiserror = { workspace = true } rand_core = { workspace = true, features = ["getrandom"] } rand_chacha = { workspace = true } +[features] +default = [] +zk = [] + [dev-dependencies] jolt-field = { path = "../jolt-field", features = ["bn254"] } criterion = { workspace = true } rand = { workspace = true } +memory-stats = { workspace = true } [[bench]] name = "hyperkzg" harness = false +[[bench]] +name = "hyperkzg_zk_profile" +harness = false + [package.metadata.cargo-machete] ignored = ["rand_core", "rand_chacha", "rand"] diff --git a/crates/jolt-hyperkzg/README.md b/crates/jolt-hyperkzg/README.md index a71a555d61..39bf7f08dc 100644 --- a/crates/jolt-hyperkzg/README.md +++ b/crates/jolt-hyperkzg/README.md @@ -16,6 +16,34 @@ This crate is generic over `PairingGroup` from `jolt-crypto` and implements `Com 2. **Open** (Gemini reduction) — fold the multilinear polynomial `ℓ-1` times producing intermediate commitments, derive challenge `r`, batch KZG open at `[r, -r, r²]`. 3. **Verify** — evaluation consistency check, then batch KZG pairing check. +With the `zk` feature, HyperKZG uses commitment blinding plus KZG proof +randomization: ZK hints carry one scalar blind, hidden evaluations are group +commitments, and the verifier checks Gemini over group elements without seeing +raw evaluations. + +## SRS Generation and Import + +HyperKZG uses a **structured** reference string with a KZG trapdoor `beta`. +Production provers/verifiers should import an SRS generated by a separate trusted +setup ceremony, rather than generating `beta` in the live proving process. + +The canonical prover SRS filename is: + +```text +hyperkzg_{k}.srs +``` + +Here `k` is the exponent for the supported polynomial size: `hyperkzg_20.srs` +supports multilinear polynomials with up to `2^20` evaluations. The serialized +prover setup stores one additional G1 power internally, following the KZG SRS +convention used by this crate. + +SRS files use a versioned envelope with a `Plain`/`Zk` discriminant so loaders do +not silently accept the wrong ceremony output. `HyperKZGScheme::setup` and +`HyperKZGScheme::setup_from_secret` are intended for tests or trusted setup +tooling only. The runtime import path is `HyperKZGScheme::read_srs_file(path)` +or `HyperKZGScheme::read_srs_from_dir(dir, k)`. + ## Public API - **`HyperKZGScheme

`** — Main entry point. Implements `CommitmentScheme` and `AdditivelyHomomorphic`. @@ -42,7 +70,8 @@ Used by `jolt-zkvm`. ## Feature Flags -This crate has no feature flags. +- **`zk`** — Enables ZK HyperKZG surface area. Transparent HyperKZG remains the + default path. ## License diff --git a/crates/jolt-hyperkzg/benches/hyperkzg.rs b/crates/jolt-hyperkzg/benches/hyperkzg.rs index 27c587e741..c678455d74 100644 --- a/crates/jolt-hyperkzg/benches/hyperkzg.rs +++ b/crates/jolt-hyperkzg/benches/hyperkzg.rs @@ -94,7 +94,7 @@ fn bench_verify(c: &mut Criterion) { let poly = Polynomial::::random(nv, &mut rng); let point: Vec = (0..nv).map(|_| Fr::random(&mut rng)).collect(); let eval = poly.evaluate(&point); - let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); + let (commitment, _) = TestScheme::commit(poly.evaluations(), &pk); let mut transcript = jolt_transcript::Blake2bTranscript::new(b"bench-verify"); let proof = ::open( @@ -138,7 +138,7 @@ fn bench_combine(c: &mut Criterion) { let commitments: Vec<_> = (0..count) .map(|_| { let poly = Polynomial::::random(num_vars, &mut rng); - let (c, ()) = TestScheme::commit(poly.evaluations(), &pk); + let (c, _) = TestScheme::commit(poly.evaluations(), &pk); c }) .collect(); diff --git a/crates/jolt-hyperkzg/benches/hyperkzg_zk_profile.rs b/crates/jolt-hyperkzg/benches/hyperkzg_zk_profile.rs new file mode 100644 index 0000000000..93b7c9f22a --- /dev/null +++ b/crates/jolt-hyperkzg/benches/hyperkzg_zk_profile.rs @@ -0,0 +1,514 @@ +#[cfg(not(feature = "zk"))] +fn main() {} + +#[cfg(feature = "zk")] +mod profile { + #![expect(clippy::print_stdout, reason = "benchmark reports metrics to stdout")] + + use std::error::Error; + use std::process::Command; + use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, + }; + use std::thread::JoinHandle; + use std::time::{Duration, Instant}; + + use jolt_crypto::{Bn254, JoltGroup}; + use jolt_field::{Fr, FromPrimitiveInt, RandomSampling}; + use jolt_hyperkzg::{ + HyperKZGCommitment, HyperKZGProof, HyperKZGProverSetup, HyperKZGScheme, + HyperKZGVerifierSetup, + }; + use jolt_openings::{CommitmentScheme, ZkOpeningScheme}; + use jolt_poly::Polynomial; + use jolt_transcript::{Blake2bTranscript, Transcript}; + use rand_chacha::ChaCha20Rng; + use rand_core::SeedableRng; + + const NUM_VARS: usize = 18; + const NUM_EVALS: usize = 1 << NUM_VARS; + const DEFAULT_SAMPLES: usize = 3; + const MEMORY_SAMPLE_INTERVAL: Duration = Duration::from_millis(1); + const CHILD_MODE_ENV: &str = "JOLT_HYPERKZG_PROFILE_CHILD_MODE"; + const SAMPLES_ENV: &str = "JOLT_HYPERKZG_PROFILE_SAMPLES"; + const RESULT_PREFIX: &str = "RESULT"; + + type BenchResult = Result>; + type Scheme = HyperKZGScheme; + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum Mode { + Transparent, + Zk, + } + + impl Mode { + const ALL: [Self; 2] = [Self::Transparent, Self::Zk]; + + const fn as_str(self) -> &'static str { + match self { + Self::Transparent => "transparent", + Self::Zk => "zk", + } + } + + fn parse(value: &str) -> BenchResult { + match value { + "transparent" => Ok(Self::Transparent), + "zk" => Ok(Self::Zk), + _ => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("unknown HyperKZG profile mode {value:?}"), + ) + .into()), + } + } + } + + pub fn main() -> BenchResult<()> { + let samples = sample_count()?; + if let Some(mode) = child_mode()? { + let metrics = run_mode(mode, samples)?; + print_machine_result(mode, &metrics); + return Ok(()); + } + + let mut rows = Vec::with_capacity(Mode::ALL.len()); + for mode in Mode::ALL { + rows.push(run_child(mode, samples)?); + } + print_report(samples, &rows); + Ok(()) + } + + fn run_child(mode: Mode, samples: usize) -> BenchResult<(Mode, ModeMetrics)> { + let output = Command::new(std::env::current_exe()?) + .env(CHILD_MODE_ENV, mode.as_str()) + .env(SAMPLES_ENV, samples.to_string()) + .output()?; + + if !output.status.success() { + return Err(std::io::Error::other(format!( + "HyperKZG profile child {:?} failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + mode, + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + )) + .into()); + } + + parse_machine_result(&output.stdout) + } + + fn run_mode(mode: Mode, samples: usize) -> BenchResult { + let mut metrics = ModeMetrics::default(); + match mode { + Mode::Transparent => { + let pk = make_clear_setup(); + let vk = Scheme::verifier_setup(&pk); + for sample_index in 0..samples { + let (poly, point, eval) = sample_input(sample_index); + metrics.record(profile_clear_sample(&pk, &vk, &poly, &point, eval)?); + } + } + Mode::Zk => { + let pk = make_zk_setup(); + let vk = Scheme::verifier_setup(&pk); + for sample_index in 0..samples { + let (poly, point, eval) = sample_input(sample_index); + metrics.record(profile_zk_sample(&pk, &vk, &poly, &point, eval)?); + } + } + } + Ok(metrics) + } + + fn make_clear_setup() -> HyperKZGProverSetup { + let beta = Fr::from_u64(12345); + let g1 = Bn254::g1_generator(); + let g2 = Bn254::g2_generator(); + Scheme::setup_from_secret(beta, NUM_EVALS, g1, g2) + } + + fn make_zk_setup() -> HyperKZGProverSetup { + let beta = Fr::from_u64(12345); + let g1 = Bn254::g1_generator(); + let g2 = Bn254::g2_generator(); + let hiding_g1 = g1.scalar_mul(&Fr::from_u64(17)); + Scheme::setup_zk_from_secret(beta, NUM_EVALS, g1, hiding_g1, g2) + } + + fn sample_input(sample_index: usize) -> (Polynomial, Vec, Fr) { + let mut rng = ChaCha20Rng::seed_from_u64(0x5eed_0000 + sample_index as u64); + let poly = Polynomial::::random(NUM_VARS, &mut rng); + let point: Vec = (0..NUM_VARS).map(|_| Fr::random(&mut rng)).collect(); + let eval = poly.evaluate(&point); + (poly, point, eval) + } + + fn profile_clear_sample( + pk: &HyperKZGProverSetup, + vk: &HyperKZGVerifierSetup, + poly: &Polynomial, + point: &[Fr], + eval: Fr, + ) -> BenchResult { + let ((commitment, proof), prover_time, peak) = measure_prover_peak(|| { + let (commitment, hint) = ::commit(poly.evaluations(), pk); + let mut transcript = Blake2bTranscript::new(b"hyperkzg-profile-clear"); + let proof = ::open( + poly, + point, + eval, + pk, + Some(hint), + &mut transcript, + ); + (commitment, proof) + }); + + let proof_bytes = serialized_proof_size(&proof)?; + let verifier_time = time_verify_clear(&commitment, point, eval, &proof, vk)?; + + Ok(SampleMetrics { + proof_bytes, + prover_time, + verifier_time, + peak_rss_bytes: peak.peak_bytes, + peak_delta_bytes: peak.delta_bytes, + }) + } + + fn profile_zk_sample( + pk: &HyperKZGProverSetup, + vk: &HyperKZGVerifierSetup, + poly: &Polynomial, + point: &[Fr], + eval: Fr, + ) -> BenchResult { + let ((commitment, proof, y_out), prover_time, peak) = measure_prover_peak(|| { + let (commitment, hint) = ::commit_zk(poly.evaluations(), pk); + let mut transcript = Blake2bTranscript::new(b"hyperkzg-profile-zk"); + let (proof, y_out, _blind) = + Scheme::open_zk(poly, point, eval, pk, hint, &mut transcript); + (commitment, proof, y_out) + }); + + let proof_bytes = serialized_proof_size(&proof)?; + let verifier_time = time_verify_zk(&commitment, point, &proof, vk, &y_out)?; + + Ok(SampleMetrics { + proof_bytes, + prover_time, + verifier_time, + peak_rss_bytes: peak.peak_bytes, + peak_delta_bytes: peak.delta_bytes, + }) + } + + fn time_verify_clear( + commitment: &HyperKZGCommitment, + point: &[Fr], + eval: Fr, + proof: &HyperKZGProof, + vk: &HyperKZGVerifierSetup, + ) -> BenchResult { + let start = Instant::now(); + let mut transcript = Blake2bTranscript::new(b"hyperkzg-profile-clear"); + ::verify(commitment, point, eval, proof, vk, &mut transcript)?; + Ok(start.elapsed()) + } + + fn time_verify_zk( + commitment: &HyperKZGCommitment, + point: &[Fr], + proof: &HyperKZGProof, + vk: &HyperKZGVerifierSetup, + expected_y_out: &::HidingCommitment, + ) -> BenchResult { + let start = Instant::now(); + let mut transcript = Blake2bTranscript::new(b"hyperkzg-profile-zk"); + let verified_y_out = Scheme::verify_zk(commitment, point, proof, vk, &mut transcript)?; + if &verified_y_out != expected_y_out { + return Err(std::io::Error::other( + "ZK verifier returned the wrong output hiding commitment", + ) + .into()); + } + Ok(start.elapsed()) + } + + fn serialized_proof_size(proof: &HyperKZGProof) -> BenchResult { + let bytes = bincode::serde::encode_to_vec(proof, bincode::config::standard())?; + Ok(bytes.len()) + } + + fn measure_prover_peak(f: impl FnOnce() -> T) -> (T, Duration, PeakMeasurement) { + let sampler = PeakSampler::start(); + let start = Instant::now(); + let result = f(); + let elapsed = start.elapsed(); + let peak = sampler.stop(); + (result, elapsed, peak) + } + + #[derive(Clone, Copy)] + struct PeakMeasurement { + peak_bytes: Option, + delta_bytes: Option, + } + + struct PeakSampler { + baseline: Option, + peak: Arc, + stop: Arc, + handle: Option>, + } + + impl PeakSampler { + fn start() -> Self { + let baseline = current_rss_bytes(); + let peak = Arc::new(AtomicUsize::new(baseline.unwrap_or_default())); + let stop = Arc::new(AtomicBool::new(false)); + let thread_peak = Arc::clone(&peak); + let thread_stop = Arc::clone(&stop); + + let handle = std::thread::spawn(move || { + while !thread_stop.load(Ordering::Relaxed) { + if let Some(bytes) = current_rss_bytes() { + let _ = thread_peak.fetch_max(bytes, Ordering::Relaxed); + } + std::thread::sleep(MEMORY_SAMPLE_INTERVAL); + } + if let Some(bytes) = current_rss_bytes() { + let _ = thread_peak.fetch_max(bytes, Ordering::Relaxed); + } + }); + + Self { + baseline, + peak, + stop, + handle: Some(handle), + } + } + + fn stop(mut self) -> PeakMeasurement { + self.stop.store(true, Ordering::Relaxed); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + let Some(baseline) = self.baseline else { + return PeakMeasurement { + peak_bytes: None, + delta_bytes: None, + }; + }; + let peak = self.peak.load(Ordering::Relaxed); + PeakMeasurement { + peak_bytes: Some(peak), + delta_bytes: Some(peak.saturating_sub(baseline)), + } + } + } + + fn current_rss_bytes() -> Option { + memory_stats::memory_stats().map(|stats| stats.physical_mem) + } + + #[derive(Clone, Copy)] + struct SampleMetrics { + proof_bytes: usize, + prover_time: Duration, + verifier_time: Duration, + peak_rss_bytes: Option, + peak_delta_bytes: Option, + } + + #[derive(Clone, Default)] + struct ModeMetrics { + samples: usize, + proof_bytes_total: usize, + prover_time_total: Duration, + verifier_time_total: Duration, + max_peak_rss_bytes: Option, + max_peak_delta_bytes: Option, + } + + impl ModeMetrics { + fn record(&mut self, sample: SampleMetrics) { + self.samples += 1; + self.proof_bytes_total += sample.proof_bytes; + self.prover_time_total += sample.prover_time; + self.verifier_time_total += sample.verifier_time; + self.max_peak_rss_bytes = max_optional(self.max_peak_rss_bytes, sample.peak_rss_bytes); + self.max_peak_delta_bytes = + max_optional(self.max_peak_delta_bytes, sample.peak_delta_bytes); + } + + fn average_proof_bytes(&self) -> usize { + self.proof_bytes_total / self.samples + } + + fn average_prover_time(&self) -> Duration { + self.prover_time_total / self.samples as u32 + } + + fn average_verifier_time(&self) -> Duration { + self.verifier_time_total / self.samples as u32 + } + } + + fn max_optional(lhs: Option, rhs: Option) -> Option { + match (lhs, rhs) { + (Some(lhs), Some(rhs)) => Some(lhs.max(rhs)), + (None, Some(rhs)) => Some(rhs), + (lhs, None) => lhs, + } + } + + fn print_report(samples: usize, rows: &[(Mode, ModeMetrics)]) { + println!("HyperKZG 2^{NUM_VARS} profile ({samples} samples)"); + println!("prover = commit + open; setup and input generation excluded from timing"); + println!("memory = child-process max RSS observed during prover work"); + println!(); + println!( + "{:<13} {:>12} {:>16} {:>16} {:>16} {:>16}", + "mode", "proof bytes", "prover avg", "verifier avg", "peak RSS", "RSS delta" + ); + for (mode, metrics) in rows { + print_row(*mode, metrics); + } + } + + fn print_row(mode: Mode, metrics: &ModeMetrics) { + println!( + "{:<13} {:>12} {:>16} {:>16} {:>16} {:>16}", + mode.as_str(), + metrics.average_proof_bytes(), + format_duration(metrics.average_prover_time()), + format_duration(metrics.average_verifier_time()), + format_memory(metrics.max_peak_rss_bytes), + format_memory(metrics.max_peak_delta_bytes) + ); + } + + fn print_machine_result(mode: Mode, metrics: &ModeMetrics) { + println!( + "{RESULT_PREFIX}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + mode.as_str(), + metrics.samples, + metrics.proof_bytes_total, + metrics.prover_time_total.as_nanos(), + metrics.verifier_time_total.as_nanos(), + machine_optional_usize(metrics.max_peak_rss_bytes), + machine_optional_usize(metrics.max_peak_delta_bytes) + ); + } + + fn parse_machine_result(stdout: &[u8]) -> BenchResult<(Mode, ModeMetrics)> { + let stdout = String::from_utf8(stdout.to_vec())?; + let Some(line) = stdout.lines().find(|line| line.starts_with(RESULT_PREFIX)) else { + return Err(std::io::Error::other(format!( + "HyperKZG profile child did not emit a {RESULT_PREFIX} line:\n{stdout}" + )) + .into()); + }; + + let fields: Vec<&str> = line.split('\t').collect(); + if fields.len() != 8 { + return Err(std::io::Error::other(format!( + "malformed HyperKZG profile result line: {line}" + )) + .into()); + } + + let mode = Mode::parse(fields[1])?; + let samples = fields[2].parse::()?; + let proof_bytes_total = fields[3].parse::()?; + let prover_time_total = duration_from_nanos(fields[4].parse::()?)?; + let verifier_time_total = duration_from_nanos(fields[5].parse::()?)?; + let max_peak_rss_bytes = parse_optional_usize(fields[6])?; + let max_peak_delta_bytes = parse_optional_usize(fields[7])?; + + Ok(( + mode, + ModeMetrics { + samples, + proof_bytes_total, + prover_time_total, + verifier_time_total, + max_peak_rss_bytes, + max_peak_delta_bytes, + }, + )) + } + + fn duration_from_nanos(nanos: u128) -> BenchResult { + let nanos = u64::try_from(nanos)?; + Ok(Duration::from_nanos(nanos)) + } + + fn machine_optional_usize(value: Option) -> String { + value.map_or_else(|| "-".to_string(), |value| value.to_string()) + } + + fn parse_optional_usize(value: &str) -> BenchResult> { + if value == "-" { + return Ok(None); + } + Ok(Some(value.parse::()?)) + } + + fn format_duration(duration: Duration) -> String { + format!("{:.2} ms", duration.as_secs_f64() * 1_000.0) + } + + fn format_memory(bytes: Option) -> String { + bytes.map_or_else( + || "unavailable".to_string(), + |bytes| format!("{:.2} MiB", bytes as f64 / (1024.0 * 1024.0)), + ) + } + + fn child_mode() -> BenchResult> { + let Some(raw) = std::env::var_os(CHILD_MODE_ENV) else { + return Ok(None); + }; + let raw = raw.into_string().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{CHILD_MODE_ENV} must be valid UTF-8"), + ) + })?; + Ok(Some(Mode::parse(&raw)?)) + } + + fn sample_count() -> BenchResult { + let Some(raw) = std::env::var_os(SAMPLES_ENV) else { + return Ok(DEFAULT_SAMPLES); + }; + let raw = raw.into_string().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{SAMPLES_ENV} must be valid UTF-8"), + ) + })?; + let samples = raw.parse::()?; + if samples == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{SAMPLES_ENV} must be nonzero"), + ) + .into()); + } + Ok(samples) + } +} + +#[cfg(feature = "zk")] +fn main() -> Result<(), Box> { + profile::main() +} 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..9b53609f78 100644 --- a/crates/jolt-hyperkzg/fuzz/fuzz_targets/commit_open_verify.rs +++ b/crates/jolt-hyperkzg/fuzz/fuzz_targets/commit_open_verify.rs @@ -33,7 +33,7 @@ fuzz_target!(|data: &[u8]| { let point: Vec = (0..num_vars).map(|_| Fr::random(&mut rng)).collect(); let eval = poly.evaluate(&point); - let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); + let (commitment, _) = TestScheme::commit(poly.evaluations(), &pk); let mut pt = Blake2bTranscript::new(b"fuzz"); let proof = diff --git a/crates/jolt-hyperkzg/fuzz/fuzz_targets/tampered_proof.rs b/crates/jolt-hyperkzg/fuzz/fuzz_targets/tampered_proof.rs index 1363e2487c..3d71efd224 100644 --- a/crates/jolt-hyperkzg/fuzz/fuzz_targets/tampered_proof.rs +++ b/crates/jolt-hyperkzg/fuzz/fuzz_targets/tampered_proof.rs @@ -35,7 +35,7 @@ fuzz_target!(|data: &[u8]| { let point: Vec = (0..num_vars).map(|_| Fr::random(&mut rng)).collect(); let eval = poly.evaluate(&point); - let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); + let (commitment, _) = TestScheme::commit(poly.evaluations(), &pk); let mut pt = Blake2bTranscript::new(b"fuzz-tamper"); let proof = @@ -46,13 +46,19 @@ fuzz_target!(|data: &[u8]| { match data[0] % 3 { 0 => { // Tamper evaluation entries (exercises folding consistency checks) - let tamper_row = (data[1] as usize) % tampered.v.len(); - let tamper_col = (data[2] as usize) % tampered.v[tamper_row].len(); + let Some(v) = tampered.clear_evaluations_mut() else { + return; + }; + let proof_v = proof + .clear_evaluations() + .expect("transparent proof must have clear evaluations"); + let tamper_row = (data[1] as usize) % v.len(); + let tamper_col = (data[2] as usize) % v[tamper_row].len(); let tamper_val = Fr::from_bytes(&data[3..]); - if tamper_val == proof.v[tamper_row][tamper_col] { + if tamper_val == proof_v[tamper_row][tamper_col] { return; } - tampered.v[tamper_row][tamper_col] = tamper_val; + v[tamper_row][tamper_col] = tamper_val; } 1 => { // Tamper intermediate commitments (exercises pairing check) diff --git a/crates/jolt-hyperkzg/fuzz/fuzz_targets/wrong_eval.rs b/crates/jolt-hyperkzg/fuzz/fuzz_targets/wrong_eval.rs index 4c15d1af2f..2ea9716fac 100644 --- a/crates/jolt-hyperkzg/fuzz/fuzz_targets/wrong_eval.rs +++ b/crates/jolt-hyperkzg/fuzz/fuzz_targets/wrong_eval.rs @@ -40,7 +40,7 @@ fuzz_target!(|data: &[u8]| { return; } - let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); + let (commitment, _) = TestScheme::commit(poly.evaluations(), &pk); let mut pt = Blake2bTranscript::new(b"fuzz-wrong-eval"); let proof = diff --git a/crates/jolt-hyperkzg/src/error.rs b/crates/jolt-hyperkzg/src/error.rs index bc6df8af9c..7662d52c11 100644 --- a/crates/jolt-hyperkzg/src/error.rs +++ b/crates/jolt-hyperkzg/src/error.rs @@ -1,5 +1,9 @@ //! Error types for HyperKZG operations. +use std::path::PathBuf; + +use crate::types::{HyperKZGProofKind, HyperKZGSrsKind}; + /// Errors produced by the HyperKZG commitment scheme. #[derive(Debug, thiserror::Error)] pub enum HyperKZGError { @@ -12,15 +16,85 @@ pub enum HyperKZGError { #[error("each evaluation row must have {expected} entries")] WrongEvaluationWidth { expected: usize }, + #[error("expected {expected:?} proof payload, got {got:?}")] + WrongProofPayload { + expected: HyperKZGProofKind, + got: HyperKZGProofKind, + }, + + #[error("expected {expected:?} SRS setup, got {got:?}")] + WrongSrsSetupKind { + expected: HyperKZGSrsKind, + got: HyperKZGSrsKind, + }, + + #[error("ZK HyperKZG requires an SRS with hiding G1 powers")] + MissingZkSrs, + #[error("polynomial must have at least 1 variable")] EmptyPoint, #[error("folding consistency check failed at level {level}")] FoldingConsistencyFailed { level: usize }, + #[error("batched folding consistency check failed")] + BatchedFoldingConsistencyFailed, + #[error("batch KZG pairing check failed")] PairingCheckFailed, - #[error("degenerate Fiat-Shamir challenge: r = 0")] + #[error("degenerate Fiat-Shamir challenge")] DegenerateChallenge, + + #[error("SRS exponent too large: k = {k}")] + SrsExponentTooLarge { k: usize }, + + #[error("SRS capacity must be a power of two for file export, got {capacity}")] + SrsFileCapacityNotPowerOfTwo { capacity: usize }, + + #[error( + "SRS too small for hyperkzg_{k}.srs: supports {supported} evaluations, needs {required}" + )] + SrsFileCapacityMismatch { + k: usize, + supported: usize, + required: usize, + }, + + #[error("SRS file {path} has invalid name {name:?}")] + SrsFileNameMismatch { path: PathBuf, name: String }, + + #[error("SRS file {path} has unsupported version {version}")] + SrsFileVersionUnsupported { path: PathBuf, version: u32 }, + + #[error("SRS file {path} has kind {got:?}, expected {expected:?}")] + SrsFileKindMismatch { + path: PathBuf, + expected: HyperKZGSrsKind, + got: HyperKZGSrsKind, + }, + + #[error("failed to read SRS file {path}: {source}")] + SrsFileRead { + path: PathBuf, + source: std::io::Error, + }, + + #[error("failed to write SRS file {path}: {source}")] + SrsFileWrite { + path: PathBuf, + source: std::io::Error, + }, + + #[error("failed to encode SRS file {path}: {source}")] + SrsFileEncode { + path: PathBuf, + source: bincode::error::EncodeError, + }, + + #[error("failed to decode SRS file {path}: {source}")] + SrsFileDecode { + path: PathBuf, + source: bincode::error::DecodeError, + }, } diff --git a/crates/jolt-hyperkzg/src/kzg.rs b/crates/jolt-hyperkzg/src/kzg.rs index 00dcff0ea1..f731af0c5c 100644 --- a/crates/jolt-hyperkzg/src/kzg.rs +++ b/crates/jolt-hyperkzg/src/kzg.rs @@ -3,13 +3,13 @@ //! These are the building blocks consumed by the HyperKZG protocol. //! All operations are generic over `P: PairingGroup`. +use crate::error::HyperKZGError; +use crate::types::{HyperKZGProverSetup, HyperKZGVerifierSetup}; use jolt_crypto::{JoltGroup, PairingGroup}; use jolt_field::Field; use jolt_transcript::{AppendToTranscript, Transcript}; use num_traits::{One, Zero}; - -use crate::error::HyperKZGError; -use crate::types::{HyperKZGProverSetup, HyperKZGVerifierSetup}; +use rayon::join; /// Commits to a polynomial (given as evaluation/coefficient vector) using MSM against SRS G1 powers. pub(crate) fn kzg_commit( @@ -25,6 +25,32 @@ pub(crate) fn kzg_commit( Ok(P::G1::msm(&setup.g1_powers[..coeffs.len()], coeffs)) } +/// Adds one scalar hiding shift to a transparent KZG commitment. +#[cfg(feature = "zk")] +pub(crate) fn blind_commitment( + commitment: P::G1, + hiding_base: P::G1, + blind: P::ScalarField, +) -> P::G1 { + commitment + hiding_base.scalar_mul(&blind) +} + +/// Commits to a V2 hidden scalar evaluation: +/// `value * G + (rho + u*tau) * H - tau * H_1`. +#[cfg(feature = "zk")] +pub(crate) fn randomized_eval_commitment( + value_base: P::G1, + hiding_base: P::G1, + beta_hiding_base: P::G1, + value: P::ScalarField, + rho: P::ScalarField, + tau: P::ScalarField, + u: P::ScalarField, +) -> P::G1 { + value_base.scalar_mul(&value) + hiding_base.scalar_mul(&(rho + u * tau)) + - beta_hiding_base.scalar_mul(&tau) +} + /// Computes the KZG witness polynomial `h(x) = f(x) / (x - u)`. /// /// Uses Horner's method in reverse: `h[i-1] = f[i] + h[i] * u`. @@ -56,6 +82,23 @@ pub(crate) fn eval_univariate(coeffs: &[F], u: F) -> F { result } +/// Computes the transparent KZG witness commitment for one polynomial at `u`. +#[cfg(feature = "zk")] +pub(crate) fn kzg_witness_commitment( + coeffs: &[P::ScalarField], + u: P::ScalarField, + setup: &HyperKZGProverSetup

, +) -> Result { + let witness = compute_witness_polynomial::(coeffs, u); + if setup.g1_powers.len() < witness.len() { + return Err(HyperKZGError::SrsTooSmall { + have: setup.g1_powers.len(), + need: witness.len(), + }); + } + Ok(P::G1::msm(&setup.g1_powers[..witness.len()], &witness)) +} + /// Batch KZG opening: commits to witness polynomials for each evaluation point. /// /// Given polynomials `f[0..k]` and evaluation points `u[0..t]`, computes: @@ -79,8 +122,16 @@ where let k = f.len(); // Compute evaluations v[t][j] = f_j(u_t) - let v: [Vec; 3] = - (*u).map(|ui| f.iter().map(|fj| eval_univariate(fj, ui)).collect()); + let eval_row = |u_i| { + f.iter() + .map(|fj| eval_univariate(fj, u_i)) + .collect::>() + }; + let (v_r, (v_neg_r, v_r2)) = join( + || eval_row(u[0]), + || join(|| eval_row(u[1]), || eval_row(u[2])), + ); + let v = [v_r, v_neg_r, v_r2]; // Absorb all evaluations into transcript for row in &v { @@ -103,10 +154,15 @@ where } // Compute witness polynomials and commit - let w: [P::G1; 3] = (*u).map(|ui| { - let h = compute_witness_polynomial::(&b_poly, ui); + let witness = |u_i| { + let h = compute_witness_polynomial::(&b_poly, u_i); P::G1::msm(&setup.g1_powers[..h.len()], &h) - }); + }; + let (w_r, (w_neg_r, w_r2)) = join( + || witness(u[0]), + || join(|| witness(u[1]), || witness(u[2])), + ); + let w = [w_r, w_neg_r, w_r2]; // Absorb witness commitments and mirror the verifier's `d_0` challenge // to keep prover/verifier transcripts in sync. diff --git a/crates/jolt-hyperkzg/src/lib.rs b/crates/jolt-hyperkzg/src/lib.rs index 532f02d01e..da515bbc04 100644 --- a/crates/jolt-hyperkzg/src/lib.rs +++ b/crates/jolt-hyperkzg/src/lib.rs @@ -18,11 +18,24 @@ //! - Phase 3: Batch KZG opening of all intermediate polynomials at three points. //! 3. **Verify**: Check evaluation consistency across the three evaluation vectors, //! then batch KZG pairing check. +//! +//! # SRS handling +//! +//! HyperKZG uses a structured reference string with a KZG trapdoor `beta`. +//! Production runtimes should import ceremony-generated files named +//! `hyperkzg_{k}.srs`, where `k` is the exponent for `2^k` supported +//! evaluations. `setup` and `setup_from_secret` are for tests or trusted setup +//! tooling; live proving/verifying code should use `read_srs_file` or +//! `read_srs_from_dir` so it never observes `beta`. pub mod error; pub mod kzg; pub mod scheme; +mod setup; pub mod types; pub use scheme::HyperKZGScheme; -pub use types::{HyperKZGCommitment, HyperKZGProof, HyperKZGProverSetup, HyperKZGVerifierSetup}; +pub use types::{ + HyperKZGCommitment, HyperKZGOpeningHint, HyperKZGProof, HyperKZGProofKind, + HyperKZGProofPayload, HyperKZGProverSetup, HyperKZGSrsKind, HyperKZGVerifierSetup, +}; diff --git a/crates/jolt-hyperkzg/src/scheme.rs b/crates/jolt-hyperkzg/src/scheme.rs index fd9d8d846f..32dd351975 100644 --- a/crates/jolt-hyperkzg/src/scheme.rs +++ b/crates/jolt-hyperkzg/src/scheme.rs @@ -10,24 +10,70 @@ use std::marker::PhantomData; -use jolt_crypto::{Commitment, DeriveSetup, JoltGroup, PairingGroup, PedersenSetup}; -use jolt_field::{FromPrimitiveInt, RandomSampling}; +use jolt_crypto::{Commitment, JoltGroup, PairingGroup}; +use jolt_field::FromPrimitiveInt; +#[cfg(feature = "zk")] +use jolt_field::Invertible; +#[cfg(feature = "zk")] +use jolt_field::RandomSampling; +#[cfg(feature = "zk")] +use jolt_openings::ZkOpeningScheme; use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme, OpeningsError}; -use jolt_poly::Polynomial; +use jolt_poly::{MultilinearPoly, Polynomial}; use jolt_transcript::{AppendToTranscript, Label, LabelWithCount, Transcript}; use num_traits::{One, Zero}; +#[cfg(feature = "zk")] +use rand_core::{RngCore, SeedableRng}; use rayon::prelude::*; use serde::{Deserialize, Serialize}; use crate::error::HyperKZGError; use crate::kzg::{self, kzg_open_batch, kzg_verify_batch}; -use crate::types::{HyperKZGCommitment, HyperKZGProof, HyperKZGProverSetup, HyperKZGVerifierSetup}; +use crate::types::{ + HyperKZGCommitment, HyperKZGOpeningHint, HyperKZGProof, HyperKZGProofKind, HyperKZGProverSetup, + HyperKZGVerifierSetup, +}; +#[cfg(feature = "zk")] +use crate::types::{ + HyperKZGHiddenEvaluationCommitments, HyperKZGProofPayload, HyperKZGZkOpenOutput, +}; + +#[cfg(feature = "zk")] +type HyperKZGTauRows

= [Vec<

::ScalarField>; 3]; + +trait HyperKZGMode { + const PROOF_KIND: HyperKZGProofKind; +} + +struct Transparent; + +impl HyperKZGMode for Transparent { + const PROOF_KIND: HyperKZGProofKind = HyperKZGProofKind::Clear; +} + +#[cfg(feature = "zk")] +struct Zk; + +#[cfg(feature = "zk")] +impl HyperKZGMode for Zk { + const PROOF_KIND: HyperKZGProofKind = HyperKZGProofKind::Zk; +} + +#[cfg(feature = "zk")] +#[derive(Clone, Copy)] +struct V2Bases { + value: P::G1, + hiding: P::G1, + beta_hiding: P::G1, +} /// HyperKZG multilinear polynomial commitment scheme. /// /// Generic over `P: PairingGroup`. Implements [`CommitmentScheme`] and /// [`AdditivelyHomomorphic`] from `jolt-openings`. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(bound = "")] + pub struct HyperKZGScheme { #[serde(skip)] _phantom: PhantomData

, @@ -38,46 +84,6 @@ where P::ScalarField: AppendToTranscript, P::G1: AppendToTranscript, { - /// Generates an SRS from a random generator and secret scalar. - /// - /// `max_degree` is the maximum polynomial length (number of evaluations). - /// The SRS will contain `max_degree + 1` G1 powers and 2 G2 powers. - pub fn setup( - rng: &mut R, - max_degree: usize, - g1: P::G1, - g2: P::G2, - ) -> HyperKZGProverSetup

{ - let beta = P::ScalarField::random(rng); - Self::setup_from_secret(beta, max_degree, g1, g2) - } - - /// Generates SRS from a known secret. - /// - /// WARNING: this is only appropriate for deterministic tests or trusted - /// setup tooling that destroys `beta`; anyone who knows `beta` can break - /// KZG binding. - pub fn setup_from_secret( - beta: P::ScalarField, - max_degree: usize, - g1: P::G1, - g2: P::G2, - ) -> HyperKZGProverSetup

{ - let mut g1_powers = Vec::with_capacity(max_degree + 1); - let mut cur = g1; - for _ in 0..=max_degree { - g1_powers.push(cur); - cur = cur.scalar_mul(&beta); - } - - let g2_powers = vec![g2, g2.scalar_mul(&beta)]; - - HyperKZGProverSetup { - g1_powers, - g2_powers, - } - } - /// Phase 1 of the HyperKZG protocol: fold the multilinear polynomial. /// /// Given polynomial $P$ with $2^\ell$ evaluations and opening point @@ -109,6 +115,54 @@ where polys } + fn dense_evaluations + ?Sized>( + poly: &S, + ) -> Vec { + let mut evaluations = Vec::with_capacity(1 << poly.num_vars()); + poly.for_each_row(poly.num_vars(), &mut |_, row| { + evaluations.extend_from_slice(row); + }); + evaluations + } + + fn commit_with_mode( + poly: &S, + setup: &HyperKZGProverSetup

, + ) -> (HyperKZGCommitment

, HyperKZGOpeningHint

) + where + M: HyperKZGMode, + S: MultilinearPoly + ?Sized, + { + let evaluations = Self::dense_evaluations(poly); + #[cfg(not(feature = "zk"))] + { + debug_assert_eq!(M::PROOF_KIND, HyperKZGProofKind::Clear); + let point = kzg::kzg_commit::

(&evaluations, setup) + .expect("SRS must be large enough for the polynomial"); + (HyperKZGCommitment { point }, HyperKZGOpeningHint::clear()) + } + #[cfg(feature = "zk")] + { + match M::PROOF_KIND { + HyperKZGProofKind::Clear => { + let point = kzg::kzg_commit::

(&evaluations, setup) + .expect("SRS must be large enough for the polynomial"); + (HyperKZGCommitment { point }, HyperKZGOpeningHint::clear()) + } + HyperKZGProofKind::Zk => { + let (hiding_base, _) = + Self::zk_hiding_bases(setup).expect("ZK SRS must contain hiding bases"); + let mut rng = Self::zk_rng(); + let blind = P::ScalarField::random(&mut rng); + let transparent = kzg::kzg_commit::

(&evaluations, setup) + .expect("SRS must be large enough for the polynomial"); + let point = kzg::blind_commitment::

(transparent, hiding_base, blind); + (HyperKZGCommitment { point }, HyperKZGOpeningHint::zk(blind)) + } + } + } + } + /// Full HyperKZG opening proof. #[tracing::instrument(skip_all, name = "HyperKZG::open")] pub fn open>( @@ -117,6 +171,20 @@ where point: &[P::ScalarField], transcript: &mut T, ) -> Result, HyperKZGError> { + Self::open_with_mode::(setup, evals, point, transcript) + } + + fn open_with_mode( + setup: &HyperKZGProverSetup

, + evals: &[P::ScalarField], + point: &[P::ScalarField], + transcript: &mut T, + ) -> Result, HyperKZGError> + where + M: HyperKZGMode, + T: Transcript, + { + debug_assert_eq!(M::PROOF_KIND, HyperKZGProofKind::Clear); let ell = point.len(); if ell == 0 { return Err(HyperKZGError::EmptyPoint); @@ -145,7 +213,292 @@ where // Phase 3: batch open all polynomials at the three points let (w, v) = kzg_open_batch::(&polys, &u, setup, transcript); - Ok(HyperKZGProof { com, w, v }) + Ok(HyperKZGProof::clear(com, w, v)) + } + + #[cfg(feature = "zk")] + fn combine_polys_with_powers( + polys: &[Vec], + powers: &[P::ScalarField], + ) -> Vec { + let mut combined = vec![P::ScalarField::zero(); polys[0].len()]; + for (poly, &power) in polys.iter().zip(powers) { + for (acc, &coeff) in combined.iter_mut().zip(poly) { + *acc += power * coeff; + } + } + combined + } + + #[cfg(feature = "zk")] + fn combine_scalars_with_powers( + scalars: &[P::ScalarField], + powers: &[P::ScalarField], + ) -> P::ScalarField { + scalars + .iter() + .zip(powers) + .map(|(&scalar, &power)| scalar * power) + .fold(P::ScalarField::zero(), |acc, value| acc + value) + } + + #[cfg(feature = "zk")] + fn zk_rng() -> rand_chacha::ChaCha20Rng { + let mut seed = ::Seed::default(); + rand_core::OsRng.fill_bytes(&mut seed); + rand_chacha::ChaCha20Rng::from_seed(seed) + } + + #[cfg(feature = "zk")] + fn zk_hiding_bases(setup: &HyperKZGProverSetup

) -> Result<(P::G1, P::G1), HyperKZGError> { + let hiding_g1_sequence = setup + .hiding_g1_sequence + .as_ref() + .ok_or(HyperKZGError::MissingZkSrs)?; + if hiding_g1_sequence.len() < 2 { + return Err(HyperKZGError::SrsTooSmall { + have: hiding_g1_sequence.len(), + need: 2, + }); + } + Ok((hiding_g1_sequence[0], hiding_g1_sequence[1])) + } + + #[cfg(feature = "zk")] + fn append_zk_opening_prefix( + point: &[P::ScalarField], + y_out: &P::G1, + com: &[P::G1], + transcript: &mut T, + ) -> P::ScalarField + where + T: Transcript, + { + transcript.append(&LabelWithCount(b"hyperkzg_zk_point", point.len() as u64)); + for value in point { + value.append_to_transcript(transcript); + } + transcript.append(&Label(b"hyperkzg_zk_y_out")); + y_out.append_to_transcript(transcript); + transcript.append(&LabelWithCount(b"hyperkzg_zk_fold_com", com.len() as u64)); + for commitment in com { + commitment.append_to_transcript(transcript); + } + transcript.append(&Label(b"hyperkzg_zk_gemini")); + transcript.challenge() + } + + #[cfg(feature = "zk")] + fn append_zk_eval_commitments( + y: &[Vec; 3], + transcript: &mut T, + ) -> (P::ScalarField, P::ScalarField) + where + T: Transcript, + { + for (label, row) in [ + b"hyperkzg_zk_eval_r".as_slice(), + b"hyperkzg_zk_eval_neg_r".as_slice(), + b"hyperkzg_zk_eval_r2".as_slice(), + ] + .into_iter() + .zip(y) + { + transcript.append(&LabelWithCount(label, row.len() as u64)); + for commitment in row { + commitment.append_to_transcript(transcript); + } + } + transcript.append(&Label(b"hyperkzg_zk_gemini_batch")); + let alpha = transcript.challenge(); + transcript.append(&Label(b"hyperkzg_zk_eval_batch")); + let q = transcript.challenge(); + (alpha, q) + } + + #[cfg(feature = "zk")] + fn append_zk_witnesses(w: &[P::G1; 3], transcript: &mut T) -> P::ScalarField + where + T: Transcript, + { + for (label, witness) in [ + b"hyperkzg_zk_wit_r".as_slice(), + b"hyperkzg_zk_wit_nr".as_slice(), + b"hyperkzg_zk_wit_r2".as_slice(), + ] + .into_iter() + .zip(w) + { + transcript.append(&Label(label)); + witness.append_to_transcript(transcript); + } + transcript.append(&Label(b"hyperkzg_zk_wit_batch")); + transcript.challenge() + } + + #[cfg(feature = "zk")] + fn solve_v2_fold_randomizers( + r: P::ScalarField, + xi: P::ScalarField, + rho_i: P::ScalarField, + next_a: P::ScalarField, + next_b: P::ScalarField, + ) -> Result<(P::ScalarField, P::ScalarField), HyperKZGError> { + let one_minus_xi = P::ScalarField::one() - xi; + let denom_pos = r * one_minus_xi + xi; + let denom_neg = xi - r * one_minus_xi; + let denom_pos_inv = denom_pos + .inverse() + .ok_or(HyperKZGError::DegenerateChallenge)?; + let denom_neg_inv = denom_neg + .inverse() + .ok_or(HyperKZGError::DegenerateChallenge)?; + + let base = next_a - one_minus_xi * rho_i; + let tau_pos = (base - r * next_b) * denom_pos_inv; + let tau_neg = (base + r * next_b) * denom_neg_inv; + Ok((tau_pos, tau_neg)) + } + + #[cfg(feature = "zk")] + fn v2_hidden_evaluation_rows( + polys: &[Vec], + point: &[P::ScalarField], + rho: &[P::ScalarField], + lambda: P::ScalarField, + u: &[P::ScalarField; 3], + bases: V2Bases

, + rng: &mut rand_chacha::ChaCha20Rng, + ) -> Result<(HyperKZGHiddenEvaluationCommitments

, HyperKZGTauRows

), HyperKZGError> { + let ell = polys.len(); + debug_assert_eq!(point.len(), ell); + debug_assert_eq!(rho.len(), ell); + + let tau_sq: Vec = (0..ell).map(|_| P::ScalarField::random(rng)).collect(); + let mut tau_pos = vec![P::ScalarField::zero(); ell]; + let mut tau_neg = vec![P::ScalarField::zero(); ell]; + + for i in 0..ell { + let (next_a, next_b) = if i + 1 < ell { + (rho[i + 1] + u[2] * tau_sq[i + 1], -tau_sq[i + 1]) + } else { + (lambda, P::ScalarField::zero()) + }; + let xi = point[ell - i - 1]; + let (pos, neg) = Self::solve_v2_fold_randomizers(u[0], xi, rho[i], next_a, next_b)?; + tau_pos[i] = pos; + tau_neg[i] = neg; + } + + let tau = [tau_pos, tau_neg, tau_sq]; + let row = |u_i, tau_i: &[P::ScalarField]| { + polys + .iter() + .zip(rho) + .zip(tau_i) + .map(|((poly, &rho_i), &tau_i_u)| { + let value = kzg::eval_univariate(poly, u_i); + kzg::randomized_eval_commitment::

( + bases.value, + bases.hiding, + bases.beta_hiding, + value, + rho_i, + tau_i_u, + u_i, + ) + }) + .collect() + }; + let (row_r, (row_neg_r, row_r2)) = rayon::join( + || row(u[0], &tau[0]), + || rayon::join(|| row(u[1], &tau[1]), || row(u[2], &tau[2])), + ); + Ok(([row_r, row_neg_r, row_r2], tau)) + } + + #[cfg(feature = "zk")] + fn open_zk_inner( + setup: &HyperKZGProverSetup

, + evals: &[P::ScalarField], + point: &[P::ScalarField], + claimed_eval: P::ScalarField, + rho_0: P::ScalarField, + transcript: &mut T, + ) -> Result, HyperKZGError> + where + T: Transcript, + { + let ell = point.len(); + if ell == 0 { + return Err(HyperKZGError::EmptyPoint); + } + let n = evals.len(); + assert_eq!(n, 1 << ell, "evaluation count must be 2^ell"); + + let (hiding_base, beta_hiding_base) = Self::zk_hiding_bases(setup)?; + let mut rng = Self::zk_rng(); + + let polys = Self::fold_polynomials(evals, point); + let output_blind = P::ScalarField::random(&mut rng); + let y_out = + setup.g1_powers[0].scalar_mul(&claimed_eval) + hiding_base.scalar_mul(&output_blind); + + let mut rho = vec![P::ScalarField::zero(); ell]; + rho[0] = rho_0; + for blind in &mut rho[1..] { + *blind = P::ScalarField::random(&mut rng); + } + + let com: Vec = polys[1..] + .par_iter() + .zip(&rho[1..]) + .map(|(poly, &blind)| { + let transparent = + kzg::kzg_commit::

(poly, setup).expect("SRS large enough for intermediate"); + kzg::blind_commitment::

(transparent, hiding_base, blind) + }) + .collect(); + + let r = Self::append_zk_opening_prefix(point, &y_out, &com, transcript); + if r.is_zero() { + return Err(HyperKZGError::DegenerateChallenge); + } + let u = [r, -r, r * r]; + + let (y, tau) = Self::v2_hidden_evaluation_rows( + &polys, + point, + &rho, + output_blind, + &u, + V2Bases { + value: setup.g1_powers[0], + hiding: hiding_base, + beta_hiding: beta_hiding_base, + }, + &mut rng, + )?; + let (_, q) = Self::append_zk_eval_commitments(&y, transcript); + let q_powers = kzg::challenge_powers(q, polys.len()); + let batched_poly = Self::combine_polys_with_powers(&polys, &q_powers); + + let tau_q = [ + Self::combine_scalars_with_powers(&tau[0], &q_powers), + Self::combine_scalars_with_powers(&tau[1], &q_powers), + Self::combine_scalars_with_powers(&tau[2], &q_powers), + ]; + let witness = |i: usize| { + let transparent = kzg::kzg_witness_commitment::

(&batched_poly, u[i], setup) + .expect("SRS large enough for witness"); + transparent + hiding_base.scalar_mul(&tau_q[i]) + }; + let (w_r, (w_neg_r, w_r2)) = + rayon::join(|| witness(0), || rayon::join(|| witness(1), || witness(2))); + let w = [w_r, w_neg_r, w_r2]; + let _d = Self::append_zk_witnesses(&w, transcript); + + Ok((HyperKZGProof::zk(com, w, y, y_out), y_out, output_blind)) } /// HyperKZG verification. @@ -171,7 +524,12 @@ where } // Validate inner evaluation widths before mutating the transcript. - let v = &proof.v; + let v = proof + .clear_evaluations() + .ok_or_else(|| HyperKZGError::WrongProofPayload { + expected: HyperKZGProofKind::Clear, + got: proof.payload_kind(), + })?; if v[0].len() != ell || v[1].len() != ell || v[2].len() != ell { return Err(HyperKZGError::WrongEvaluationWidth { expected: ell }); } @@ -217,30 +575,119 @@ where } // Batch KZG pairing check - if !kzg_verify_batch::(vk, &com, &proof.w, &u, &proof.v, transcript) { + if !kzg_verify_batch::(vk, &com, &proof.w, &u, v, transcript) { return Err(HyperKZGError::PairingCheckFailed); } Ok(()) } -} -/// # Security note -/// -/// Uses KZG SRS powers as Pedersen generators — Pedersen binding shares the -/// KZG trapdoor `beta`. Both are sound once `beta` is destroyed, but the two -/// schemes do not have independent security assumptions. -impl DeriveSetup> for PedersenSetup { - fn derive(source: &HyperKZGProverSetup

, capacity: usize) -> Self { - assert!( - source.g1_powers.len() > capacity, - "SRS has {} G1 powers, need at least {} (capacity + 1 for blinding)", - source.g1_powers.len(), - capacity + 1, - ); - let message_generators = source.g1_powers[..capacity].to_vec(); - let blinding_generator = source.g1_powers[capacity]; - PedersenSetup::new(message_generators, blinding_generator) + #[cfg(feature = "zk")] + fn verify_zk_inner( + vk: &HyperKZGVerifierSetup

, + commitment: &HyperKZGCommitment

, + point: &[P::ScalarField], + proof: &HyperKZGProof

, + transcript: &mut T, + ) -> Result + where + T: Transcript, + { + let ell = point.len(); + if ell == 0 { + return Err(HyperKZGError::EmptyPoint); + } + if proof.com.len() + 1 != ell { + return Err(HyperKZGError::WrongCommitmentCount { + expected: ell - 1, + got: proof.com.len(), + }); + } + + let _ = vk.hiding_g1.ok_or(HyperKZGError::MissingZkSrs)?; + let (y, y_out) = match &proof.payload { + HyperKZGProofPayload::Zk { y, y_out } => (y, y_out), + HyperKZGProofPayload::Clear { .. } => { + return Err(HyperKZGError::WrongProofPayload { + expected: HyperKZGProofKind::Zk, + got: proof.payload_kind(), + }); + } + }; + if y[0].len() != ell || y[1].len() != ell || y[2].len() != ell { + return Err(HyperKZGError::WrongEvaluationWidth { expected: ell }); + } + + let r = Self::append_zk_opening_prefix(point, y_out, &proof.com, transcript); + if r.is_zero() { + return Err(HyperKZGError::DegenerateChallenge); + } + let u = [r, -r, r * r]; + + let (alpha, q) = Self::append_zk_eval_commitments(y, transcript); + let alpha_powers = kzg::challenge_powers(alpha, ell); + let q_powers = kzg::challenge_powers(q, ell); + + let two_r = P::ScalarField::from_u64(2) * r; + let mut gemini_bases = Vec::with_capacity(3 * ell); + let mut gemini_scalars = Vec::with_capacity(3 * ell); + for i in 0..ell { + let alpha_i = alpha_powers[i]; + let xi = point[ell - i - 1]; + let r_one_minus_xi = r * (P::ScalarField::one() - xi); + + if i + 1 < ell { + gemini_bases.push(y[2][i + 1]); + } else { + gemini_bases.push(*y_out); + } + gemini_scalars.push(alpha_i * two_r); + gemini_bases.push(y[0][i]); + gemini_scalars.push(-(alpha_i * (r_one_minus_xi + xi))); + gemini_bases.push(y[1][i]); + gemini_scalars.push(-(alpha_i * (r_one_minus_xi - xi))); + } + if !P::G1::msm(&gemini_bases, &gemini_scalars).is_identity() { + return Err(HyperKZGError::BatchedFoldingConsistencyFailed); + } + + let d = Self::append_zk_witnesses(&proof.w, transcript); + let d_powers = [P::ScalarField::one(), d, d * d]; + + let mut com = Vec::with_capacity(ell); + com.push(commitment.point); + com.extend_from_slice(&proof.com); + let commitment_multiplier = d_powers + .iter() + .copied() + .fold(P::ScalarField::zero(), |acc, power| acc + power); + let mut kzg_bases = Vec::with_capacity(4 * ell + 3); + let mut kzg_scalars = Vec::with_capacity(4 * ell + 3); + + for (&commitment_i, &q_i) in com.iter().zip(&q_powers) { + kzg_bases.push(commitment_i); + kzg_scalars.push(q_i * commitment_multiplier); + } + for t in 0..3 { + for (&y_t_i, &q_i) in y[t].iter().zip(&q_powers) { + kzg_bases.push(y_t_i); + kzg_scalars.push(-(d_powers[t] * q_i)); + } + } + for t in 0..3 { + kzg_bases.push(proof.w[t]); + kzg_scalars.push(d_powers[t] * u[t]); + } + + let batched_lhs = P::G1::msm(&kzg_bases, &kzg_scalars); + let batched_rhs = P::G1::msm(&proof.w, &d_powers); + + let result = P::multi_pairing(&[batched_lhs, -batched_rhs], &[vk.g2, vk.beta_g2]); + if !result.is_identity() { + return Err(HyperKZGError::PairingCheckFailed); + } + + Ok(*y_out) } } @@ -258,7 +705,7 @@ where type ProverSetup = HyperKZGProverSetup

; type VerifierSetup = HyperKZGVerifierSetup

; type Polynomial = Polynomial; - type OpeningHint = (); + type OpeningHint = HyperKZGOpeningHint

; type SetupParams = (usize, P::G1, P::G2); fn setup( @@ -279,14 +726,7 @@ where poly: &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 point = kzg::kzg_commit::

(&evaluations, setup) - .expect("SRS must be large enough for the polynomial"); - (HyperKZGCommitment { point }, ()) + Self::commit_with_mode::(poly, setup) } fn open( @@ -342,329 +782,87 @@ where point: P::G1::msm(&bases, scalars), } } -} -#[cfg(test)] -mod tests { - use super::*; - use jolt_crypto::Bn254; - use jolt_field::Fr; - use jolt_poly::Polynomial; - use jolt_transcript::Blake2bTranscript; - use rand_chacha::ChaCha20Rng; - use rand_core::SeedableRng; - - type TestScheme = HyperKZGScheme; - - fn test_setup(max_degree: usize) -> (HyperKZGProverSetup, HyperKZGVerifierSetup) { - let mut rng = ChaCha20Rng::seed_from_u64(0xdead_beef); - 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); - (prover, verifier) - } - - #[test] - fn commit_open_verify_roundtrip() { - for ell in [2, 3, 4, 6, 8] { - let n = 1 << ell; - let mut rng = ChaCha20Rng::seed_from_u64(ell as u64); - let (pk, vk) = test_setup(n); - - let poly = Polynomial::::random(ell, &mut rng); - let point: Vec = (0..ell).map(|_| Fr::random(&mut rng)).collect(); - let eval = poly.evaluate(&point); - - let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - - let mut prover_transcript = Blake2bTranscript::new(b"test"); - let proof = ::open( - &poly, - &point, - eval, - &pk, - None, - &mut prover_transcript, - ); - - let mut verifier_transcript = Blake2bTranscript::new(b"test"); - let result = ::verify( - &commitment, - &point, - eval, - &proof, - &vk, - &mut verifier_transcript, - ); - assert!(result.is_ok(), "ell={ell}: verification failed: {result:?}"); + fn combine_hints(hints: Vec, scalars: &[Self::Field]) -> Self::OpeningHint { + assert_eq!(hints.len(), scalars.len()); + if hints.is_empty() { + return HyperKZGOpeningHint::clear(); } - } - - #[test] - fn wrong_eval_rejects() { - let ell = 4; - let n = 1 << ell; - let mut rng = ChaCha20Rng::seed_from_u64(42); - let (pk, vk) = test_setup(n); - - let poly = Polynomial::::random(ell, &mut rng); - let point: Vec = (0..ell).map(|_| Fr::random(&mut rng)).collect(); - let eval = poly.evaluate(&point); - let wrong_eval = eval + Fr::from_u64(1); - - let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - - let mut prover_transcript = Blake2bTranscript::new(b"test-bad"); - let proof = ::open( - &poly, - &point, - eval, - &pk, - None, - &mut prover_transcript, - ); - - let mut verifier_transcript = Blake2bTranscript::new(b"test-bad"); - let result = ::verify( - &commitment, - &point, - wrong_eval, - &proof, - &vk, - &mut verifier_transcript, - ); - assert!(result.is_err(), "wrong evaluation should be rejected"); - } - - #[test] - fn missing_intermediate_commitment_rejects() { - let ell = 4; - let n = 1 << ell; - let mut rng = ChaCha20Rng::seed_from_u64(43); - let (pk, vk) = test_setup(n); - - let poly = Polynomial::::random(ell, &mut rng); - let point: Vec = (0..ell).map(|_| Fr::random(&mut rng)).collect(); - let eval = poly.evaluate(&point); - - let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - - let mut prover_transcript = Blake2bTranscript::new(b"test-missing-com"); - let mut proof = ::open( - &poly, - &point, - eval, - &pk, - None, - &mut prover_transcript, - ); - let _ = proof.com.pop(); - - let mut verifier_transcript = Blake2bTranscript::new(b"test-missing-com"); - let result = TestScheme::verify( - &vk, - &commitment, - &point, - &eval, - &proof, - &mut verifier_transcript, - ); - assert!(matches!( - result, - Err(HyperKZGError::WrongCommitmentCount { .. }) - )); - } - - #[test] - fn trait_setup_uses_fresh_randomness() { - let g1 = Bn254::g1_generator(); - let g2 = Bn254::g2_generator(); - - let (_pk1, vk1) = ::setup((4, g1, g2)); - let (_pk2, vk2) = ::setup((4, g1, g2)); - - assert_ne!(vk1.beta_g2, vk2.beta_g2); - } - - #[test] - fn tampered_proof_rejects() { - let ell = 4; - let n = 1 << ell; - let mut rng = ChaCha20Rng::seed_from_u64(99); - let (pk, vk) = test_setup(n); - - let poly = Polynomial::::random(ell, &mut rng); - let point: Vec = (0..ell).map(|_| Fr::random(&mut rng)).collect(); - let eval = poly.evaluate(&point); - - let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - - let mut prover_transcript = Blake2bTranscript::new(b"test-tamper"); - let mut proof = ::open( - &poly, - &point, - eval, - &pk, - None, - &mut prover_transcript, - ); - - // Tamper with proof: swap v[0] and v[1] - let v1 = proof.v[1].clone(); - proof.v[0].clone_from(&v1); - - let mut verifier_transcript = Blake2bTranscript::new(b"test-tamper"); - let result = ::verify( - &commitment, - &point, - eval, - &proof, - &vk, - &mut verifier_transcript, - ); - assert!(result.is_err(), "tampered proof should be rejected"); - } - - #[test] - fn combine_is_homomorphic() { - let ell = 3; - let n = 1 << ell; - let mut rng = ChaCha20Rng::seed_from_u64(300); - let (pk, _vk) = test_setup(n); - - let poly_a = Polynomial::::random(ell, &mut rng); - let poly_b = Polynomial::::random(ell, &mut rng); - - let (ca, ()) = TestScheme::commit(poly_a.evaluations(), &pk); - let (cb, ()) = TestScheme::commit(poly_b.evaluations(), &pk); - - let sum_evals: Vec = poly_a - .evaluations() - .iter() - .zip(poly_b.evaluations().iter()) - .map(|(a, b)| *a + *b) - .collect(); - let (c_sum_direct, ()) = TestScheme::commit(&sum_evals, &pk); - - let c_sum_combined = TestScheme::combine(&[ca, cb], &[Fr::from_u64(1), Fr::from_u64(1)]); + let zk_hint_count = hints.iter().filter(|hint| hint.is_zk()).count(); + if zk_hint_count == 0 { + return HyperKZGOpeningHint::clear(); + } assert_eq!( - c_sum_direct, c_sum_combined, - "combine([1,1]) must match commitment to sum" + zk_hint_count, + hints.len(), + "cannot combine mixed transparent and ZK HyperKZG opening hints" ); - } - - #[test] - fn combine_with_scalars() { - let ell = 3; - let n = 1 << ell; - let mut rng = ChaCha20Rng::seed_from_u64(400); - let (pk, _vk) = test_setup(n); - let poly_a = Polynomial::::random(ell, &mut rng); - let poly_b = Polynomial::::random(ell, &mut rng); - let s_a = Fr::random(&mut rng); - let s_b = Fr::random(&mut rng); - - let (ca, ()) = TestScheme::commit(poly_a.evaluations(), &pk); - let (cb, ()) = TestScheme::commit(poly_b.evaluations(), &pk); - - let combined_evals: Vec = poly_a - .evaluations() + let blind = hints .iter() - .zip(poly_b.evaluations().iter()) - .map(|(a, b)| s_a * *a + s_b * *b) - .collect(); - let (c_direct, ()) = TestScheme::commit(&combined_evals, &pk); + .zip(scalars) + .map(|(hint, &scalar)| scalar * hint.blind.expect("ZK hint must contain a blind")) + .fold(P::ScalarField::zero(), |acc, value| acc + value); - let c_combined = TestScheme::combine(&[ca, cb], &[s_a, s_b]); - - assert_eq!(c_direct, c_combined); + HyperKZGOpeningHint { blind: Some(blind) } } +} - #[test] - fn open_verify_with_random_points() { - let mut rng = ChaCha20Rng::seed_from_u64(0xcafe); - - for _ in 0..5 { - let ell = 4; - let n = 1 << ell; - let (pk, vk) = test_setup(n); - - let poly = Polynomial::::random(ell, &mut rng); - let point: Vec = (0..ell).map(|_| Fr::random(&mut rng)).collect(); - let eval = poly.evaluate(&point); - - let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - - let mut pt = Blake2bTranscript::new(b"rand-test"); - let proof = - ::open(&poly, &point, eval, &pk, None, &mut pt); +#[cfg(feature = "zk")] +impl ZkOpeningScheme for HyperKZGScheme

+where + P::ScalarField: AppendToTranscript, + P::G1: AppendToTranscript, +{ + type HidingCommitment = P::G1; + type Blind = P::ScalarField; - let mut vt = Blake2bTranscript::new(b"rand-test"); - ::verify( - &commitment, - &point, - eval, - &proof, - &vk, - &mut vt, - ) - .expect("random instance should verify"); - } + fn commit_zk + ?Sized>( + poly: &S, + setup: &Self::ProverSetup, + ) -> (Self::Output, Self::OpeningHint) { + Self::commit_with_mode::(poly, setup) } - #[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); - - assert_eq!( - as VectorCommitment>::capacity(&vc_setup), - capacity, - ); - - // Commit and verify a small vector. - let values = vec![Fr::one(), Fr::from_u64(2), Fr::from_u64(3)]; - let blinding = Fr::from_u64(42); - let commitment = as VectorCommitment>::commit( - &vc_setup, &values, &blinding, - ); - assert!( - as VectorCommitment>::verify( - &vc_setup, - &commitment, - &values, - &blinding, - ) - ); + #[tracing::instrument(skip_all, name = "HyperKZG::open_zk")] + fn open_zk( + poly: &Self::Polynomial, + point: &[Self::Field], + eval: Self::Field, + setup: &Self::ProverSetup, + hint: Self::OpeningHint, + transcript: &mut impl Transcript, + ) -> (Self::Proof, Self::HidingCommitment, Self::Blind) { + let blind = hint + .into_zk_blind() + .expect("ZK HyperKZG opening requires a ZK opening hint"); + Self::open_zk_inner(setup, poly.evaluations(), point, eval, blind, transcript) + .expect("HyperKZG ZK open should not fail with valid inputs") } - #[test] - fn trivial_polynomial() { - // 1-variable polynomial: [a, b] - let ell = 1; - let n = 1 << ell; - let mut rng = ChaCha20Rng::seed_from_u64(777); - let (pk, vk) = test_setup(n); - - let poly = Polynomial::::random(ell, &mut rng); - let point: Vec = (0..ell).map(|_| Fr::random(&mut rng)).collect(); - let eval = poly.evaluate(&point); - - let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - - let mut pt = Blake2bTranscript::new(b"trivial"); - let proof = ::open(&poly, &point, eval, &pk, None, &mut pt); + #[tracing::instrument(skip_all, name = "HyperKZG::verify_zk")] + fn verify_zk( + commitment: &Self::Output, + point: &[Self::Field], + proof: &Self::Proof, + setup: &Self::VerifierSetup, + transcript: &mut impl Transcript, + ) -> Result { + Self::verify_zk_inner(setup, commitment, point, proof, transcript) + .map_err(|_| OpeningsError::VerificationFailed) + } - let mut vt = Blake2bTranscript::new(b"trivial"); - ::verify(&commitment, &point, eval, &proof, &vk, &mut vt) - .expect("trivial polynomial should verify"); + fn bind_zk_opening_inputs( + transcript: &mut impl Transcript, + point: &[Self::Field], + hiding_commitment: &Self::HidingCommitment, + ) { + transcript.append(&LabelWithCount(b"hyperkzg_zk_point", point.len() as u64)); + for p in point { + p.append_to_transcript(transcript); + } + transcript.append(&Label(b"hyperkzg_zk_eval_com")); + hiding_commitment.append_to_transcript(transcript); } } diff --git a/crates/jolt-hyperkzg/src/setup.rs b/crates/jolt-hyperkzg/src/setup.rs new file mode 100644 index 0000000000..d92c9d2baf --- /dev/null +++ b/crates/jolt-hyperkzg/src/setup.rs @@ -0,0 +1,434 @@ +//! Setup and SRS file handling for HyperKZG. + +use std::path::{Path, PathBuf}; + +use jolt_crypto::{DeriveSetup, JoltGroup, PairingGroup, PedersenSetup}; +use jolt_field::RandomSampling; +use serde::{de::DeserializeOwned, Serialize}; + +use crate::error::HyperKZGError; +use crate::scheme::HyperKZGScheme; +use crate::types::{ + HyperKZGProverSetup, HyperKZGSrsFile, HyperKZGSrsKind, HYPERKZG_SRS_NAME, HYPERKZG_SRS_VERSION, +}; + +impl HyperKZGScheme

{ + /// Returns the canonical filename for a ceremony-generated HyperKZG SRS. + /// + /// The `k` in `hyperkzg_{k}.srs` is the exponent for the supported + /// multilinear evaluation table length: `hyperkzg_20.srs` supports + /// polynomials with up to `2^20` evaluations. The serialized prover setup + /// stores one additional G1 power internally, following the KZG SRS + /// convention used by this crate. + #[must_use] + pub fn srs_file_name(k: usize) -> String { + format!("hyperkzg_{k}.srs") + } + + /// Returns the canonical filename for a ceremony-generated ZK HyperKZG SRS. + #[cfg(feature = "zk")] + #[must_use] + pub fn zk_srs_file_name(k: usize) -> String { + format!("hyperkzg_zk_{k}.srs") + } + + /// Returns `dir / hyperkzg_{k}.srs`. + #[must_use] + pub fn srs_path(dir: impl AsRef, k: usize) -> PathBuf { + dir.as_ref().join(Self::srs_file_name(k)) + } + + /// Returns `dir / hyperkzg_zk_{k}.srs`. + #[cfg(feature = "zk")] + #[must_use] + pub fn zk_srs_path(dir: impl AsRef, k: usize) -> PathBuf { + dir.as_ref().join(Self::zk_srs_file_name(k)) + } + + pub(crate) fn supported_evaluations(setup: &HyperKZGProverSetup

) -> usize { + setup.g1_powers.len().saturating_sub(1) + } + + fn setup_srs_kind(setup: &HyperKZGProverSetup

) -> HyperKZGSrsKind { + if setup.hiding_g1_sequence.is_some() { + HyperKZGSrsKind::Zk + } else { + HyperKZGSrsKind::Plain + } + } + + fn validate_setup_kind( + setup: &HyperKZGProverSetup

, + expected: HyperKZGSrsKind, + ) -> Result<(), HyperKZGError> { + let got = Self::setup_srs_kind(setup); + if got != expected { + return Err(HyperKZGError::WrongSrsSetupKind { expected, got }); + } + Ok(()) + } + + fn capacity_exponent(capacity: usize) -> Result { + if !capacity.is_power_of_two() { + return Err(HyperKZGError::SrsFileCapacityNotPowerOfTwo { capacity }); + } + Ok(capacity.ilog2() as usize) + } + + fn required_evaluations(k: usize) -> Result { + if k >= usize::BITS as usize { + return Err(HyperKZGError::SrsExponentTooLarge { k }); + } + Ok(1usize << k) + } + + fn validate_srs_capacity( + setup: &HyperKZGProverSetup

, + k: usize, + ) -> Result<(), HyperKZGError> { + let required = Self::required_evaluations(k)?; + let supported = Self::supported_evaluations(setup); + if supported < required { + return Err(HyperKZGError::SrsFileCapacityMismatch { + k, + supported, + required, + }); + } + Ok(()) + } + + fn validate_srs_file( + path: &Path, + file: &HyperKZGSrsFile

, + expected_kind: HyperKZGSrsKind, + ) -> Result<(), HyperKZGError> { + if file.name != HYPERKZG_SRS_NAME { + return Err(HyperKZGError::SrsFileNameMismatch { + path: path.to_path_buf(), + name: file.name.clone(), + }); + } + if file.version != HYPERKZG_SRS_VERSION { + return Err(HyperKZGError::SrsFileVersionUnsupported { + path: path.to_path_buf(), + version: file.version, + }); + } + if file.kind != expected_kind { + return Err(HyperKZGError::SrsFileKindMismatch { + path: path.to_path_buf(), + expected: expected_kind, + got: file.kind, + }); + } + + let required = Self::required_evaluations(file.k)?; + if file.capacity != required { + return Err(HyperKZGError::SrsFileCapacityMismatch { + k: file.k, + supported: file.capacity, + required, + }); + } + + let supported = Self::supported_evaluations(&file.setup); + if supported < file.capacity { + return Err(HyperKZGError::SrsFileCapacityMismatch { + k: file.k, + supported, + required: file.capacity, + }); + } + + Ok(()) + } + + fn read_srs_file_with_kind( + path: impl AsRef, + expected_kind: HyperKZGSrsKind, + ) -> Result, HyperKZGError> + where + P::G1: DeserializeOwned, + P::G2: DeserializeOwned, + { + let path = path.as_ref(); + let bytes = std::fs::read(path).map_err(|source| HyperKZGError::SrsFileRead { + path: path.to_path_buf(), + source, + })?; + let (file, _): (HyperKZGSrsFile

, _) = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).map_err( + |source| HyperKZGError::SrsFileDecode { + path: path.to_path_buf(), + source, + }, + )?; + Self::validate_srs_file(path, &file, expected_kind)?; + Ok(file.setup) + } + + /// Reads a bincode-encoded prover SRS from an explicit path. + /// + /// Production proving code should use this with a ceremony-generated file, + /// not generate `beta` in the proving process. + pub fn read_srs_file(path: impl AsRef) -> Result, HyperKZGError> + where + P::G1: DeserializeOwned, + P::G2: DeserializeOwned, + { + Self::read_srs_file_with_kind(path, HyperKZGSrsKind::Plain) + } + + /// Reads a bincode-encoded ZK prover SRS from an explicit path. + #[cfg(feature = "zk")] + pub fn read_zk_srs_file(path: impl AsRef) -> Result, HyperKZGError> + where + P::G1: DeserializeOwned, + P::G2: DeserializeOwned, + { + Self::read_srs_file_with_kind(path, HyperKZGSrsKind::Zk) + } + + /// Reads `dir / hyperkzg_{k}.srs` and verifies that it supports `2^k` + /// evaluations. + pub fn read_srs_from_dir( + dir: impl AsRef, + k: usize, + ) -> Result, HyperKZGError> + where + P::G1: DeserializeOwned, + P::G2: DeserializeOwned, + { + let setup = Self::read_srs_file(Self::srs_path(dir, k))?; + Self::validate_srs_capacity(&setup, k)?; + Ok(setup) + } + + /// Reads `dir / hyperkzg_zk_{k}.srs` and verifies that it supports `2^k` + /// evaluations. + #[cfg(feature = "zk")] + pub fn read_zk_srs_from_dir( + dir: impl AsRef, + k: usize, + ) -> Result, HyperKZGError> + where + P::G1: DeserializeOwned, + P::G2: DeserializeOwned, + { + let setup = Self::read_zk_srs_file(Self::zk_srs_path(dir, k))?; + Self::validate_srs_capacity(&setup, k)?; + Ok(setup) + } + + /// Writes a bincode-encoded prover SRS to an explicit path. + /// + /// This helper is intended for trusted setup tooling and tests. A + /// production prover should load the ceremony output with + /// [`read_srs_file`](Self::read_srs_file) or + /// [`read_srs_from_dir`](Self::read_srs_from_dir). + pub fn write_srs_file( + setup: &HyperKZGProverSetup

, + path: impl AsRef, + ) -> Result<(), HyperKZGError> + where + P::G1: Serialize, + P::G2: Serialize, + { + Self::write_srs_file_with_kind(setup, path, HyperKZGSrsKind::Plain) + } + + /// Writes a bincode-encoded ZK prover SRS to an explicit path. + #[cfg(feature = "zk")] + pub fn write_zk_srs_file( + setup: &HyperKZGProverSetup

, + path: impl AsRef, + ) -> Result<(), HyperKZGError> + where + P::G1: Serialize, + P::G2: Serialize, + { + Self::write_srs_file_with_kind(setup, path, HyperKZGSrsKind::Zk) + } + + fn write_srs_file_with_kind( + setup: &HyperKZGProverSetup

, + path: impl AsRef, + expected_kind: HyperKZGSrsKind, + ) -> Result<(), HyperKZGError> + where + P::G1: Serialize, + P::G2: Serialize, + { + Self::validate_setup_kind(setup, expected_kind)?; + let path = path.as_ref(); + let capacity = Self::supported_evaluations(setup); + let k = Self::capacity_exponent(capacity)?; + let file = HyperKZGSrsFile { + name: HYPERKZG_SRS_NAME.to_string(), + version: HYPERKZG_SRS_VERSION, + kind: expected_kind, + k, + capacity, + setup: setup.clone(), + }; + let bytes = bincode::serde::encode_to_vec(&file, bincode::config::standard()).map_err( + |source| HyperKZGError::SrsFileEncode { + path: path.to_path_buf(), + source, + }, + )?; + std::fs::write(path, bytes).map_err(|source| HyperKZGError::SrsFileWrite { + path: path.to_path_buf(), + source, + }) + } + + /// Writes `dir / hyperkzg_{k}.srs` after verifying that the setup supports + /// `2^k` evaluations. + pub fn write_srs_to_dir( + setup: &HyperKZGProverSetup

, + dir: impl AsRef, + k: usize, + ) -> Result<(), HyperKZGError> + where + P::G1: Serialize, + P::G2: Serialize, + { + Self::validate_srs_capacity(setup, k)?; + Self::write_srs_file(setup, Self::srs_path(dir, k)) + } + + /// Writes `dir / hyperkzg_zk_{k}.srs` after verifying that the setup + /// supports `2^k` evaluations. + #[cfg(feature = "zk")] + pub fn write_zk_srs_to_dir( + setup: &HyperKZGProverSetup

, + dir: impl AsRef, + k: usize, + ) -> Result<(), HyperKZGError> + where + P::G1: Serialize, + P::G2: Serialize, + { + Self::validate_srs_capacity(setup, k)?; + Self::write_zk_srs_file(setup, Self::zk_srs_path(dir, k)) + } + + /// Generates an SRS from a random generator and secret scalar. + /// + /// WARNING: this is suitable for tests or trusted setup tooling only. A + /// production prover/verifier should load a ceremony-generated + /// `hyperkzg_{k}.srs` file via [`read_srs_file`](Self::read_srs_file) or + /// [`read_srs_from_dir`](Self::read_srs_from_dir), so the live runtime never + /// observes the KZG trapdoor `beta`. + /// + /// `max_degree` is the maximum polynomial length (number of evaluations). + /// The SRS will contain `max_degree + 1` G1 powers and 2 G2 powers. + pub fn setup( + rng: &mut R, + max_degree: usize, + g1: P::G1, + g2: P::G2, + ) -> HyperKZGProverSetup

{ + let beta = P::ScalarField::random(rng); + Self::setup_from_secret(beta, max_degree, g1, g2) + } + + /// Generates SRS from a known secret. + /// + /// WARNING: this is not a production runtime API. It is only appropriate + /// for deterministic tests or trusted setup tooling that destroys `beta`. + /// Anyone who knows `beta` can break KZG binding. Production proving and + /// verifying should import a ceremony-generated `hyperkzg_{k}.srs` file + /// instead. + pub fn setup_from_secret( + beta: P::ScalarField, + max_degree: usize, + g1: P::G1, + g2: P::G2, + ) -> HyperKZGProverSetup

{ + let mut g1_powers = Vec::with_capacity(max_degree + 1); + let mut cur = g1; + for _ in 0..=max_degree { + g1_powers.push(cur); + cur = cur.scalar_mul(&beta); + } + + let g2_powers = vec![g2, g2.scalar_mul(&beta)]; + + HyperKZGProverSetup { + g1_powers, + g2_powers, + hiding_g1_sequence: None, + } + } + + /// Generates a ZK-capable SRS from a random secret scalar. + /// + /// WARNING: this is suitable for tests or trusted setup tooling only. A + /// production prover/verifier should load a ceremony-generated + /// `hyperkzg_zk_{k}.srs` file so the live runtime never observes `beta`. + #[cfg(feature = "zk")] + pub fn setup_zk( + rng: &mut R, + max_degree: usize, + g1: P::G1, + hiding_g1: P::G1, + g2: P::G2, + ) -> HyperKZGProverSetup

{ + let beta = P::ScalarField::random(rng); + Self::setup_zk_from_secret(beta, max_degree, g1, hiding_g1, g2) + } + + /// Generates a ZK-capable SRS from a known secret. + /// + /// WARNING: this is not a production runtime API. It is only appropriate + /// for deterministic tests or trusted setup tooling that destroys `beta`. + #[cfg(feature = "zk")] + pub fn setup_zk_from_secret( + beta: P::ScalarField, + max_degree: usize, + g1: P::G1, + hiding_g1: P::G1, + g2: P::G2, + ) -> HyperKZGProverSetup

{ + let mut g1_powers = Vec::with_capacity(max_degree + 1); + let mut hiding_g1_sequence = Vec::with_capacity(max_degree + 1); + let mut cur_g1 = g1; + let mut cur_hiding_g1 = hiding_g1; + for _ in 0..=max_degree { + g1_powers.push(cur_g1); + hiding_g1_sequence.push(cur_hiding_g1); + cur_g1 = cur_g1.scalar_mul(&beta); + cur_hiding_g1 = cur_hiding_g1.scalar_mul(&beta); + } + + let g2_powers = vec![g2, g2.scalar_mul(&beta)]; + + HyperKZGProverSetup { + g1_powers, + g2_powers, + hiding_g1_sequence: Some(hiding_g1_sequence), + } + } +} + +/// # Security note +/// +/// Uses KZG SRS powers as Pedersen generators. Pedersen binding shares the +/// KZG trapdoor `beta`; both are sound once `beta` is destroyed, but the two +/// schemes do not have independent security assumptions. +impl DeriveSetup> for PedersenSetup { + fn derive(source: &HyperKZGProverSetup

, capacity: usize) -> Self { + assert!( + source.g1_powers.len() > capacity, + "SRS has {} G1 powers, need at least {} (capacity + 1 for blinding)", + source.g1_powers.len(), + capacity + 1, + ); + let message_generators = source.g1_powers[..capacity].to_vec(); + let blinding_generator = source.g1_powers[capacity]; + PedersenSetup::new(message_generators, blinding_generator) + } +} diff --git a/crates/jolt-hyperkzg/src/types.rs b/crates/jolt-hyperkzg/src/types.rs index 73798c11b5..8d10c54101 100644 --- a/crates/jolt-hyperkzg/src/types.rs +++ b/crates/jolt-hyperkzg/src/types.rs @@ -3,8 +3,12 @@ //! All types are generic over `P: PairingGroup` — no arkworks leakage. use jolt_crypto::{HomomorphicCommitment, JoltGroup, PairingGroup}; +use jolt_transcript::{AppendToTranscript, Transcript}; use serde::{Deserialize, Serialize}; +pub(crate) const HYPERKZG_SRS_NAME: &str = "HYPERKZG_SRS"; +pub(crate) const HYPERKZG_SRS_VERSION: u32 = 1; + /// Commitment to a multilinear polynomial: a single G1 element. #[derive(Serialize, Deserialize)] #[serde(bound( @@ -43,7 +47,18 @@ impl PartialEq for HyperKZGCommitment

{ impl Eq for HyperKZGCommitment

{} +impl

AppendToTranscript for HyperKZGCommitment

+where + P: PairingGroup, + P::G1: AppendToTranscript, +{ + fn append_to_transcript(&self, transcript: &mut T) { + self.point.append_to_transcript(transcript); + } +} + impl HomomorphicCommitment for HyperKZGCommitment

{ + #[inline] fn add(c1: &Self, c2: &Self) -> Self { Self { @@ -67,12 +82,49 @@ impl Default for HyperKZGCommitment

{ } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum HyperKZGProofKind { + Clear, + Zk, +} + +/// Mode-specific HyperKZG opening proof data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(bound( + serialize = "P::G1: Serialize, P::ScalarField: Serialize", + deserialize = "P::G1: for<'a> Deserialize<'a>, P::ScalarField: for<'a> Deserialize<'a>" +))] +pub enum HyperKZGProofPayload { + /// Clear evaluations of all intermediate polynomials at `[r, -r, r^2]`. + Clear { v: [Vec; 3] }, + /// Hidden evaluation commitments for ZK HyperKZG. + Zk { y: [Vec; 3], y_out: P::G1 }, +} + +impl HyperKZGProofPayload

{ + pub(crate) const fn kind(&self) -> HyperKZGProofKind { + match self { + Self::Clear { .. } => HyperKZGProofKind::Clear, + Self::Zk { .. } => HyperKZGProofKind::Zk, + } + } +} + +#[cfg(feature = "zk")] +pub type HyperKZGHiddenEvaluationCommitments

= [Vec<

::G1>; 3]; + +#[cfg(feature = "zk")] +pub(crate) type HyperKZGZkOpenOutput

= ( + HyperKZGProof

, +

::G1, +

::ScalarField, +); + /// Opening proof for the HyperKZG protocol. /// /// - `com`: intermediate polynomial commitments from the Gemini folding (ell - 1 elements) /// - `w`: KZG witness commitments for the three evaluation points `[r, -r, r^2]` -/// - `v`: evaluations of all intermediate polynomials at the three points -/// (`v[t][k]` = polynomial k evaluated at point t) +/// - `payload`: mode-specific clear or ZK evaluation data #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(bound( serialize = "P::G1: Serialize, P::ScalarField: Serialize", @@ -81,7 +133,99 @@ impl Default for HyperKZGCommitment

{ pub struct HyperKZGProof { pub com: Vec, pub w: [P::G1; 3], - pub v: [Vec; 3], + pub payload: HyperKZGProofPayload

, +} + +impl HyperKZGProof

{ + pub(crate) fn clear(com: Vec, w: [P::G1; 3], v: [Vec; 3]) -> Self { + Self { + com, + w, + payload: HyperKZGProofPayload::Clear { v }, + } + } + + #[cfg(feature = "zk")] + pub(crate) fn zk(com: Vec, w: [P::G1; 3], y: [Vec; 3], y_out: P::G1) -> Self { + Self { + com, + w, + payload: HyperKZGProofPayload::Zk { y, y_out }, + } + } + + pub(crate) const fn payload_kind(&self) -> HyperKZGProofKind { + self.payload.kind() + } + + pub fn clear_evaluations(&self) -> Option<&[Vec; 3]> { + match &self.payload { + HyperKZGProofPayload::Clear { v } => Some(v), + HyperKZGProofPayload::Zk { .. } => None, + } + } + + pub fn clear_evaluations_mut(&mut self) -> Option<&mut [Vec; 3]> { + match &mut self.payload { + HyperKZGProofPayload::Clear { v } => Some(v), + HyperKZGProofPayload::Zk { .. } => None, + } + } + + #[cfg(feature = "zk")] + pub fn hidden_evaluation_commitments( + &self, + ) -> Option<(&HyperKZGHiddenEvaluationCommitments

, &P::G1)> { + match &self.payload { + HyperKZGProofPayload::Clear { .. } => None, + HyperKZGProofPayload::Zk { y, y_out } => Some((y, y_out)), + } + } + + #[cfg(feature = "zk")] + pub fn hidden_evaluation_commitments_mut( + &mut self, + ) -> Option<(&mut HyperKZGHiddenEvaluationCommitments

, &mut P::G1)> { + match &mut self.payload { + HyperKZGProofPayload::Clear { .. } => None, + HyperKZGProofPayload::Zk { y, y_out } => Some((y, y_out)), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(bound( + serialize = "P::ScalarField: Serialize", + deserialize = "P::ScalarField: for<'a> Deserialize<'a>" +))] +pub struct HyperKZGOpeningHint { + pub(crate) blind: Option, +} + +impl HyperKZGOpeningHint

{ + pub(crate) const fn clear() -> Self { + Self { blind: None } + } + + #[cfg(feature = "zk")] + pub(crate) const fn zk(blind: P::ScalarField) -> Self { + Self { blind: Some(blind) } + } + + pub const fn is_zk(&self) -> bool { + self.blind.is_some() + } + + #[cfg(feature = "zk")] + pub(crate) fn into_zk_blind(self) -> Option { + self.blind + } +} + +impl Default for HyperKZGOpeningHint

{ + fn default() -> Self { + Self::clear() + } } /// Prover setup: SRS G1 and G2 powers. @@ -96,6 +240,7 @@ pub struct HyperKZGProof { pub struct HyperKZGProverSetup { pub(crate) g1_powers: Vec, pub(crate) g2_powers: Vec, + pub(crate) hiding_g1_sequence: Option>, } /// Verifier setup: the four G1/G2 elements needed for pairing checks. @@ -112,6 +257,7 @@ pub struct HyperKZGVerifierSetup { pub(crate) g1: P::G1, pub(crate) g2: P::G2, pub(crate) beta_g2: P::G2, + pub(crate) hiding_g1: Option, } impl From<&HyperKZGProverSetup

> for HyperKZGVerifierSetup

{ @@ -120,6 +266,27 @@ impl From<&HyperKZGProverSetup

> for HyperKZGVerifierSetup

g1: prover.g1_powers[0], g2: prover.g2_powers[0], beta_g2: prover.g2_powers[1], + hiding_g1: prover.hiding_g1_sequence.as_ref().map(|powers| powers[0]), } } } + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum HyperKZGSrsKind { + Plain, + Zk, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(bound( + serialize = "P::G1: Serialize, P::G2: Serialize", + deserialize = "P::G1: for<'a> Deserialize<'a>, P::G2: for<'a> Deserialize<'a>" +))] +pub(crate) struct HyperKZGSrsFile { + pub(crate) name: String, + pub(crate) version: u32, + pub(crate) kind: HyperKZGSrsKind, + pub(crate) k: usize, + pub(crate) capacity: usize, + pub(crate) setup: HyperKZGProverSetup

, +} diff --git a/crates/jolt-hyperkzg/tests/commit_open_verify.rs b/crates/jolt-hyperkzg/tests/commit_open_verify.rs index 52a8376880..d650a34caf 100644 --- a/crates/jolt-hyperkzg/tests/commit_open_verify.rs +++ b/crates/jolt-hyperkzg/tests/commit_open_verify.rs @@ -1,25 +1,21 @@ -//! Integration tests for HyperKZG commit → open → verify pipeline with BN254. +//! End-to-end HyperKZG commit/open/verify tests. #![expect(clippy::expect_used, reason = "tests may panic on assertion failures")] +mod common; + +use common::{make_setup, KzgPCS}; use jolt_crypto::Bn254; use jolt_field::{Fr, FromPrimitiveInt, RandomSampling}; -use jolt_hyperkzg::{HyperKZGProverSetup, HyperKZGScheme, HyperKZGVerifierSetup}; -use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme}; +use jolt_hyperkzg::{HyperKZGProverSetup, HyperKZGVerifierSetup}; +use jolt_openings::CommitmentScheme; use jolt_poly::Polynomial; use jolt_transcript::{Blake2bTranscript, Transcript}; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; -type KzgPCS = HyperKZGScheme; - -fn make_setup(max_degree: usize) -> (HyperKZGProverSetup, HyperKZGVerifierSetup) { - let mut rng = ChaCha20Rng::seed_from_u64(0xdead_beef); - 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); - (pk, vk) +fn random_point(num_vars: usize, rng: &mut ChaCha20Rng) -> Vec { + (0..num_vars).map(|_| Fr::random(rng)).collect() } fn commit_open_verify( @@ -30,195 +26,72 @@ fn commit_open_verify( label: &'static [u8], ) { let eval = poly.evaluate(point); - let (commitment, ()) = ::commit(poly.evaluations(), pk); + let (commitment, _) = ::commit(poly.evaluations(), pk); - let mut t_p = Blake2bTranscript::new(label); - let proof = ::open(poly, point, eval, pk, None, &mut t_p); + let mut prover_transcript = Blake2bTranscript::new(label); + let proof = + ::open(poly, point, eval, pk, None, &mut prover_transcript); - let mut t_v = Blake2bTranscript::new(label); - ::verify(&commitment, point, eval, &proof, vk, &mut t_v) - .expect("verification should succeed"); + let mut verifier_transcript = Blake2bTranscript::new(label); + ::verify( + &commitment, + point, + eval, + &proof, + vk, + &mut verifier_transcript, + ) + .expect("verification should succeed"); } -// Basic roundtrip for various polynomial sizes - #[test] fn roundtrip_num_vars_1_to_8() { let mut rng = ChaCha20Rng::seed_from_u64(1000); - for nv in 1..=8 { - let (pk, vk) = make_setup(1 << nv); - let poly = Polynomial::::random(nv, &mut rng); - let point: Vec = (0..nv).map(|_| Fr::random(&mut rng)).collect(); + for num_vars in 1..=8 { + let (pk, vk) = make_setup(1 << num_vars); + let poly = Polynomial::::random(num_vars, &mut rng); + let point = random_point(num_vars, &mut rng); commit_open_verify(&poly, &point, &pk, &vk, b"kzg-sizes"); } } -// Edge cases - -/// All-zero polynomial commits to identity point and still verifies. #[test] fn zero_polynomial_roundtrip() { - let nv = 3; - let (pk, vk) = make_setup(1 << nv); - let poly = Polynomial::::zeros(nv); - let point = vec![Fr::from_u64(42); nv]; + let num_vars = 3; + let (pk, vk) = make_setup(1 << num_vars); + let poly = Polynomial::::zeros(num_vars); + let point = vec![Fr::from_u64(42); num_vars]; commit_open_verify(&poly, &point, &pk, &vk, b"kzg-zero"); } -/// Single-variable polynomial (2 evaluations). #[test] fn single_variable_polynomial() { let mut rng = ChaCha20Rng::seed_from_u64(2000); let (pk, vk) = make_setup(2); let poly = Polynomial::::random(1, &mut rng); - let point = vec![Fr::random(&mut rng)]; + let point = random_point(1, &mut rng); commit_open_verify(&poly, &point, &pk, &vk, b"kzg-single-var"); } -/// Constant polynomial (all evaluations are the same value). #[test] fn constant_polynomial() { - let nv = 3; - let (pk, vk) = make_setup(1 << nv); - let val = Fr::from_u64(42); - let poly = Polynomial::new(vec![val; 1 << nv]); + let num_vars = 3; + let (pk, vk) = make_setup(1 << num_vars); + let value = Fr::from_u64(42); + let poly = Polynomial::new(vec![value; 1 << num_vars]); let mut rng = ChaCha20Rng::seed_from_u64(2001); - let point: Vec = (0..nv).map(|_| Fr::random(&mut rng)).collect(); + let point = random_point(num_vars, &mut rng); commit_open_verify(&poly, &point, &pk, &vk, b"kzg-constant"); } -// Wrong evaluation rejection - -#[test] -fn wrong_eval_rejected() { - let mut rng = ChaCha20Rng::seed_from_u64(3000); - let nv = 4; - let (pk, vk) = make_setup(1 << nv); - let poly = Polynomial::::random(nv, &mut rng); - let point: Vec = (0..nv).map(|_| Fr::random(&mut rng)).collect(); - - let correct_eval = poly.evaluate(&point); - let wrong_eval = correct_eval + Fr::from_u64(1); - let (commitment, ()) = ::commit(poly.evaluations(), &pk); - - // Prover opens with correct eval - let mut t_p = Blake2bTranscript::new(b"kzg-wrong"); - let proof = - ::open(&poly, &point, correct_eval, &pk, None, &mut t_p); - - // Verifier checks with wrong eval - let mut t_v = Blake2bTranscript::new(b"kzg-wrong"); - let result = ::verify( - &commitment, - &point, - wrong_eval, - &proof, - &vk, - &mut t_v, - ); - assert!(result.is_err(), "wrong evaluation must be rejected"); -} - -// Homomorphic properties - -/// combine([C_a, C_b], [1, 1]) == commit(a + b). -#[test] -fn homomorphic_sum() { - let mut rng = ChaCha20Rng::seed_from_u64(4000); - let nv = 4; - let (pk, vk) = make_setup(1 << nv); - let a = Polynomial::::random(nv, &mut rng); - let b = Polynomial::::random(nv, &mut rng); - - let (com_a, ()) = ::commit(a.evaluations(), &pk); - let (com_b, ()) = ::commit(b.evaluations(), &pk); - let combined_com = ::combine( - &[com_a, com_b], - &[Fr::from_u64(1), Fr::from_u64(1)], - ); - - let sum_poly = a + b; - let point: Vec = (0..nv).map(|_| Fr::random(&mut rng)).collect(); - let eval = sum_poly.evaluate(&point); - - let mut t_p = Blake2bTranscript::new(b"kzg-homo"); - 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"); -} - -/// combine with arbitrary scalars: s_a·C_a + s_b·C_b == commit(s_a·a + s_b·b). -#[test] -fn homomorphic_weighted_combination() { - let mut rng = ChaCha20Rng::seed_from_u64(4001); - let nv = 3; - let (pk, vk) = make_setup(1 << nv); - let a = Polynomial::::random(nv, &mut rng); - let b = Polynomial::::random(nv, &mut rng); - let s_a = Fr::random(&mut rng); - let s_b = Fr::random(&mut rng); - - 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 weighted_poly = a * s_a + b * s_b; - let point: Vec = (0..nv).map(|_| Fr::random(&mut rng)).collect(); - let eval = weighted_poly.evaluate(&point); - - let mut t_p = Blake2bTranscript::new(b"kzg-weighted"); - let proof = - ::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"); -} - -// Deterministic setup - -#[test] -fn deterministic_setup_from_secret() { - let g1 = Bn254::g1_generator(); - let g2 = Bn254::g2_generator(); - let beta = Fr::from_u64(12345); - - 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); - - // Same setup yields same commitments - let poly = Polynomial::new(vec![Fr::from_u64(1), Fr::from_u64(2)]); - let (com1, ()) = ::commit(poly.evaluations(), &pk1); - let (com2, ()) = ::commit(poly.evaluations(), &pk2); - assert_eq!( - com1, com2, - "deterministic setups must produce same commitments" - ); - - // Verify with either setup - let point = vec![Fr::from_u64(7)]; - let eval = poly.evaluate(&point); - 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) - .expect("cross-setup verification must work"); -} - -// Property test: random polynomials always verify - #[test] fn property_random_polynomials_always_verify() { for seed in 5000..5010 { let mut rng = ChaCha20Rng::seed_from_u64(seed); - let nv = 2 + (seed as usize % 5); // 2..6 - let (pk, vk) = make_setup(1 << nv); - let poly = Polynomial::::random(nv, &mut rng); - let point: Vec = (0..nv).map(|_| Fr::random(&mut rng)).collect(); + let num_vars = 2 + (seed as usize % 5); + let (pk, vk) = make_setup(1 << num_vars); + let poly = Polynomial::::random(num_vars, &mut rng); + let point = random_point(num_vars, &mut rng); commit_open_verify(&poly, &point, &pk, &vk, b"kzg-property"); } } diff --git a/crates/jolt-hyperkzg/tests/common/mod.rs b/crates/jolt-hyperkzg/tests/common/mod.rs new file mode 100644 index 0000000000..6ae0677e0d --- /dev/null +++ b/crates/jolt-hyperkzg/tests/common/mod.rs @@ -0,0 +1,16 @@ +use jolt_crypto::Bn254; +use jolt_hyperkzg::{HyperKZGProverSetup, HyperKZGScheme, HyperKZGVerifierSetup}; +use jolt_openings::CommitmentScheme; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; + +pub type KzgPCS = HyperKZGScheme; + +pub fn make_setup(max_degree: usize) -> (HyperKZGProverSetup, HyperKZGVerifierSetup) { + let mut rng = ChaCha20Rng::seed_from_u64(0xdead_beef); + 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); + (pk, vk) +} diff --git a/crates/jolt-hyperkzg/tests/homomorphism.rs b/crates/jolt-hyperkzg/tests/homomorphism.rs new file mode 100644 index 0000000000..9c1e4243fc --- /dev/null +++ b/crates/jolt-hyperkzg/tests/homomorphism.rs @@ -0,0 +1,118 @@ +//! HyperKZG additive homomorphism tests. + +#![expect(clippy::expect_used, reason = "tests may panic on assertion failures")] + +mod common; + +use common::{make_setup, KzgPCS}; +use jolt_field::{Fr, FromPrimitiveInt, RandomSampling}; +use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme}; +use jolt_poly::Polynomial; +use jolt_transcript::{Blake2bTranscript, Transcript}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; + +fn random_point(num_vars: usize, rng: &mut ChaCha20Rng) -> Vec { + (0..num_vars).map(|_| Fr::random(rng)).collect() +} + +#[test] +fn homomorphic_sum() { + let mut rng = ChaCha20Rng::seed_from_u64(4000); + let num_vars = 4; + let (pk, vk) = make_setup(1 << num_vars); + let a = Polynomial::::random(num_vars, &mut rng); + let b = Polynomial::::random(num_vars, &mut rng); + + let (com_a, _) = ::commit(a.evaluations(), &pk); + let (com_b, _) = ::commit(b.evaluations(), &pk); + let combined_com = ::combine( + &[com_a, com_b], + &[Fr::from_u64(1), Fr::from_u64(1)], + ); + + let sum_poly = a + b; + let point = random_point(num_vars, &mut rng); + let eval = sum_poly.evaluate(&point); + + let mut prover_transcript = Blake2bTranscript::new(b"kzg-homo"); + let proof = ::open( + &sum_poly, + &point, + eval, + &pk, + None, + &mut prover_transcript, + ); + + let mut verifier_transcript = Blake2bTranscript::new(b"kzg-homo"); + ::verify( + &combined_com, + &point, + eval, + &proof, + &vk, + &mut verifier_transcript, + ) + .expect("homomorphic sum must verify"); +} + +#[test] +fn homomorphic_weighted_combination() { + let mut rng = ChaCha20Rng::seed_from_u64(4001); + let num_vars = 3; + let (pk, vk) = make_setup(1 << num_vars); + let a = Polynomial::::random(num_vars, &mut rng); + let b = Polynomial::::random(num_vars, &mut rng); + let s_a = Fr::random(&mut rng); + let s_b = Fr::random(&mut rng); + + 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 weighted_poly = a * s_a + b * s_b; + let point = random_point(num_vars, &mut rng); + let eval = weighted_poly.evaluate(&point); + + let mut prover_transcript = Blake2bTranscript::new(b"kzg-weighted"); + let proof = ::open( + &weighted_poly, + &point, + eval, + &pk, + None, + &mut prover_transcript, + ); + + let mut verifier_transcript = Blake2bTranscript::new(b"kzg-weighted"); + ::verify( + &combined_com, + &point, + eval, + &proof, + &vk, + &mut verifier_transcript, + ) + .expect("weighted combination must verify"); +} + +#[test] +fn clear_hints_combine_to_clear_hint() { + let mut rng = ChaCha20Rng::seed_from_u64(4100); + let num_vars = 3; + let (pk, _) = make_setup(1 << num_vars); + let a = Polynomial::::random(num_vars, &mut rng); + let b = Polynomial::::random(num_vars, &mut rng); + + let (_, hint_a) = ::commit(a.evaluations(), &pk); + let (_, hint_b) = ::commit(b.evaluations(), &pk); + assert!(!hint_a.is_zk()); + assert!(!hint_b.is_zk()); + + let combined = ::combine_hints( + vec![hint_a, hint_b], + &[Fr::from_u64(3), Fr::from_u64(5)], + ); + assert!(!combined.is_zk()); +} diff --git a/crates/jolt-hyperkzg/tests/setup.rs b/crates/jolt-hyperkzg/tests/setup.rs new file mode 100644 index 0000000000..b37dc84dd0 --- /dev/null +++ b/crates/jolt-hyperkzg/tests/setup.rs @@ -0,0 +1,149 @@ +//! HyperKZG setup, SRS file, and derived-vector-commitment tests. + +#![expect(clippy::expect_used, reason = "tests may panic on assertion failures")] + +mod common; + +use common::{make_setup, KzgPCS}; +use jolt_crypto::{Bn254, Bn254G1, DeriveSetup, Pedersen, PedersenSetup, VectorCommitment}; +use jolt_field::{Fr, FromPrimitiveInt}; +use jolt_hyperkzg::{HyperKZGProverSetup, HyperKZGVerifierSetup}; +use jolt_openings::CommitmentScheme; +use jolt_poly::Polynomial; +use jolt_transcript::{Blake2bTranscript, Transcript}; +use num_traits::One; + +fn commit_open_verify( + poly: &Polynomial, + point: &[Fr], + pk: &HyperKZGProverSetup, + vk: &HyperKZGVerifierSetup, + label: &'static [u8], +) { + let eval = poly.evaluate(point); + let (commitment, _) = ::commit(poly.evaluations(), pk); + + let mut prover_transcript = Blake2bTranscript::new(label); + let proof = + ::open(poly, point, eval, pk, None, &mut prover_transcript); + + let mut verifier_transcript = Blake2bTranscript::new(label); + ::verify( + &commitment, + point, + eval, + &proof, + vk, + &mut verifier_transcript, + ) + .expect("verification should succeed"); +} + +#[test] +fn deterministic_setup_from_secret() { + let g1 = Bn254::g1_generator(); + let g2 = Bn254::g2_generator(); + let beta = Fr::from_u64(12345); + + let pk1 = KzgPCS::setup_from_secret(beta, 16, g1, g2); + let pk2 = KzgPCS::setup_from_secret(beta, 16, g1, g2); + let vk2 = KzgPCS::verifier_setup(&pk2); + + let poly = Polynomial::new(vec![Fr::from_u64(1), Fr::from_u64(2)]); + let (com1, _) = ::commit(poly.evaluations(), &pk1); + let (com2, _) = ::commit(poly.evaluations(), &pk2); + assert_eq!( + com1, com2, + "deterministic setups must produce same commitments" + ); + + let point = vec![Fr::from_u64(7)]; + let eval = poly.evaluate(&point); + let mut prover_transcript = Blake2bTranscript::new(b"det-setup"); + let proof = + ::open(&poly, &point, eval, &pk1, None, &mut prover_transcript); + let mut verifier_transcript = Blake2bTranscript::new(b"det-setup"); + ::verify( + &com1, + &point, + eval, + &proof, + &vk2, + &mut verifier_transcript, + ) + .expect("cross-setup verification must work"); +} + +#[test] +fn trait_setup_uses_fresh_randomness() { + let g1 = Bn254::g1_generator(); + let g2 = Bn254::g2_generator(); + + let (pk1, _) = ::setup((4, g1, g2)); + let (pk2, _) = ::setup((4, g1, g2)); + + let poly = Polynomial::new((1..=16).map(Fr::from_u64).collect()); + let (com1, _) = ::commit(poly.evaluations(), &pk1); + let (com2, _) = ::commit(poly.evaluations(), &pk2); + assert_ne!(com1, com2, "trait setup should sample a fresh trapdoor"); +} + +#[test] +fn srs_file_roundtrip_uses_canonical_name() { + let k = 3; + let (pk, _) = make_setup(1 << k); + + let dir = std::env::temp_dir().join(format!( + "jolt-hyperkzg-srs-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after UNIX epoch") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("create temp SRS dir"); + + KzgPCS::write_srs_to_dir(&pk, &dir, k).expect("write SRS file"); + let path = dir.join("hyperkzg_3.srs"); + assert!(path.exists(), "canonical SRS file should exist"); + + let loaded = KzgPCS::read_srs_from_dir(&dir, k).expect("read SRS file"); + let vk = KzgPCS::verifier_setup(&loaded); + + let poly = Polynomial::new(vec![ + Fr::from_u64(1), + Fr::from_u64(2), + Fr::from_u64(3), + Fr::from_u64(4), + Fr::from_u64(5), + Fr::from_u64(6), + Fr::from_u64(7), + Fr::from_u64(8), + ]); + let point = vec![Fr::from_u64(9), Fr::from_u64(10), Fr::from_u64(11)]; + commit_open_verify(&poly, &point, &loaded, &vk, b"kzg-srs-file"); + + std::fs::remove_dir_all(&dir).expect("remove temp SRS dir"); +} + +#[test] +fn extract_vc_setup_produces_valid_pedersen() { + let (pk, _) = make_setup(1 << 4); + let capacity = 5; + let vc_setup = PedersenSetup::::derive(&pk, capacity); + + assert_eq!( + as VectorCommitment>::capacity(&vc_setup), + capacity, + ); + + let values = vec![Fr::one(), Fr::from_u64(2), Fr::from_u64(3)]; + let blinding = Fr::from_u64(42); + let commitment = as VectorCommitment>::commit(&vc_setup, &values, &blinding); + assert!( as VectorCommitment>::verify( + &vc_setup, + &commitment, + &values, + &blinding, + )); +} diff --git a/crates/jolt-hyperkzg/tests/soundness.rs b/crates/jolt-hyperkzg/tests/soundness.rs new file mode 100644 index 0000000000..04ebd7a7fa --- /dev/null +++ b/crates/jolt-hyperkzg/tests/soundness.rs @@ -0,0 +1,149 @@ +//! Negative HyperKZG verification tests. + +#![expect(clippy::expect_used, reason = "tests may panic on assertion failures")] + +mod common; + +use common::{make_setup, KzgPCS}; +use jolt_crypto::Bn254; +use jolt_field::{Fr, FromPrimitiveInt, RandomSampling}; +use jolt_hyperkzg::{error::HyperKZGError, HyperKZGProofPayload}; +use jolt_openings::CommitmentScheme; +use jolt_poly::Polynomial; +use jolt_transcript::{Blake2bTranscript, Transcript}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; + +fn random_point(num_vars: usize, rng: &mut ChaCha20Rng) -> Vec { + (0..num_vars).map(|_| Fr::random(rng)).collect() +} + +#[test] +fn wrong_eval_rejected() { + let mut rng = ChaCha20Rng::seed_from_u64(3000); + let num_vars = 4; + let (pk, vk) = make_setup(1 << num_vars); + let poly = Polynomial::::random(num_vars, &mut rng); + let point = random_point(num_vars, &mut rng); + + let correct_eval = poly.evaluate(&point); + let wrong_eval = correct_eval + Fr::from_u64(1); + let (commitment, _) = ::commit(poly.evaluations(), &pk); + + let mut prover_transcript = Blake2bTranscript::new(b"kzg-wrong"); + let proof = ::open( + &poly, + &point, + correct_eval, + &pk, + None, + &mut prover_transcript, + ); + + let mut verifier_transcript = Blake2bTranscript::new(b"kzg-wrong"); + let result = ::verify( + &commitment, + &point, + wrong_eval, + &proof, + &vk, + &mut verifier_transcript, + ); + assert!(result.is_err(), "wrong evaluation must be rejected"); +} + +#[test] +fn clear_verify_rejects_zk_payload_discriminant() { + let mut rng = ChaCha20Rng::seed_from_u64(3100); + let num_vars = 4; + let (pk, vk) = make_setup(1 << num_vars); + let poly = Polynomial::::random(num_vars, &mut rng); + let point = random_point(num_vars, &mut rng); + let eval = poly.evaluate(&point); + let (commitment, _) = ::commit(poly.evaluations(), &pk); + + let mut prover_transcript = Blake2bTranscript::new(b"kzg-payload-kind"); + let mut proof = + ::open(&poly, &point, eval, &pk, None, &mut prover_transcript); + let empty = Vec::new(); + proof.payload = HyperKZGProofPayload::Zk { + y: [empty.clone(), empty.clone(), empty], + y_out: Bn254::g1_generator(), + }; + + let mut verifier_transcript = Blake2bTranscript::new(b"kzg-payload-kind"); + let result = KzgPCS::verify( + &vk, + &commitment, + &point, + &eval, + &proof, + &mut verifier_transcript, + ); + assert!(matches!( + result, + Err(HyperKZGError::WrongProofPayload { .. }) + )); +} + +#[test] +fn missing_intermediate_commitment_rejects() { + let mut rng = ChaCha20Rng::seed_from_u64(3200); + let num_vars = 4; + let (pk, vk) = make_setup(1 << num_vars); + let poly = Polynomial::::random(num_vars, &mut rng); + let point = random_point(num_vars, &mut rng); + let eval = poly.evaluate(&point); + let (commitment, _) = ::commit(poly.evaluations(), &pk); + + let mut prover_transcript = Blake2bTranscript::new(b"kzg-missing-com"); + let mut proof = + ::open(&poly, &point, eval, &pk, None, &mut prover_transcript); + let _ = proof.com.pop(); + + let mut verifier_transcript = Blake2bTranscript::new(b"kzg-missing-com"); + let result = KzgPCS::verify( + &vk, + &commitment, + &point, + &eval, + &proof, + &mut verifier_transcript, + ); + assert!(matches!( + result, + Err(HyperKZGError::WrongCommitmentCount { .. }) + )); +} + +#[test] +fn tampered_proof_rejects() { + let mut rng = ChaCha20Rng::seed_from_u64(3300); + let num_vars = 4; + let (pk, vk) = make_setup(1 << num_vars); + let poly = Polynomial::::random(num_vars, &mut rng); + let point = random_point(num_vars, &mut rng); + let eval = poly.evaluate(&point); + let (commitment, _) = ::commit(poly.evaluations(), &pk); + + let mut prover_transcript = Blake2bTranscript::new(b"kzg-tamper"); + let mut proof = + ::open(&poly, &point, eval, &pk, None, &mut prover_transcript); + + let v = proof + .clear_evaluations_mut() + .expect("transparent proof must have clear evaluations"); + let v1 = v[1].clone(); + v[0].clone_from(&v1); + + let mut verifier_transcript = Blake2bTranscript::new(b"kzg-tamper"); + let result = ::verify( + &commitment, + &point, + eval, + &proof, + &vk, + &mut verifier_transcript, + ); + assert!(result.is_err(), "tampered proof must be rejected"); +} diff --git a/crates/jolt-hyperkzg/tests/zk_homomorphism.rs b/crates/jolt-hyperkzg/tests/zk_homomorphism.rs new file mode 100644 index 0000000000..7c89d66cc8 --- /dev/null +++ b/crates/jolt-hyperkzg/tests/zk_homomorphism.rs @@ -0,0 +1,101 @@ +//! ZK HyperKZG additive homomorphism tests. + +#![cfg(feature = "zk")] +#![expect(clippy::expect_used, reason = "tests may panic on assertion failures")] + +use jolt_crypto::{Bn254, JoltGroup}; +use jolt_field::{Fr, FromPrimitiveInt, RandomSampling}; +use jolt_hyperkzg::HyperKZGScheme; +use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme, ZkOpeningScheme}; +use jolt_poly::Polynomial; +use jolt_transcript::{Blake2bTranscript, Transcript}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; + +type KzgPCS = HyperKZGScheme; + +fn make_zk_setup(max_degree: usize) -> jolt_hyperkzg::HyperKZGProverSetup { + let g1 = Bn254::g1_generator(); + let hiding_g1 = g1.scalar_mul(&Fr::from_u64(17)); + let g2 = Bn254::g2_generator(); + KzgPCS::setup_zk_from_secret(Fr::from_u64(12345), max_degree, g1, hiding_g1, g2) +} + +fn random_point(num_vars: usize, rng: &mut ChaCha20Rng) -> Vec { + (0..num_vars).map(|_| Fr::random(rng)).collect() +} + +#[test] +fn homomorphic_zk_weighted_combination_verifies_with_combined_hint() { + let mut rng = ChaCha20Rng::seed_from_u64(7400); + let num_vars = 4; + let pk = make_zk_setup(1 << num_vars); + let vk = KzgPCS::verifier_setup(&pk); + let poly_a = Polynomial::::random(num_vars, &mut rng); + let poly_b = Polynomial::::random(num_vars, &mut rng); + let scalar_a = Fr::random(&mut rng); + let scalar_b = Fr::random(&mut rng); + + let (commitment_a, hint_a) = ::commit_zk(poly_a.evaluations(), &pk); + let (commitment_b, hint_b) = ::commit_zk(poly_b.evaluations(), &pk); + assert!(hint_a.is_zk()); + assert!(hint_b.is_zk()); + + let combined_commitment = ::combine( + &[commitment_a, commitment_b], + &[scalar_a, scalar_b], + ); + let combined_hint = ::combine_hints( + vec![hint_a, hint_b], + &[scalar_a, scalar_b], + ); + assert!(combined_hint.is_zk()); + + let weighted_poly = poly_a * scalar_a + poly_b * scalar_b; + let point = random_point(num_vars, &mut rng); + let eval = weighted_poly.evaluate(&point); + + let mut prover_transcript = Blake2bTranscript::new(b"hyperkzg-zk-combine"); + let (proof, y_out, output_blind) = KzgPCS::open_zk( + &weighted_poly, + &point, + eval, + &pk, + combined_hint, + &mut prover_transcript, + ); + + let mut verifier_transcript = Blake2bTranscript::new(b"hyperkzg-zk-combine"); + let verified_y_out = KzgPCS::verify_zk( + &combined_commitment, + &point, + &proof, + &vk, + &mut verifier_transcript, + ) + .expect("combined ZK opening should verify"); + + let hiding_g1 = Bn254::g1_generator().scalar_mul(&Fr::from_u64(17)); + let expected_y_out = + Bn254::g1_generator().scalar_mul(&eval) + hiding_g1.scalar_mul(&output_blind); + assert_eq!(verified_y_out, y_out); + assert_eq!(verified_y_out, expected_y_out); +} + +#[test] +#[should_panic(expected = "cannot combine mixed transparent and ZK")] +fn combining_mixed_clear_and_zk_hints_panics() { + let mut rng = ChaCha20Rng::seed_from_u64(7410); + let num_vars = 3; + let pk = make_zk_setup(1 << num_vars); + let poly = Polynomial::::random(num_vars, &mut rng); + + let (_, clear_hint) = + ::commit(poly.evaluations(), &pk); + let (_, zk_hint) = ::commit_zk(poly.evaluations(), &pk); + + let _ = ::combine_hints( + vec![clear_hint, zk_hint], + &[Fr::from_u64(1), Fr::from_u64(1)], + ); +} diff --git a/crates/jolt-hyperkzg/tests/zk_opening.rs b/crates/jolt-hyperkzg/tests/zk_opening.rs new file mode 100644 index 0000000000..28bbe9201d --- /dev/null +++ b/crates/jolt-hyperkzg/tests/zk_opening.rs @@ -0,0 +1,71 @@ +//! ZK HyperKZG opening tests. + +#![cfg(feature = "zk")] +#![expect(clippy::expect_used, reason = "tests may panic on assertion failures")] + +use jolt_crypto::{Bn254, JoltGroup}; +use jolt_field::{Fr, FromPrimitiveInt, RandomSampling}; +use jolt_hyperkzg::HyperKZGScheme; +use jolt_openings::{CommitmentScheme, ZkOpeningScheme}; +use jolt_poly::Polynomial; +use jolt_transcript::{Blake2bTranscript, Transcript}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; + +type KzgPCS = HyperKZGScheme; + +fn make_zk_setup(max_degree: usize) -> jolt_hyperkzg::HyperKZGProverSetup { + let g1 = Bn254::g1_generator(); + let hiding_g1 = g1.scalar_mul(&Fr::from_u64(17)); + let g2 = Bn254::g2_generator(); + KzgPCS::setup_zk_from_secret(Fr::from_u64(12345), max_degree, g1, hiding_g1, g2) +} + +fn random_point(num_vars: usize, rng: &mut ChaCha20Rng) -> Vec { + (0..num_vars).map(|_| Fr::random(rng)).collect() +} + +#[test] +fn zk_roundtrip_returns_hidden_evaluation_commitment() { + let mut rng = ChaCha20Rng::seed_from_u64(7000); + let num_vars = 4; + let pk = make_zk_setup(1 << num_vars); + let vk = KzgPCS::verifier_setup(&pk); + let poly = Polynomial::::random(num_vars, &mut rng); + let point = random_point(num_vars, &mut rng); + let eval = poly.evaluate(&point); + + let (commitment, hint) = ::commit_zk(poly.evaluations(), &pk); + assert!(hint.is_zk()); + + let mut prover_transcript = Blake2bTranscript::new(b"hyperkzg-zk-roundtrip"); + let (proof, y_out, output_blind) = + KzgPCS::open_zk(&poly, &point, eval, &pk, hint, &mut prover_transcript); + + let mut verifier_transcript = Blake2bTranscript::new(b"hyperkzg-zk-roundtrip"); + let verified_y_out = + KzgPCS::verify_zk(&commitment, &point, &proof, &vk, &mut verifier_transcript) + .expect("ZK opening should verify"); + + let hiding_g1 = Bn254::g1_generator().scalar_mul(&Fr::from_u64(17)); + let expected_y_out = + Bn254::g1_generator().scalar_mul(&eval) + hiding_g1.scalar_mul(&output_blind); + assert_eq!(verified_y_out, y_out); + assert_eq!(verified_y_out, expected_y_out); +} + +#[test] +fn zk_commitment_uses_fresh_blinding() { + let mut rng = ChaCha20Rng::seed_from_u64(7100); + let num_vars = 3; + let pk = make_zk_setup(1 << num_vars); + let poly = Polynomial::::random(num_vars, &mut rng); + + let (commitment_a, _) = ::commit_zk(poly.evaluations(), &pk); + let (commitment_b, _) = ::commit_zk(poly.evaluations(), &pk); + + assert_ne!( + commitment_a, commitment_b, + "ZK commitments should use fresh scalar blinds" + ); +} diff --git a/crates/jolt-hyperkzg/tests/zk_soundness.rs b/crates/jolt-hyperkzg/tests/zk_soundness.rs new file mode 100644 index 0000000000..d712c3e0cb --- /dev/null +++ b/crates/jolt-hyperkzg/tests/zk_soundness.rs @@ -0,0 +1,273 @@ +//! Negative ZK HyperKZG verification and precondition tests. + +#![cfg(feature = "zk")] +#![expect(clippy::expect_used, reason = "tests may panic on assertion failures")] + +use jolt_crypto::{Bn254, Bn254G1, JoltGroup}; +use jolt_field::{Fr, FromPrimitiveInt, RandomSampling}; +use jolt_hyperkzg::{HyperKZGCommitment, HyperKZGProof, HyperKZGScheme, HyperKZGVerifierSetup}; +use jolt_openings::{CommitmentScheme, ZkOpeningScheme}; +use jolt_poly::Polynomial; +use jolt_transcript::{Blake2bTranscript, Transcript}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; + +type KzgPCS = HyperKZGScheme; + +struct ZkCase { + commitment: HyperKZGCommitment, + point: Vec, + proof: HyperKZGProof, + verifier_setup: HyperKZGVerifierSetup, +} + +fn make_zk_setup(max_degree: usize) -> jolt_hyperkzg::HyperKZGProverSetup { + let g1 = Bn254::g1_generator(); + let hiding_g1 = g1.scalar_mul(&Fr::from_u64(17)); + let g2 = Bn254::g2_generator(); + KzgPCS::setup_zk_from_secret(Fr::from_u64(12345), max_degree, g1, hiding_g1, g2) +} + +fn random_point(num_vars: usize, rng: &mut ChaCha20Rng) -> Vec { + (0..num_vars).map(|_| Fr::random(rng)).collect() +} + +fn make_case(seed: u64, label: &'static [u8]) -> ZkCase { + let mut rng = ChaCha20Rng::seed_from_u64(seed); + let num_vars = 4; + let prover_setup = make_zk_setup(1 << num_vars); + let verifier_setup = KzgPCS::verifier_setup(&prover_setup); + let poly = Polynomial::::random(num_vars, &mut rng); + let point = random_point(num_vars, &mut rng); + let eval = poly.evaluate(&point); + let (commitment, hint) = + ::commit_zk(poly.evaluations(), &prover_setup); + + let mut prover_transcript = Blake2bTranscript::new(label); + let (proof, _y_out, _output_blind) = KzgPCS::open_zk( + &poly, + &point, + eval, + &prover_setup, + hint, + &mut prover_transcript, + ); + + ZkCase { + commitment, + point, + proof, + verifier_setup, + } +} + +fn verify_case( + case: &ZkCase, + label: &'static [u8], +) -> Result { + let mut verifier_transcript = Blake2bTranscript::new(label); + KzgPCS::verify_zk( + &case.commitment, + &case.point, + &case.proof, + &case.verifier_setup, + &mut verifier_transcript, + ) +} + +#[test] +fn tampered_zk_evaluation_commitment_rejects() { + let label = b"hyperkzg-zk-tamper-y"; + let mut case = make_case(7500, label); + let (y, _) = case + .proof + .hidden_evaluation_commitments_mut() + .expect("ZK proof should expose hidden evaluation commitments"); + y[0][0] += Bn254::g1_generator(); + + assert!( + verify_case(&case, label).is_err(), + "tampered ZK evaluation must reject" + ); +} + +#[test] +fn tampered_y_out_rejects() { + let label = b"hyperkzg-zk-tamper-out"; + let mut case = make_case(7510, label); + let (_, y_out) = case + .proof + .hidden_evaluation_commitments_mut() + .expect("ZK proof should expose hidden evaluation commitments"); + *y_out += Bn254::g1_generator(); + + assert!( + verify_case(&case, label).is_err(), + "tampered ZK output commitment must reject" + ); +} + +#[test] +fn tampered_witness_rejects() { + let label = b"hyperkzg-zk-tamper-w"; + let mut case = make_case(7520, label); + case.proof.w[0] += Bn254::g1_generator(); + + assert!( + verify_case(&case, label).is_err(), + "tampered ZK witness must reject" + ); +} + +#[test] +fn tampered_fold_commitment_rejects() { + let label = b"hyperkzg-zk-tamper-com"; + let mut case = make_case(7530, label); + case.proof.com[0] += Bn254::g1_generator(); + + assert!( + verify_case(&case, label).is_err(), + "tampered ZK fold commitment must reject" + ); +} + +#[test] +fn wrong_zk_commitment_rejects() { + let label = b"hyperkzg-zk-wrong-com"; + let mut case = make_case(7540, label); + let mut rng = ChaCha20Rng::seed_from_u64(7541); + let prover_setup = make_zk_setup(1 << case.point.len()); + let wrong_poly = Polynomial::::random(case.point.len(), &mut rng); + let (wrong_commitment, _) = + ::commit_zk(wrong_poly.evaluations(), &prover_setup); + case.commitment = wrong_commitment; + + assert!( + verify_case(&case, label).is_err(), + "wrong ZK commitment must reject" + ); +} + +#[test] +fn wrong_claimed_eval_in_zk_open_rejects() { + let mut rng = ChaCha20Rng::seed_from_u64(7545); + let label = b"hyperkzg-zk-wrong-eval"; + let num_vars = 4; + let prover_setup = make_zk_setup(1 << num_vars); + let verifier_setup = KzgPCS::verifier_setup(&prover_setup); + let poly = Polynomial::::random(num_vars, &mut rng); + let point = random_point(num_vars, &mut rng); + let wrong_eval = poly.evaluate(&point) + Fr::from_u64(1); + let (commitment, hint) = + ::commit_zk(poly.evaluations(), &prover_setup); + + let mut prover_transcript = Blake2bTranscript::new(label); + let (proof, _y_out, _output_blind) = KzgPCS::open_zk( + &poly, + &point, + wrong_eval, + &prover_setup, + hint, + &mut prover_transcript, + ); + + let mut verifier_transcript = Blake2bTranscript::new(label); + let result = KzgPCS::verify_zk( + &commitment, + &point, + &proof, + &verifier_setup, + &mut verifier_transcript, + ); + assert!( + result.is_err(), + "wrong hidden output evaluation must reject" + ); +} + +#[test] +fn malformed_zk_evaluation_width_rejects() { + let label = b"hyperkzg-zk-bad-width"; + let mut case = make_case(7550, label); + let (y, _) = case + .proof + .hidden_evaluation_commitments_mut() + .expect("ZK proof should expose hidden evaluation commitments"); + let _ = y[0].pop(); + + assert!( + verify_case(&case, label).is_err(), + "malformed ZK evaluation row must reject" + ); +} + +#[test] +fn verify_zk_rejects_clear_payload() { + let mut rng = ChaCha20Rng::seed_from_u64(7560); + let label = b"hyperkzg-zk-clear-payload"; + let num_vars = 3; + let prover_setup = make_zk_setup(1 << num_vars); + let verifier_setup = KzgPCS::verifier_setup(&prover_setup); + let poly = Polynomial::::random(num_vars, &mut rng); + let point = random_point(num_vars, &mut rng); + let eval = poly.evaluate(&point); + let (commitment, _) = ::commit(poly.evaluations(), &prover_setup); + + let mut prover_transcript = Blake2bTranscript::new(label); + let proof = ::open( + &poly, + &point, + eval, + &prover_setup, + None, + &mut prover_transcript, + ); + + let mut verifier_transcript = Blake2bTranscript::new(label); + let result = KzgPCS::verify_zk( + &commitment, + &point, + &proof, + &verifier_setup, + &mut verifier_transcript, + ); + assert!( + result.is_err(), + "ZK verifier should reject a clear proof payload" + ); +} + +#[test] +#[should_panic(expected = "ZK SRS must contain hiding bases")] +fn commit_zk_with_plain_srs_panics() { + let mut rng = ChaCha20Rng::seed_from_u64(7570); + let num_vars = 3; + let g1 = Bn254::g1_generator(); + let g2 = Bn254::g2_generator(); + let plain_setup = KzgPCS::setup_from_secret(Fr::from_u64(12345), 1 << num_vars, g1, g2); + let poly = Polynomial::::random(num_vars, &mut rng); + + let _ = ::commit_zk(poly.evaluations(), &plain_setup); +} + +#[test] +#[should_panic(expected = "ZK HyperKZG opening requires a ZK opening hint")] +fn open_zk_with_clear_hint_panics() { + let mut rng = ChaCha20Rng::seed_from_u64(7580); + let num_vars = 3; + let prover_setup = make_zk_setup(1 << num_vars); + let poly = Polynomial::::random(num_vars, &mut rng); + let point = random_point(num_vars, &mut rng); + let eval = poly.evaluate(&point); + let (_, clear_hint) = ::commit(poly.evaluations(), &prover_setup); + + let mut prover_transcript = Blake2bTranscript::new(b"hyperkzg-zk-clear-hint"); + let _ = KzgPCS::open_zk( + &poly, + &point, + eval, + &prover_setup, + clear_hint, + &mut prover_transcript, + ); +} diff --git a/crates/jolt-hyperkzg/tests/zk_srs.rs b/crates/jolt-hyperkzg/tests/zk_srs.rs new file mode 100644 index 0000000000..7a2027a349 --- /dev/null +++ b/crates/jolt-hyperkzg/tests/zk_srs.rs @@ -0,0 +1,97 @@ +//! ZK HyperKZG SRS import/export tests. + +#![cfg(feature = "zk")] +#![expect(clippy::expect_used, reason = "tests may panic on assertion failures")] + +use jolt_crypto::{Bn254, JoltGroup}; +use jolt_field::{Fr, FromPrimitiveInt}; +use jolt_hyperkzg::error::HyperKZGError; +use jolt_hyperkzg::HyperKZGScheme; + +type KzgPCS = HyperKZGScheme; + +fn temp_dir(label: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "jolt-hyperkzg-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time should be after UNIX epoch") + .as_nanos() + )) +} + +fn make_zk_setup(max_degree: usize) -> jolt_hyperkzg::HyperKZGProverSetup { + let g1 = Bn254::g1_generator(); + let hiding_g1 = g1.scalar_mul(&Fr::from_u64(17)); + let g2 = Bn254::g2_generator(); + KzgPCS::setup_zk_from_secret(Fr::from_u64(12345), max_degree, g1, hiding_g1, g2) +} + +#[test] +fn zk_srs_file_roundtrip_uses_canonical_name() { + let k = 3; + let pk = make_zk_setup(1 << k); + let dir = temp_dir("zk-srs"); + std::fs::create_dir_all(&dir).expect("create temp SRS dir"); + + KzgPCS::write_zk_srs_to_dir(&pk, &dir, k).expect("write ZK SRS file"); + let path = dir.join("hyperkzg_zk_3.srs"); + assert!(path.exists(), "canonical ZK SRS file should exist"); + + let _loaded = KzgPCS::read_zk_srs_from_dir(&dir, k).expect("read ZK SRS file"); + + std::fs::remove_dir_all(&dir).expect("remove temp SRS dir"); +} + +#[test] +fn plain_loader_rejects_zk_srs_file() { + let pk = make_zk_setup(8); + let dir = temp_dir("plain-rejects-zk"); + std::fs::create_dir_all(&dir).expect("create temp SRS dir"); + let path = dir.join("mismatch.srs"); + + KzgPCS::write_zk_srs_file(&pk, &path).expect("write ZK SRS file"); + let result = KzgPCS::read_srs_file(&path); + + assert!(matches!( + result, + Err(HyperKZGError::SrsFileKindMismatch { .. }) + )); + std::fs::remove_dir_all(&dir).expect("remove temp SRS dir"); +} + +#[test] +fn zk_loader_rejects_plain_srs_file() { + let g1 = Bn254::g1_generator(); + let g2 = Bn254::g2_generator(); + let pk = KzgPCS::setup_from_secret(Fr::from_u64(12345), 8, g1, g2); + let dir = temp_dir("zk-rejects-plain"); + std::fs::create_dir_all(&dir).expect("create temp SRS dir"); + let path = dir.join("mismatch.srs"); + + KzgPCS::write_srs_file(&pk, &path).expect("write plain SRS file"); + let result = KzgPCS::read_zk_srs_file(&path); + + assert!(matches!( + result, + Err(HyperKZGError::SrsFileKindMismatch { .. }) + )); + std::fs::remove_dir_all(&dir).expect("remove temp SRS dir"); +} + +#[test] +fn plain_writer_rejects_zk_setup() { + let pk = make_zk_setup(8); + let dir = temp_dir("plain-writer-rejects-zk"); + std::fs::create_dir_all(&dir).expect("create temp SRS dir"); + let path = dir.join("wrong-writer.srs"); + + let result = KzgPCS::write_srs_file(&pk, &path); + + assert!(matches!( + result, + Err(HyperKZGError::WrongSrsSetupKind { .. }) + )); + std::fs::remove_dir_all(&dir).expect("remove temp SRS dir"); +} diff --git a/crates/jolt-hyperkzg/tests/zk_statistical.rs b/crates/jolt-hyperkzg/tests/zk_statistical.rs new file mode 100644 index 0000000000..aa569fa927 --- /dev/null +++ b/crates/jolt-hyperkzg/tests/zk_statistical.rs @@ -0,0 +1,540 @@ +//! Statistical tests for ZK HyperKZG hiding. + +#![cfg(feature = "zk")] +#![expect( + clippy::cast_precision_loss, + clippy::expect_used, + reason = "statistical tests compute empirical floating-point statistics and fail loudly" +)] + +use std::collections::BTreeMap; + +use jolt_crypto::{Bn254, JoltGroup}; +use jolt_field::{FixedBytes, Fr, FromPrimitiveInt}; +use jolt_hyperkzg::{HyperKZGCommitment, HyperKZGProof, HyperKZGScheme}; +use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme, ZkOpeningScheme}; +use jolt_poly::Polynomial; +use jolt_transcript::{AppendToTranscript, Blake2bTranscript, Transcript}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; +use serde::Serialize; + +const DEFAULT_SAMPLES: usize = 128; +const NUM_BUCKETS: usize = 16; +const MIN_SAMPLES: usize = NUM_BUCKETS * 4; +const CHI2_CRITICAL: f64 = 43.84; +const NUM_VARS: usize = 4; + +type KzgPCS = HyperKZGScheme; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum PolyFamily { + Zero, + Sparse, + Structured, + Random, + Combined, +} + +impl PolyFamily { + fn all() -> [Self; 5] { + [ + Self::Zero, + Self::Sparse, + Self::Structured, + Self::Random, + Self::Combined, + ] + } + + fn name(self) -> &'static str { + match self { + Self::Zero => "zero", + Self::Sparse => "sparse", + Self::Structured => "structured", + Self::Random => "random", + Self::Combined => "combined", + } + } +} + +struct ZkSample { + commitment: HyperKZGCommitment, + proof: HyperKZGProof, + output_blind: Fr, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct StableProofShape { + point_len: usize, + fold_commitments: usize, + y_rows: [usize; 3], + witnesses: usize, +} + +impl StableProofShape { + fn from_proof(point: &[Fr], proof: &HyperKZGProof) -> Self { + let (y, _) = proof + .hidden_evaluation_commitments() + .expect("ZK proof should expose hidden evaluation commitments"); + Self { + point_len: point.len(), + fold_commitments: proof.com.len(), + y_rows: [y[0].len(), y[1].len(), y[2].len()], + witnesses: proof.w.len(), + } + } +} + +#[test] +fn zk_statistical_smoke_components_vary() { + let setup = make_zk_setup(1 << NUM_VARS); + let verifier_setup = KzgPCS::verifier_setup(&setup); + let point = fixed_point(NUM_VARS); + let mut tracker = BucketTracker::new(); + let mut baseline_shape = None; + + for sample_index in 0usize..16 { + let mut rng = ChaCha20Rng::seed_from_u64(10_000 + sample_index as u64); + let poly = polynomial_for_family(PolyFamily::Zero, sample_index, &mut rng); + let sample = prove_family(PolyFamily::Zero, &poly, &point, &setup, sample_index); + + let mut verifier_transcript = Blake2bTranscript::new(b"hyperkzg-zk-stat"); + let _verified_y_out = KzgPCS::verify_zk( + &sample.commitment, + &point, + &sample.proof, + &verifier_setup, + &mut verifier_transcript, + ) + .expect("ZK statistical smoke sample should verify"); + + let shape = StableProofShape::from_proof(&point, &sample.proof); + if let Some(baseline) = &baseline_shape { + assert_eq!(baseline, &shape); + } else { + baseline_shape = Some(shape); + } + collect_sample_statistics("", &sample, &mut tracker); + } + + for name in tracker.names() { + assert!( + unique_sample_count(&tracker.samples[&name]) > 1, + "{name} should vary across repeated ZK samples" + ); + } +} + +#[test] +#[ignore = "run with --release: cargo nextest run -p jolt-hyperkzg hyperkzg_zk_proof_components_are_statistically_independent --features zk --run-ignored ignored-only --cargo-quiet"] +fn hyperkzg_zk_proof_components_are_statistically_independent() { + require_release_build(); + + let samples = statistical_sample_count(); + assert!( + samples >= MIN_SAMPLES, + "JOLT_HYPERKZG_ZK_STAT_SAMPLES must be at least {MIN_SAMPLES}" + ); + + let setup = make_zk_setup(1 << NUM_VARS); + let verifier_setup = KzgPCS::verifier_setup(&setup); + let point = fixed_point(NUM_VARS); + let mut family_trackers = BTreeMap::new(); + let mut even = BucketTracker::new(); + let mut odd = BucketTracker::new(); + let mut baseline_shape = None; + + for family in PolyFamily::all() { + assert!(family_trackers + .insert(family, BucketTracker::new()) + .is_none()); + } + + for sample_index in 0..samples { + for family in PolyFamily::all() { + let mut rng = + ChaCha20Rng::seed_from_u64(20_000 + sample_index as u64 * 17 + family as u64); + let poly = polynomial_for_family(family, sample_index, &mut rng); + let sample = prove_family(family, &poly, &point, &setup, sample_index); + + let mut verifier_transcript = Blake2bTranscript::new(b"hyperkzg-zk-stat"); + let _verified_y_out = KzgPCS::verify_zk( + &sample.commitment, + &point, + &sample.proof, + &verifier_setup, + &mut verifier_transcript, + ) + .expect("ZK statistical sample should verify"); + + let shape = StableProofShape::from_proof(&point, &sample.proof); + if let Some(baseline) = &baseline_shape { + assert_eq!( + baseline, &shape, + "all samples should have a stable public statement and proof shape" + ); + } else { + baseline_shape = Some(shape); + } + + let tracker = family_trackers + .get_mut(&family) + .expect("family tracker should exist"); + collect_sample_statistics(family.name(), &sample, tracker); + + if family == PolyFamily::Zero { + if sample_index.is_multiple_of(2) { + collect_sample_statistics("", &sample, &mut even); + } else { + collect_sample_statistics("", &sample, &mut odd); + } + } + } + } + + for (family, tracker) in &family_trackers { + assert_uniformity(tracker, samples as f64 / NUM_BUCKETS as f64, family.name()); + } + + let baseline = family_trackers + .get(&PolyFamily::Zero) + .expect("zero family tracker should exist"); + for family in [ + PolyFamily::Sparse, + PolyFamily::Structured, + PolyFamily::Random, + PolyFamily::Combined, + ] { + assert_same_distribution( + baseline, + family_trackers + .get(&family) + .expect("family tracker should exist"), + family.name(), + ); + } + assert_same_distribution(&even, &odd, "zero split-half"); +} + +fn make_zk_setup(max_degree: usize) -> jolt_hyperkzg::HyperKZGProverSetup { + let g1 = Bn254::g1_generator(); + let hiding_g1 = g1.scalar_mul(&Fr::from_u64(17)); + let g2 = Bn254::g2_generator(); + KzgPCS::setup_zk_from_secret(Fr::from_u64(12345), max_degree, g1, hiding_g1, g2) +} + +fn fixed_point(num_vars: usize) -> Vec { + (0..num_vars) + .map(|i| Fr::from_u64((i as u64 + 3) * 11)) + .collect() +} + +fn polynomial_for_family( + family: PolyFamily, + sample_index: usize, + rng: &mut ChaCha20Rng, +) -> Polynomial { + let len = 1 << NUM_VARS; + match family { + PolyFamily::Zero => Polynomial::new(vec![Fr::from_u64(0); len]), + PolyFamily::Sparse => { + let mut evals = vec![Fr::from_u64(0); len]; + evals[0] = Fr::from_u64(1); + evals[len / 2] = Fr::from_u64(2); + evals[len - 1] = Fr::from_u64(3); + Polynomial::new(evals) + } + PolyFamily::Structured => Polynomial::new( + (0..len) + .map(|i| { + let x = i as u64; + Fr::from_u64((x * x + 3 * x + 7) % 97) + }) + .collect(), + ), + PolyFamily::Random => Polynomial::::random(NUM_VARS, rng), + PolyFamily::Combined => { + let mut left_rng = ChaCha20Rng::seed_from_u64(30_000 + sample_index as u64); + let mut right_rng = ChaCha20Rng::seed_from_u64(40_000 + sample_index as u64); + let left = Polynomial::::random(NUM_VARS, &mut left_rng); + let right = polynomial_for_family(PolyFamily::Structured, sample_index, &mut right_rng); + let scalar_left = Fr::from_u64(13); + let scalar_right = Fr::from_u64(29); + let evals = left + .evaluations() + .iter() + .zip(right.evaluations()) + .map(|(&lhs, &rhs)| scalar_left * lhs + scalar_right * rhs) + .collect(); + Polynomial::new(evals) + } + } +} + +fn prove_family( + family: PolyFamily, + poly: &Polynomial, + point: &[Fr], + setup: &jolt_hyperkzg::HyperKZGProverSetup, + sample_index: usize, +) -> ZkSample { + let eval = poly.evaluate(point); + let mut prover_transcript = Blake2bTranscript::new(b"hyperkzg-zk-stat"); + + if family == PolyFamily::Combined { + let mut left_rng = ChaCha20Rng::seed_from_u64(30_000 + sample_index as u64); + let mut right_rng = ChaCha20Rng::seed_from_u64(40_000 + sample_index as u64); + let left = Polynomial::::random(NUM_VARS, &mut left_rng); + let right = polynomial_for_family(PolyFamily::Structured, sample_index, &mut right_rng); + let scalar_left = Fr::from_u64(13); + let scalar_right = Fr::from_u64(29); + + let (left_commitment, left_hint) = + ::commit_zk(left.evaluations(), setup); + let (right_commitment, right_hint) = + ::commit_zk(right.evaluations(), setup); + let commitment = ::combine( + &[left_commitment, right_commitment], + &[scalar_left, scalar_right], + ); + let hint = ::combine_hints( + vec![left_hint, right_hint], + &[scalar_left, scalar_right], + ); + let (proof, _y_out, output_blind) = + KzgPCS::open_zk(poly, point, eval, setup, hint, &mut prover_transcript); + return ZkSample { + commitment, + proof, + output_blind, + }; + } + + let (commitment, hint) = ::commit_zk(poly.evaluations(), setup); + let (proof, _y_out, output_blind) = + KzgPCS::open_zk(poly, point, eval, setup, hint, &mut prover_transcript); + ZkSample { + commitment, + proof, + output_blind, + } +} + +fn collect_sample_statistics(prefix: &str, sample: &ZkSample, tracker: &mut BucketTracker) { + let component_prefix = |name: &str| { + if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}.{name}") + } + }; + + tracker.record_serde(component_prefix("commitment"), &sample.commitment); + tracker.record_append_positions(&component_prefix("fold_com"), &sample.proof.com); + + let (y, y_out) = sample + .proof + .hidden_evaluation_commitments() + .expect("ZK proof should expose hidden evaluation commitments"); + tracker.record_append_positions(&component_prefix("y_r"), &y[0]); + tracker.record_append_positions(&component_prefix("y_neg_r"), &y[1]); + tracker.record_append_positions(&component_prefix("y_r2"), &y[2]); + tracker.record_append(component_prefix("y_out"), y_out); + tracker.record_append_positions(&component_prefix("witness"), &sample.proof.w); + tracker.record_append(component_prefix("output_blind"), &sample.output_blind); +} + +#[derive(Clone, Debug, Default)] +struct BucketTracker { + buckets: BTreeMap>, + samples: BTreeMap>, +} + +impl BucketTracker { + fn new() -> Self { + Self::default() + } + + fn record_append(&mut self, name: impl Into, value: &A) { + let name = name.into(); + let mut transcript = Blake2bTranscript::::new(b"hkzg-zk-stat"); + transcript.append_bytes(name.as_bytes()); + value.append_to_transcript(&mut transcript); + self.record_projected(name, field_low_u64(transcript.challenge())); + } + + fn record_append_positions(&mut self, prefix: &str, values: &[A]) { + for index in selected_positions(values.len()) { + self.record_append(format!("{prefix}.{index}"), &values[index]); + } + } + + fn record_serde(&mut self, name: impl Into, value: &A) { + let name = name.into(); + let bytes = bincode::serde::encode_to_vec(value, bincode::config::standard()) + .expect("statistical component serialization should succeed"); + self.record_bytes(name, &bytes); + } + + fn record_bytes(&mut self, name: String, bytes: &[u8]) { + let mut transcript = Blake2bTranscript::::new(b"hkzg-zk-stat"); + transcript.append_bytes(name.as_bytes()); + transcript.append_bytes(bytes); + self.record_projected(name, field_low_u64(transcript.challenge())); + } + + fn record_projected(&mut self, name: String, value: u64) { + self.buckets + .entry(name.clone()) + .or_insert_with(|| vec![0; NUM_BUCKETS])[(value as usize) % NUM_BUCKETS] += 1; + self.samples.entry(name).or_default().push(value); + } + + fn names(&self) -> Vec { + self.buckets.keys().cloned().collect() + } + + fn chi_squared(&self, name: &str, expected: f64) -> f64 { + self.buckets[name] + .iter() + .map(|&observed| { + let delta = observed as f64 - expected; + delta * delta / expected + }) + .sum() + } +} + +fn selected_positions(len: usize) -> Vec { + match len { + 0 => Vec::new(), + 1 => vec![0], + 2 => vec![0, 1], + _ => { + let mut positions = vec![0, len / 2, len - 1]; + positions.dedup(); + positions + } + } +} + +fn field_low_u64(value: Fr) -> u64 { + let bytes = value.to_bytes_array(); + u64::from_le_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ]) +} + +fn assert_uniformity(tracker: &BucketTracker, expected: f64, context: &str) { + let mut failures = Vec::new(); + for name in tracker.names() { + let unique = unique_sample_count(&tracker.samples[&name]); + let minimum_unique = tracker.samples[&name].len() * 90 / 100; + if unique < minimum_unique { + failures.push(format!( + "{context}:{name}: only {unique} unique projected samples out of {}", + tracker.samples[&name].len() + )); + continue; + } + + let chi2 = tracker.chi_squared(&name, expected); + if chi2 >= CHI2_CRITICAL { + failures.push(format!( + "{context}:{name}: chi2={chi2:.2} >= {CHI2_CRITICAL:.2}" + )); + } + } + + assert!( + failures.is_empty(), + "HyperKZG ZK statistical uniformity check failed for {} projected components:\n{}", + failures.len(), + failures.join("\n") + ); +} + +fn assert_same_distribution(lhs: &BucketTracker, rhs: &BucketTracker, context: &str) { + let mut failures = Vec::new(); + for name in lhs.names() { + let rhs_name = counterpart_name(&name, context); + let Some(rhs_buckets) = rhs.buckets.get(&rhs_name) else { + continue; + }; + let chi2 = two_sample_chi_squared(&lhs.buckets[&name], rhs_buckets); + if chi2 >= CHI2_CRITICAL { + failures.push(format!( + "{context}:{name}: two-sample chi2={chi2:.2} >= {CHI2_CRITICAL:.2}" + )); + } + } + + assert!( + failures.is_empty(), + "HyperKZG ZK statistical two-sample check failed for {} projected components:\n{}", + failures.len(), + failures.join("\n") + ); +} + +fn counterpart_name(lhs_name: &str, rhs_family_name: &str) -> String { + let Some((_, suffix)) = lhs_name.split_once('.') else { + return lhs_name.to_string(); + }; + format!("{rhs_family_name}.{suffix}") +} + +fn unique_sample_count(values: &[u64]) -> usize { + let mut values = values.to_vec(); + values.sort_unstable(); + values.dedup(); + values.len() +} + +fn two_sample_chi_squared(a: &[usize], b: &[usize]) -> f64 { + let n_a = a.iter().sum::() as f64; + let n_b = b.iter().sum::() as f64; + let n_total = n_a + n_b; + + a.iter() + .zip(b) + .map(|(&observed_a, &observed_b)| { + let pooled = observed_a as f64 + observed_b as f64; + if pooled < 1.0 { + return 0.0; + } + let expected_a = pooled * n_a / n_total; + let expected_b = pooled * n_b / n_total; + let term_a = if expected_a > 0.0 { + (observed_a as f64 - expected_a).powi(2) / expected_a + } else { + 0.0 + }; + let term_b = if expected_b > 0.0 { + (observed_b as f64 - expected_b).powi(2) / expected_b + } else { + 0.0 + }; + term_a + term_b + }) + .sum() +} + +fn require_release_build() { + assert!( + !(cfg!(debug_assertions) + && std::env::var_os("JOLT_HYPERKZG_ALLOW_DEBUG_STAT_TESTS").is_none()), + "run this statistical test with --release, or set JOLT_HYPERKZG_ALLOW_DEBUG_STAT_TESTS=1" + ); +} + +fn statistical_sample_count() -> usize { + std::env::var("JOLT_HYPERKZG_ZK_STAT_SAMPLES") + .ok() + .map_or(DEFAULT_SAMPLES, |value| { + value + .parse::() + .expect("JOLT_HYPERKZG_ZK_STAT_SAMPLES must be a positive integer") + }) +} diff --git a/specs/hyperkzg-zk.md b/specs/hyperkzg-zk.md new file mode 100644 index 0000000000..5d2626babd --- /dev/null +++ b/specs/hyperkzg-zk.md @@ -0,0 +1,1428 @@ +# Spec: ZK HyperKZG + +| Field | Value | +|-------|-------| +| Author(s) | Markos Georghiades, Codex | +| Created | 2026-05-26 | +| Status | exploratory draft | +| PR | TBD | + +## Purpose + +This spec describes zero-knowledge variants of `jolt-hyperkzg`. + +The goal is to keep HyperKZG's Gemini reduction and KZG batching structure, but +make the transcript-visible commitment and opening data statistically hiding: + +```text +plain HyperKZG: + commitments to folded polynomials + raw scalar evaluations v[i][u] + KZG witnesses + +ZK HyperKZG: + hiding commitments to folded polynomials + hiding commitments to evaluations + hiding KZG witnesses + no raw opened evaluations +``` + +V1 is retained as the conservative full-mask reference construction. The +implementation target is V2 only: it keeps commitments hiding while avoiding +the full second mask-polynomial prover cost. There is no requirement to preserve +V1 opening-hint or prover-path compatibility. + +## Background + +Plain HyperKZG uses Gemini to reduce a multilinear opening claim to univariate +KZG openings. For a multilinear table of size `N = 2^ell`, define: + +```text +P_0 = original evaluation table, interpreted as a univariate coefficient vector +P_{i+1} = Fold_{x_i}(P_i) +``` + +where: + +```text +Fold_x(A)[j] = A[2j] + x * (A[2j + 1] - A[2j]) +``` + +After all folds, `P_ell` is the claimed multilinear evaluation at the opening +point `x`. + +Gemini commits to `P_1, ..., P_{ell-1}`, derives a Fiat-Shamir challenge `r`, +opens all `P_i` at: + +```text +r, -r, r^2 +``` + +and checks that adjacent folds are consistent. In the current plain protocol, +those checks use raw scalar evaluations. That is not zero-knowledge. + +## V1 Design Intuition + +Gemini's verifier checks are linear. Therefore they can be checked +homomorphically over hiding commitments. + +The prover samples a random mask table: + +```text +B_0 +``` + +and folds it with the same Gemini folds: + +```text +B_{i+1} = Fold_{x_i}(B_i) +``` + +The prover commits to each pair `(P_i, B_i)` with a Pedersenized KZG commitment: + +```text +C_i = P_i(beta) * G + B_i(beta) * H +``` + +Evaluation values are also hidden: + +```text +Y_{i,u} = P_i(u) * G + B_i(u) * H +``` + +Since the masks satisfy the same fold equations as the real values, Gemini's +linear checks can be performed directly on the `Y` group elements. + +## V1 SRS + +ZK HyperKZG needs a structured reference string with two independent G1 power +sequences over the same KZG trapdoor: + +```text +G_i = beta^i * G +H_i = beta^i * H +``` + +and ordinary KZG G2 powers: + +```text +K +beta * K +``` + +The prover SRS for capacity `2^k` contains: + +```text +G_0, ..., G_{2^k} +H_0, ..., H_{2^k} +K, beta * K +``` + +The verifier SRS needs enough public data to verify pairings and expose the +evaluation-commitment base to callers: + +```text +G_0 +H_0 +K +beta * K +``` + +`H` must be an independent generator. The ceremony must destroy the KZG +trapdoor `beta`; no production prover or verifier should see it. + +Plain HyperKZG keeps the current canonical filename: + +```text +hyperkzg_{k}.srs +``` + +The ZK variant should use a separate SRS artifact: + +```text +hyperkzg_zk_{k}.srs +``` + +where `k` is the exponent for `2^k` supported evaluations. This mirrors the +plain SRS naming convention while making the ceremony output explicit: the ZK +SRS contains two structured G1 power sequences plus the G2 powers needed for +hidden KZG verification, while the plain SRS contains only the non-hiding +material. The serialized file format must still be versioned with a name +header, mode, and capacity field. A ZK production loader should reject a plain +`hyperkzg_{k}.srs` rather than silently upgrading or accepting it. + +The file envelope should include an explicit discriminant: + +```text +name: "HYPERKZG_SRS" +version: 1 +kind: Plain | Zk +k: supported exponent +capacity: supported evaluation count +payload: plain or ZK setup material +``` + +`read_srs_from_dir(dir, k)` loads `hyperkzg_{k}.srs` and requires +`kind = Plain`. `read_zk_srs_from_dir(dir, k)` loads `hyperkzg_zk_{k}.srs` and +requires `kind = Zk`. Both paths should reject mismatched discriminants. + +## V1 Commitment + +For a polynomial/table `P_0` of length `N = 2^ell`, sample a uniform random mask +table `B_0` of the same length. + +Commit: + +```text +C_0 = sum_j P_0[j] * G_j + sum_j B_0[j] * H_j +``` + +The commitment is statistically hiding in `P_0` because `B_0` is uniform and the +`H_j` sequence is independent of the `G_j` sequence. + +`commit_zk` must return an opening hint containing the mask material needed by +`open_zk`. The simplest hint is `B_0`. More memory-efficient hints, such as a +private seed expanded deterministically by the prover, are possible but weaken +the information-theoretic proof unless modeled carefully. + +## V1 Opening + +Input: + +```text +P_0 +B_0 +opening point x = (x_0, ..., x_{ell-1}) +commitment C_0 +``` + +The prover computes folded chains: + +```text +P_{i+1} = Fold_{x_i}(P_i) +B_{i+1} = Fold_{x_i}(B_i) +``` + +The hidden final evaluation commitment is: + +```text +Y_out = P_ell * G_0 + B_ell * H_0 +``` + +The value `B_ell` is the output blinding returned to the prover-side caller. +This mirrors Dory ZK mode's `y_com` / `y_blinding` split. + +The prover commits to intermediate folds: + +```text +C_i = P_i(beta) * G + B_i(beta) * H +for i = 1..ell-1 +``` + +Then it appends the opening point, `Y_out`, and the intermediate commitments, +and derives Gemini challenge `r`. `Y_out` must be included before `r` because +it participates in the final Gemini fold equation. + +For each `i = 0..ell-1`, define: + +```text +Y_i^+ = P_i(r) * G_0 + B_i(r) * H_0 +Y_i^- = P_i(-r) * G_0 + B_i(-r) * H_0 +Y_i^sq = P_i(r^2) * G_0 + B_i(r^2) * H_0 +``` + +These are appended to the transcript. The verifier later checks Gemini fold +consistency directly on these group elements. + +## V1 Hidden KZG Opening + +For a single hidden polynomial pair `(P, B)` and point `u`, the opening relation +is: + +```text +C - Y_u + = (P(beta) - P(u)) * G + (B(beta) - B(u)) * H +``` + +The witness is: + +```text +W_u = + ((P(X) - P(u)) / (X - u))(beta) * G ++ ((B(X) - B(u)) / (X - u))(beta) * H +``` + +The verifier checks: + +```text +e(C - Y_u, K) = e(W_u, beta*K - u*K) +``` + +Equivalently: + +```text +e(C - Y_u + u*W_u, K) = e(W_u, beta*K) +``` + +This is the normal KZG pairing equation with the scalar evaluation replaced by +a hiding evaluation commitment. + +## V1 Batching + +ZK HyperKZG should preserve the existing batching shape. + +After appending all `Y` commitments, derive a batching challenge `q` and combine +all folded polynomials: + +```text +P_q = sum_i q^i * P_i +B_q = sum_i q^i * B_i +C_q = sum_i q^i * C_i +``` + +For each audit point: + +```text +u_0 = r +u_1 = -r +u_2 = r^2 +``` + +compute: + +```text +Y_q,u = sum_i q^i * Y_i,u +W_u = hidden KZG witness for (P_q, B_q) at u +``` + +After appending `W_{u_0}, W_{u_1}, W_{u_2}`, derive a point-batching challenge +`d`. The verifier forms: + +```text +L = sum_t d^t * (C_q - Y_q,u_t + u_t * W_u_t) +R = sum_t d^t * W_u_t +``` + +and checks: + +```text +e(L, K) = e(R, beta*K) +``` + +This gives one constant-size multi-pairing check for all `3 * ell` hidden +opening claims. + +## V1 Gemini Group Check + +Plain Gemini checks, for each fold: + +```text +2*r * P_{i+1}(r^2) + = r*(1 - x_i)*(P_i(r) + P_i(-r)) + + x_i*(P_i(r) - P_i(-r)) +``` + +ZK Gemini checks the same equation in G1: + +```text +2*r * Y_next^sq + = r*(1 - x_i)*(Y_i^+ + Y_i^-) + + x_i*(Y_i^+ - Y_i^-) +``` + +where: + +```text +Y_next^sq = Y_{i+1}^sq if i + 1 < ell +Y_next^sq = Y_out if i + 1 = ell +``` + +This works because both the value side and the mask side satisfy the same fold +relation. + +## V1 Proof Object + +For `N = 2^ell`, a ZK HyperKZG proof contains: + +```text +com: C_1, ..., C_{ell-1} // ell - 1 G1 +y_pos: Y_0^+, ..., Y_{ell-1}^+ // ell G1 +y_neg: Y_0^-, ..., Y_{ell-1}^- // ell G1 +y_sq: Y_0^sq, ..., Y_{ell-1}^sq // ell G1 +y_out: Y_out // 1 G1 +w: W_r, W_-r, W_r2 // 3 G1 +``` + +Total proof size: + +```text +(ell - 1) + 3*ell + 1 + 3 = 4*ell + 3 G1 elements +``` + +For `ell = 20`: + +```text +83 G1 elements +``` + +With 32-byte compressed BN254 G1 encodings: + +```text +83 * 32 = 2656 bytes +``` + +The verifier returns `y_out` as the hiding commitment to the evaluation. The +prover-side API also returns `B_ell` as the output blinding. + +## V1 Verifier Work + +Verifier work remains logarithmic in the evaluation table size: + +```text +N = 2^ell +verifier group work = O(ell) +pairing work = O(1) +``` + +More concretely: + +```text +Gemini group checks: + about 3*ell variable-base scalar multiplications + +commitment batching: + ell scalar multiplications for C_q + 3*ell scalar multiplications for Y_q,u + +hidden KZG point batching: + O(1) scalar multiplications over W_r, W_-r, W_r2 + +pairing: + one constant-size multi-pairing check +``` + +For `ell = 20`, this is roughly: + +```text +about 7*ell + O(1) = about 145 G1 scalar-mul equivalents +plus one constant-size multi-pairing +``` + +This is heavier than plain HyperKZG by constants because scalar Gemini checks +become group checks, but the asymptotic verifier time is unchanged. + +## V1 Prover Work + +Prover work remains linear in `N`: + +```text +fold P chain: O(N) field work +fold B chain: O(N) field work +commit folded P/B pairs: O(N) group MSM work, roughly 2x plain +build batched witnesses: O(N) field and group work, roughly 2x plain +``` + +The straightforward implementation materializes both folded chains. A later +implementation can optimize memory by streaming folds and retaining only the +data needed for commitments, evaluation commitments, and batched witnesses. + +## V2: Commitment Blinding + KZG Proof Randomization + +V1 is conservative but expensive: every hidden commitment is a commitment to +both `P_i` and a full folded mask polynomial `B_i`. V2 keeps the same verifier +equations and proof shape, but changes the prover-side hiding method. This is +the implemented ZK HyperKZG path. + +Instead of a full mask polynomial, each Gemini commitment gets one scalar +blinder: + +```text +C_i' = C_i + rho_i * H +``` + +where: + +```text +C_i = P_i(beta) * G +H_0 = H +H_1 = beta * H +``` + +For an opening at point `u`, start from the transparent KZG witness: + +```text +W_i,u = ((P_i(X) - P_i(u)) / (X - u))(beta) * G +``` + +Then randomize the witness by one scalar `tau_i,u`: + +```text +W_i,u' = W_i,u + tau_i,u * H +``` + +The corresponding hidden evaluation commitment is: + +```text +Y_i,u' = + P_i(u) * G ++ (rho_i + u * tau_i,u) * H +- tau_i,u * H_1 +``` + +The KZG equation is preserved: + +```text +C_i' - Y_i,u' + u * W_i,u' = beta * W_i,u' +``` + +because the `H` coefficient cancels and the remaining `H_1` coefficient equals +`tau_i,u`. + +V2 therefore hides: + +```text +commitments: with rho_i * H +evaluations: with rho_i, tau_i,u, and H_1 +witnesses: with tau_i,u * H +``` + +without committing to or opening a full random mask polynomial. + +Information-theoretically, V2 is the BlindFold-style idea applied directly to +HyperKZG's transcript solution space. V1 samples a random preimage in the large +mask-polynomial space and maps it through Gemini/KZG. V2 samples only the +minimal visible degrees of freedom needed by the verifier relations, then solves +the linear constraints so the resulting transcript is a random satisfying +transcript. + +## V2 SRS + +V2 requires less hiding SRS material than V1: + +```text +G_0, ..., G_{2^k} +H_0 = H +H_1 = beta * H +K +beta * K +``` + +The existing V1 ZK SRS with the full `H_i = beta^i * H` sequence is sufficient +for V2, but not minimal. A later implementation can either: + +- keep using `hyperkzg_zk_{k}.srs` and read only `H_0, H_1`, or +- introduce an explicit reduced V2 SRS kind if ceremony size matters. + +In both cases the ceremony must destroy `beta` and must not reveal the +discrete-log relation between `G` and `H`. + +## V2 Commitment + +For the original table: + +```text +C_0' = sum_j P_0[j] * G_j + rho_0 * H +``` + +`commit_zk` returns `C_0'` and an opening hint containing `rho_0`. The hint is a +single scalar, not a full mask table. Homomorphic combination of hints is just: + +```text +rho_joint = sum_i gamma_i * rho_i +``` + +This preserves the existing `AdditivelyHomomorphic` interface for stage-8 +joint openings. + +## V2 Opening Protocol + +Input: + +```text +P_0 +rho_0 +opening point x = (x_0, ..., x_{ell-1}) +commitment C_0' +``` + +The prover computes the ordinary folded chain: + +```text +P_{i+1} = Fold_{x_i}(P_i) +``` + +For `i = 1..ell-1`, sample fresh commitment blinders `rho_i` and send: + +```text +C_i' = P_i(beta) * G + rho_i * H +``` + +Also sample an output blinder `lambda` and send: + +```text +Y_out = P_ell * G + lambda * H +``` + +Append the opening point, `Y_out`, and `C_1'..C_{ell-1}'`, then derive Gemini +challenge `r`. Define audit points: + +```text +u_+ = r +u_- = -r +u_sq = r^2 +``` + +After `r` is known, sample free variables: + +```text +tau_i,sq for i = 0..ell-1 +``` + +Then solve for: + +```text +tau_i,+ and tau_i,- +``` + +so that the `H` and `H_1` coefficients satisfy the Gemini fold equations. + +For any point `u`, define: + +```text +a_i,u = rho_i + u * tau_i,u +b_i,u = -tau_i,u + +Y_i,u' = P_i(u) * G + a_i,u * H + b_i,u * H_1 +``` + +For each fold level `i`, let: + +```text +next_a = + a_{i+1,sq} if i + 1 < ell + lambda if i + 1 = ell + +next_b = + b_{i+1,sq} if i + 1 < ell + 0 if i + 1 = ell +``` + +The prover chooses `tau_i,+, tau_i,-` to satisfy: + +```text +2*r * next_a = + r*(1 - x_i)*(a_i,+ + a_i,-) ++ x_i*(a_i,+ - a_i,-) + +2*r * next_b = + r*(1 - x_i)*(b_i,+ + b_i,-) ++ x_i*(b_i,+ - b_i,-) +``` + +This is a `2 x 2` linear system in `tau_i,+, tau_i,-` once `rho_i` and +the next hidden coefficients are fixed. For the final fold those next +coefficients are determined by `lambda`; otherwise they are determined by +`rho_{i+1}` and the sampled free variable `tau_{i+1,sq}`. + +The system determinant is: + +```text +-2*r * (r^2*(1 - x_i)^2 - x_i^2) +``` + +So it is invertible except for `r = 0` or `r*(1 - x_i) = +/- x_i`. These are +Fiat-Shamir degeneracies with negligible probability. A prover-side API that +cannot return errors may panic on this event; a fallible implementation should +return a degenerate-challenge error. + +After solving, the prover appends: + +```text +Y_i,+ = Y_i,r' +Y_i,- = Y_i,-r' +Y_i,sq = Y_i,r^2' +``` + +for all `i`, then derives batching challenge `q`. + +## V2 Batched KZG Opening + +As in plain HyperKZG, combine folded polynomials: + +```text +P_q = sum_i q^i * P_i +C_q' = sum_i q^i * C_i' +``` + +For each audit point `u`, define: + +```text +tau_q,u = sum_i q^i * tau_i,u +Y_q,u' = sum_i q^i * Y_i,u' +``` + +Compute the transparent batched witness: + +```text +W_q,u = ((P_q(X) - P_q(u)) / (X - u))(beta) * G +``` + +and send the randomized witness: + +```text +W_q,u' = W_q,u + tau_q,u * H +``` + +The verifier uses exactly the same hidden KZG batch equation as V1: + +```text +L = sum_t d^t * (C_q' - Y_q,u_t' + u_t * W_q,u_t') +R = sum_t d^t * W_q,u_t' + +e(L, K) = e(R, beta*K) +``` + +The proof object is unchanged from V1: + +```text +com: C_1', ..., C_{ell-1}' +y_pos: Y_{0,+}, ..., Y_{ell-1,+} +y_neg: Y_{0,-}, ..., Y_{ell-1,-} +y_sq: Y_{0,sq}, ..., Y_{ell-1,sq} +y_out: Y_out +w: W_q,r', W_q,-r', W_q,r2' +``` + +The verifier does not need to know whether the prover used V1 full-mask hiding +or V2 proof randomization. Both constructions produce group elements satisfying +the same Gemini and hidden-KZG equations. If both prover modes remain +implemented, the proof payload can stay the same; the opening hint should carry +the mode-specific private data. + +## V2 Prover Work + +V2 removes the full second mask-polynomial path: + +```text +fold P chain: O(N) field work +commit folded P_i: same MSMs as transparent +transparent batched witnesses: same as transparent +commitment blinds: O(ell) scalar muls by H +evaluation randomizers: O(ell) scalar arithmetic + O(ell) G1 ops +witness randomizers: O(1) scalar muls by H +``` + +The expected prover cost is therefore close to transparent HyperKZG plus +logarithmic overhead, instead of V1's roughly `2x` large-MSM work. + +Verifier work is essentially the same as V1 because the verifier still receives +group-valued hidden evaluations and checks Gemini in G1. V2 primarily improves +prover time and prover memory. + +The verifier should batch the group-valued checks: + +```text +after absorbing all Y_i,u: + derive alpha and check one random-linear Gemini residual MSM + derive q for hidden KZG batching + +after absorbing all W_u: + derive d and build the hidden-KZG pairing L side with one direct MSM +``` + +The important transcript condition is that all hidden evaluation commitments +`Y_i(r), Y_i(-r), Y_i(r^2)` and `Y_out` are bound before deriving `alpha`. +This gives the batched Gemini check the usual Schwartz-Zippel soundness loss +without letting the prover choose `Y` after seeing the batching randomness. + +## V2 Hiding Sketch + +For a fixed Fiat-Shamir transcript prefix and challenge `r`, the V2 prover +samples: + +```text +rho_0, ..., rho_{ell-1} +lambda +tau_0,sq, ..., tau_{ell-1,sq} +``` + +and derives `tau_i,+, tau_i,-` by solving the Gemini coefficient equations. +Except for the negligible degenerate `r` values above, this is an affine +bijection between the sampled variables and the space of `H/H_1` coefficients +that satisfy all Gemini equations with `Y_out` constrained to have output blind +`lambda`. + +Thus the visible proof elements are: + +```text +transparent P-dependent G components ++ a uniformly random point in the linear solution space generated by H and H_1 +``` + +Adding the fixed `P`-dependent component only shifts that solution-space +distribution. Because the transparent `P`-dependent tuple itself satisfies the +same homogeneous Gemini and KZG group relations, that shift is by an element of +the same solution space. The distribution is therefore identical for every +polynomial, modulo the public relations the verifier checks. The verifier learns +the public Gemini and KZG relations, but not the scalar evaluations or +commitment contents. + +Commitments are statistically hiding because each `C_i'` is shifted by an +independent uniform `rho_i * H`. `Y_out` is statistically hiding because it is +shifted by uniform `lambda * H`. + +The KZG witnesses do not need independent full-polynomial masks. Once `Y_q,u'` +and `C_q'` are fixed, the randomized witness: + +```text +W_q,u' = W_q,u + tau_q,u * H +``` + +is the unique honest witness satisfying the hidden KZG equation. Its randomness +is exactly the same `tau` randomness already used in the corresponding +evaluation commitments, so it reveals only the public KZG opening relation. + +The Fiat-Shamir hiding argument follows the same prefix-by-prefix structure as +V1: every challenge is derived from statistically hiding prior messages, so the +challenge distribution is independent of the committed polynomial. + +## V2 Soundness Sketch + +The verifier checks the same equations as V1: + +1. Gemini consistency over group-valued hidden evaluations. +2. One random-linear batched hidden KZG pairing check. + +If a prover forges a hidden KZG opening for some `C_i'`, `Y_i,u'`, and `W_i,u'`, +then it has produced group elements satisfying: + +```text +C_i' - Y_i,u' = (beta - u) * W_i,u' +``` + +without knowing `beta`. As in KZG, this is binding except with negligible +probability under the structured reference string assumption. Random-linear +batching in `q` and `d` preserves the usual Schwartz-Zippel soundness loss. + +If the hidden KZG openings are sound, then each `Y_i,u'` is bound to the opened +hidden commitment at the audit point. The Gemini group equations then imply +that the folded chain is consistent with the returned `Y_out`, except with the +same Gemini audit soundness error as plain HyperKZG. + +The soundness model excludes: + +```text +knowledge of beta +knowledge of log_G(H) +ability to program Fiat-Shamir challenges +degenerate r values where the V2 coefficient solve is singular +``` + +These are the same setup and Fiat-Shamir assumptions already required by the +KZG-based construction. + +## Trait Integration + +The natural integration point is `jolt_openings::ZkOpeningScheme`: + +```text +type HidingCommitment = P::G1 +type Blind = P::ScalarField + +commit_zk: + samples rho_0 and returns a hint carrying rho_0 + +open_zk: + computes ZK Gemini proof + returns (proof, Y_out, lambda) + +verify_zk: + verifies group Gemini checks and hidden KZG batch check + returns Y_out + +bind_zk_opening_inputs: + binds opening point and Y_out, not the raw scalar evaluation +``` + +As with Dory's `y_com`, the HyperKZG proof should carry `Y_out`. `verify_zk` +checks that commitment as part of the proof, then returns it through the generic +`ZkOpeningScheme` API for the outer transcript or wrapper protocol. + +For V2, `lambda` is the output blinding for: + +```text +Y_out = P_ell * G + lambda * H +``` + +This preserves the generic `Blind = P::ScalarField` API while avoiding a full +mask-table opening hint. + +The PCS should keep `HidingCommitment = P::G1`. BlindFold, wrapper SNARKs, and +other consumers should remain generic over the relevant `jolt-crypto` group +types and resolve `P::G1` at concrete instantiation time. HyperKZG should not add +an adapter commitment type solely to satisfy the current Dory-shaped +`VC::Output` coupling. + +The existing non-ZK `CommitmentScheme` implementation should remain available +for transparent mode unless the crate later decides to make ZK mode the only +production path. + +## Implementation Architecture + +Use one `HyperKZGScheme

` implementation with transparent and ZK behavior +selected by a small internal mode abstraction. This mirrors the organization of +`dory-pcs`, where the same protocol code is parameterized by a `Mode`, and the +Jolt wrapper maps transparent trait methods to `Transparent` and ZK trait +methods to `Zk`. + +```text +commit -> commit_with_mode:: +open -> open_with_mode:: +verify -> verify transparent proof payload + +commit_zk -> commit_with_mode:: +open_zk -> open_with_mode:: +verify_zk -> verify ZK proof payload and return Y_out +``` + +The internal mode should be lightweight: + +```text +trait Mode { + const HIDING: bool + sample_opening_hint(len) -> none or rho_0 + require_opening_hint(hint) -> mode-specific private opening data +} + +Transparent: + HIDING = false + no blind + +Zk: + HIDING = true + samples uniform rho_0 +``` + +The public setup and proof types stay unified, but carry optional ZK material: + +```text +HyperKZGProverSetup: + g1_powers + g2_powers + hiding_g1_powers: optional beta^i * H + +HyperKZGVerifierSetup: + g1 + g2 + beta_g2 + hiding_g1: optional H + +HyperKZGOpeningHint: + none | ZkBlind(rho_0) + +HyperKZGProof: + com + w + payload: + Clear { v } + Zk { y, y_out } +``` + +The proof payload should always use an explicit `Clear | Zk` discriminant in the +serialized type. This is less footgun-prone than cfg-gating the wire shape: clear +verification can reject a `Zk` payload, ZK verification can reject a `Clear` +payload, and non-ZK builds do not silently reinterpret bytes under a different +schema. The `zk` feature gates construction and verification machinery, not the +basic ability to deserialize and reject the wrong payload mode. + +`commit` returns a transparent hint with no private opening data. `commit_zk` +requires a ZK-capable SRS and returns mode-specific hiding data. Stage-8 +batching must combine hints homomorphically. + +```text +P_joint = sum_i gamma_i * P_i +rho_joint = sum_i gamma_i * rho_i +C_joint' = sum_i gamma_i * C_i' +``` + +Thus `combine_hints` returns no private data if all inputs are transparent, +returns the linear combination of scalar blinds if all inputs are ZK, and +rejects mixed transparent/ZK hints as a caller precondition violation. + +Error policy: + +```text +prover-side APIs: + may panic on caller-precondition failures that cannot be represented by the + trait, e.g. commit_zk with a plain SRS, open_zk without the right hint kind, + mixed transparent/ZK hints, or the negligible V2 singular linear solve if the + trait remains infallible + +verifier-side APIs: + must not panic on proof, transcript, setup, or payload errors; return + HyperKZGError/OpeningsError for wrong discriminants, missing ZK SRS material, + malformed lengths, failed Gemini checks, or failed pairing checks +``` + +The `jolt-hyperkzg` crate should expose a local feature flag: + +```toml +[features] +default = [] +zk = [] +``` + +The `zk` feature gates only the ZK surface area: the `Zk` mode, the +`ZkOpeningScheme` implementation, ZK SRS import/export helpers, and ZK +construction helpers. It should not gate the Gemini folding logic, clear +`CommitmentScheme` implementation, shared KZG helpers, or the proof payload +discriminant. The workspace-level `zk` feature should enable +`jolt-hyperkzg/zk` when HyperKZG is wired into a ZK prover. + +## Transcript Order + +A candidate proof-internal transcript order: + +```text +proof verification: + append opening point + append Y_out + append intermediate commitments C_1..C_{ell-1} + derive r + append Y_i^+, Y_i^-, Y_i^sq for all i + derive alpha for batched Gemini group check + derive q + append W_r, W_-r, W_r2 + derive d + verify batched Gemini group equation + verify batched hidden KZG pairing equation +``` + +The outer `ZkOpeningScheme::bind_zk_opening_inputs` call should still bind the +opening point and `Y_out` into the Jolt transcript after `verify_zk` returns +`Y_out`, matching the current stage-8 flow. That outer binding is separate from +the proof-internal absorption above. + +Use canonical labels that name the protocol part and separate clear scalar +evaluations from hidden evaluation commitments: + +```text +clear outer binding: + hyperkzg_opening_point + hyperkzg_opening_eval + +ZK proof-internal transcript: + hyperkzg_zk_point + hyperkzg_zk_y_out + hyperkzg_zk_fold_com + hyperkzg_zk_gemini + hyperkzg_zk_eval_r + hyperkzg_zk_eval_neg_r + hyperkzg_zk_eval_r2 + hyperkzg_zk_gemini_batch + hyperkzg_zk_eval_batch + hyperkzg_zk_wit_r + hyperkzg_zk_wit_nr + hyperkzg_zk_wit_r2 + hyperkzg_zk_wit_batch + +ZK outer binding: + hyperkzg_zk_point + hyperkzg_zk_eval_com +``` + +Vector labels should include a count, following the existing +`LabelWithCount` pattern. Labels must stay within the transcript library's +24-byte label limit. The verifier should use the same labels and reject +payload-mode mismatches before absorbing mode-specific proof elements. + +## Security Target + +Binding remains computational and KZG-style: + +```text +no adversary knowing only the public SRS can open one commitment to two +different polynomial/evaluation statements, except with negligible probability +under the KZG binding assumption and discrete-log binding of the G/H bases +``` + +Hiding should be statistical for transcript-visible values: + +```text +Given the public SRS and public hidden output commitment Y_out, the distribution +of C_i, Y_i^+, Y_i^-, Y_i^sq, and W_u should be independent of P_0, except for +the public linear relations checked by the verifier. +``` + +For V1, the intended reason is that all visible scalar linear functionals of +`P_0` are masked by corresponding linear functionals of a uniform random `B_0`. +For V2, the intended reason is that commitments are independently shifted by +`rho_i * H`, while evaluation and witness commitments are sampled from the +linear solution space of the Gemini and KZG equations using `tau` randomizers. + +## V1 Proof Sketch + +This section sketches the security argument for the construction. It is not a +replacement for a final proof, but it identifies the right mathematical object: +the image of one linear map from the original table to all transcript-visible +linear functionals. + +### Hiding, Interactive View + +First consider the interactive protocol where `r`, `q`, and `d` are sampled by +the verifier after the prover has sent the relevant prior messages. + +Fix the verifier challenges and define a linear map: + +```text +Phi : F^N -> F^M +``` + +where `Phi(T)` contains every scalar functional of table `T` that appears in the +proof transcript: + +```text +fold commitments: + T_i(beta) + +hidden evaluation commitments: + T_i(r), T_i(-r), T_i(r^2) + +batched KZG witnesses: + ((T_q(X) - T_q(u)) / (X - u))(beta) + for u in {r, -r, r^2} +``` + +Here `T_i` denotes the Gemini-folded chain derived from `T`, and `T_q` denotes +the Fiat-Shamir linear combination of the folded polynomials. All entries of +`Phi(T)` are linear in `T`. + +The visible proof elements have scalar representation: + +```text +Phi(P_0) * G + Phi(B_0) * H +``` + +componentwise in G1. Since `B_0` is uniform in `F^N`, `Phi(B_0)` is uniform over +`im(Phi)`. Since `Phi(P_0)` is itself in `im(Phi)`, adding it only shifts a +uniform distribution over the same subspace. Therefore: + +```text +Phi(P_0) * G + Phi(B_0) * H +``` + +has the same distribution for every `P_0`, modulo the public linear relations +defining `im(Phi)`. + +Those public relations are exactly the Gemini fold equations and KZG opening +relations checked by the verifier. The verifier learns that the committed +messages are mutually consistent, but not their scalar values. + +### Hiding, Fiat-Shamir View + +In Fiat-Shamir, challenges are hashes of prior transcript messages. The fixed +challenge argument above should be applied prefix-by-prefix. + +At each challenge point, the prior group elements are already statistically +hiding because they are masked by the same linear-image argument. Therefore the +challenge distribution is independent of `P_0`. Conditional on any concrete +prefix and resulting challenge, the next block of messages is again an affine +shift of a linear image of the remaining random mask degrees of freedom. + +The final formal proof should write this as a simulator over transcript +prefixes, or equivalently over the image of the full Fiat-Shamir-adapted map. +The important design condition is that masks are generated by a full random +table `B_0`, not by independent ad hoc per-message masks. + +### Witness Hiding + +The KZG witnesses do not require separate randomizers. For every point `u`, the +hidden witness is: + +```text +W_u = + ((P_q(X) - P_q(u)) / (X - u))(beta) * G ++ ((B_q(X) - B_q(u)) / (X - u))(beta) * H +``` + +The quotient map is linear. Thus the witness mask is exactly the same linear +functional applied to `B_0`. The witness is correlated with `C_q` and `Y_q,u`, +but only through the public KZG equation: + +```text +C_q - Y_q,u = (beta - u) * W_u +``` + +That relation is necessary for verifiability and does not expose an unmasked +linear functional of `P_0`. + +### Soundness Sketch + +Soundness has the same two layers as plain HyperKZG: + +1. Hidden KZG openings bind each `Y_i,u` to the corresponding committed folded + polynomial commitment `C_i`. +2. Gemini group equations bind the committed folded chain to the final hidden + evaluation commitment `Y_out`. + +The hidden KZG opening check proves, under the structured KZG binding +assumption, that each accepted opening commitment `Y_i,u` is the actual hidden +evaluation commitment of `C_i` at `u`. Random-linear batching with challenges +`q` and `d` preserves this except with Schwartz-Zippel probability, as in plain +HyperKZG. + +The Gemini checks are linear group equations. If the folded chain is +inconsistent, then the residual is a nonzero low-degree expression in the +Gemini challenge `r`; it can vanish only with Schwartz-Zippel probability. This +is the same information-theoretic audit used by plain Gemini, with scalar +residuals lifted into G1 commitments. + +Binding additionally requires that the adversary cannot exploit a known +relation between `G` and `H` or the trapdoor `beta` to equivocate between +different `(P, B)` pairs. This is why the SRS ceremony must destroy `beta` and +must not reveal a discrete-log relation between the two G1 bases. + +## V1 Required Proof Obligations + +Current assessment of the proof obligations: + +1. **Mask rank.** + One full random mask table `B_0` appears sufficient. For fixed challenges, + all transcript-visible value data is a linear map of the original table: + + ```text + Phi(P_0) = (C_i value parts, Y_i,u value parts, W_u value parts) + ``` + + The mask data is the same linear map applied to a uniform random table: + + ```text + Phi(B_0) + ``` + + The visible group elements have the form: + + ```text + Phi(P_0) * G + Phi(B_0) * H + ``` + + Since `B_0` is uniform, `Phi(B_0)` is uniform over `im(Phi)`. Therefore + adding `Phi(P_0)` shifts a uniform distribution over the same image and does + not change it. Any rank deficiency corresponds to public linear relations in + `im(Phi)`, which are exactly the Gemini/KZG consistency relations the + verifier checks. + +2. **Witness hiding.** + No extra independent quotient-witness mask appears necessary. For fixed + point `u`, the quotient witness map + + ```text + P_0 -> ((P_q(X) - P_q(u)) / (X - u))(beta) + ``` + + is also linear in `P_0`, and its mask is the same linear map applied to + `B_0`. The witness is correlated with `C_q` and `Y_q,u`, but that + correlation is the public KZG opening relation. It does not add an + unmasked linear functional of `P_0`. + +3. **Batching soundness.** + Random-linear batching should have the same soundness shape as plain + HyperKZG. Invalid hidden opening claims define nonzero group/pairing + residuals. Combining them with Fiat-Shamir powers in `q` and `d` can cancel + only with Schwartz-Zippel probability, up to the usual KZG binding + assumptions. + +4. **SRS independence.** + This is mandatory. The ceremony must produce two structured G1 sequences + + ```text + beta^i * G + beta^i * H + ``` + + with no known discrete-log relation between `G` and `H`, and no surviving + knowledge of `beta`. Hiding only needs random masks, but binding depends on + the adversary not knowing the `G/H` relation or `beta`. + +5. **Transcript binding.** + `Y_out` must be absorbed inside `verify_zk` before deriving Gemini challenge + `r`, because it participates in the final Gemini group equation. Separately, + the `ZkOpeningScheme` integration may call `bind_zk_opening_inputs` after + `verify_zk` returns `Y_out`; that later call binds the opening statement into + the outer Jolt transcript and does not replace the proof-internal absorption. + +Remaining proof details: + +- Exclude standard KZG degeneracies such as `u = beta`; this happens with + negligible probability because `beta` is hidden and `u` is Fiat-Shamir + derived. +- Reject or domain-separate degenerate Gemini challenges already rejected in + plain HyperKZG, e.g. `r = 0`. +- Write the formal simulator over the image of `Phi` rather than over + independent per-message masks. + +## Statistical Integration Tests + +Add crate-local statistical tests under `crates/jolt-hyperkzg/tests/`, gated by +the `zk` feature. These should follow the same shape as `dory-pcs`' +`zk_statistical.rs` example and the `jolt-verifier` ZK statistical-independence +harness: + +```text +NUM_BUCKETS = 16 +chi-squared uniformity check over projected components +split-half two-sample check over even/odd samples +witness-family two-sample checks across different polynomial distributions +stable public statement/proof shape checks +``` + +The tests should collect transcript-visible ZK HyperKZG components: + +```text +C_0 +C_1..C_{ell-1} +Y_i(r), Y_i(-r), Y_i(r^2) +Y_out +W_r, W_-r, W_r2 +returned output blinding `lambda` +``` + +For large vectors, sample stable positions such as first, middle, and last. Each +component should be projected by canonical serialization or `AppendToTranscript` +into a field challenge, then bucketed by low bits. The tests should verify: + +- repeated proofs for the same polynomial, point, and claimed public statement + have indistinguishable projected distributions; +- repeated proofs for different polynomial families, e.g. zero, low-Hamming, + structured, and uniform random tables, have indistinguishable projected + distributions for the transcript-visible proof components; +- two `commit_zk` calls for the same polynomial produce different commitments; +- homomorphically combined ZK hints preserve the same statistical behavior for + the joint opening proof. + +Because these tests are probabilistic and potentially expensive, keep a small +non-ignored smoke test for randomized ZK roundtrips, and mark the full +statistical test `#[ignore]`. The full test should require a release build unless +an explicit environment override is set, and should read its sample count from an +environment variable such as `JOLT_HYPERKZG_ZK_STAT_SAMPLES`. + +## Current Design Decisions + +- Use a separate ZK SRS file, likely `hyperkzg_zk_{k}.srs`. + `hyperkzg_{k}.srs` remains the plain HyperKZG SRS naming convention. +- Store `Y_out` inside the proof, mirroring Dory's `y_com` pattern. `verify_zk` + absorbs and checks it, then returns it through `ZkOpeningScheme` for the outer + transcript or wrapper protocol. +- Have `commit_zk` store only the scalar `rho_0` opening hint. ZK HyperKZG uses + commitment blinding plus proof randomization; it does not carry a full random + mask table. +- Do not optimize for streaming folds in the first spec or implementation. The + straightforward materialized-fold construction is the reference path. V2 still + benefits from ordinary transparent HyperKZG folding and does not require a + second folded mask chain. +- Treat ZK HyperKZG as HyperKZG's ZK mode, implemented inside the same + `HyperKZGScheme` codebase. It remains distinct from the existing Dory PCS + internals and interacts with BlindFold or a wrapper SNARK through the generic + PCS interface for the chosen proof system. +- Keep `HidingCommitment = P::G1`; generic consumers resolve the concrete group + type when HyperKZG is paired with BlindFold or a wrapper. +- Use explicit discriminants for SRS files and proof payloads. Separate file + paths still distinguish plain and ZK SRS artifacts. +- Allow prover-side panics for invalid caller preconditions in non-`Result` + trait methods, but make verifier-side handling return errors rather than + panicking. +- Use canonical transcript labels with separate `hyperkzg_*` and + `hyperkzg_zk_*` namespaces. + +## V1 Historical Reference Plan + +This was the conservative full-mask implementation plan. It is retained only as +reference material; the implementation target is V2. + +1. Add versioned SRS file envelopes with explicit `Plain | Zk` discriminants. + The ZK payload contains `G_i`, `H_i`, `K`, and `beta*K`, serialized as + separate `hyperkzg_zk_{k}.srs` artifacts. +2. Add hidden univariate KZG helpers and unit tests for one polynomial, one + point. +3. Add hidden KZG batching across many polynomials and the three Gemini points. +4. Add ZK Gemini proof types with group evaluation commitments. +5. Implement `ZkOpeningScheme` for the existing `HyperKZGScheme` using the + shared mode-parametrized internals. +6. Add roundtrip tests: + - valid hidden opening verifies + - two `commit_zk` calls for the same polynomial produce different commitments + - tampering any `Y_i,u`, `C_i`, `W_u`, or `Y_out` rejects + - wrong hidden output commitment rejects + - batched openings reject if any folded commitment is inconsistent + - verifier rejects wrong proof/SRS discriminants without panicking + - prover panics on invalid ZK caller preconditions that the trait cannot + return as errors +7. Add statistical integration tests for transcript-visible ZK proof components, + including uniformity, split-half, witness-family independence, and combined + hint behavior. +8. Add serialization tests for ZK SRS and proof payloads. +9. Add criterion benches comparing plain and ZK HyperKZG at `ell = 8, 10, 12, + 14, 20`. + +## V2 Implementation Plan + +Implement V2 as the preferred prover path: + +1. Store `HyperKZGOpeningHint` as `None | ZkBlind(F)`. +2. Change `commit_zk` for V2 to compute the transparent KZG commitment and add + `rho_0 * H`. +3. Keep the existing proof payload shape: + + ```text + Zk { y: [Vec; 3], y_out: G1 } + ``` + + The verifier equations are unchanged. +4. Add V2 prover helpers: + - sample `rho_1..rho_{ell-1}`, `lambda`, and free `tau_i,sq`; + - solve the per-level `2 x 2` systems for `tau_i,+`, `tau_i,-`; + - build `Y_i,u'` using `H_0` and `H_1`; + - build randomized witnesses `W_q,u' = W_q,u + tau_q,u * H`. +5. Reuse the transparent folding, intermediate commitment, and batched witness + code wherever possible. V2 should not materialize a mask polynomial. + The prover needs an internal helper that computes transparent batched + evaluations and witnesses without appending clear scalar evaluations to the + transcript; `q` must be derived from the hidden `Y` commitments. +6. Optimize verifier batching: + - derive `alpha` after absorbing all hidden `Y` commitments and before `q`; + - verify one random-linear Gemini residual MSM instead of `ell` separate + group equations; + - construct the hidden-KZG pairing `L` side with one direct MSM over + `C_i`, `Y_i,u`, and `W_u`. +7. Add V2-specific tests: + - roundtrip opening verifies and returns `Y_out`; + - returned blind `lambda` satisfies `Y_out = eval * G + lambda * H`; + - two commitments to the same polynomial differ; + - homomorphic combination of `rho` hints verifies; + - tampering `rho`-derived commitments, `Y_i,u`, `Y_out`, or randomized + witnesses rejects; + - artificially forced singular coefficient systems are detected in prover + code. +8. Rerun the statistical harness against V2. The transcript-visible components + should remain independent across polynomial families. +9. Bench V2 against transparent HyperKZG. Expected V2 prover overhead is + logarithmic plus small G1 operations, while verifier overhead remains close + to the group-check ZK verifier described above. + +## Non-Goals + +This spec does not require: + +```text +wrapping plain HyperKZG in a SNARK +reusing the existing Dory/BlindFold PCS internals for Gemini +revealing any scalar evaluations +changing Dory's ZK path +making setup_from_secret a production API +``` + +The construction is deliberately PCS-native: hide the commitments, evaluations, +and witnesses at the KZG/Gemini layer, then use homomorphic checks for Gemini's +linear verifier. Wrapper SNARKs or BlindFold-style layers can consume this PCS +through `ZkOpeningScheme` and impose their own constraints over the returned +hiding commitment and blinding data. From fd2a7a3996ed34635dc0c8a3d0337d1024b1db6a Mon Sep 17 00:00:00 2001 From: Andrew Tretyakov <42178850+0xAndoroid@users.noreply.github.com> Date: Wed, 17 Jun 2026 01:46:00 -0400 Subject: [PATCH 2/2] fix: adapt hyperkzg zk openings to stack --- Cargo.lock | 2 ++ Cargo.toml | 10 ---------- crates/jolt-hyperkzg/src/scheme.rs | 1 - crates/jolt-hyperkzg/src/types.rs | 1 - 4 files changed, 2 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce10a8270b..165e53e29e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2972,12 +2972,14 @@ dependencies = [ name = "jolt-hyperkzg" version = "0.1.0" dependencies = [ + "bincode 2.0.1", "criterion", "jolt-crypto", "jolt-field", "jolt-openings", "jolt-poly", "jolt-transcript", + "memory-stats", "num-traits", "rand 0.8.5", "rand_chacha 0.3.1", diff --git a/Cargo.toml b/Cargo.toml index 6b0e64eb2c..f76decb7dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,14 +34,9 @@ members = [ "crates/jolt-sumcheck", "crates/jolt-openings", "crates/jolt-verifier", - "crates/jolt-prover", - "crates/jolt-witness", - "crates/jolt-backends", "crates/jolt-dory", - "crates/jolt-dory-assist-verifier", "crates/jolt-hyrax", "crates/jolt-hyperkzg", - "crates/jolt-wrapper-verifier", "crates/jolt-riscv", "crates/jolt-transcript", "crates/jolt-profiling", @@ -396,12 +391,7 @@ jolt-field = { path = "./crates/jolt-field" } jolt-hyperkzg = { path = "./crates/jolt-hyperkzg" } jolt-openings = { path = "./crates/jolt-openings" } jolt-hyrax = { path = "./crates/jolt-hyrax" } -jolt-dory-assist-verifier = { path = "./crates/jolt-dory-assist-verifier" } -jolt-wrapper-verifier = { path = "./crates/jolt-wrapper-verifier" } jolt-verifier = { path = "./crates/jolt-verifier" } -jolt-prover = { path = "./crates/jolt-prover" } -jolt-witness = { path = "./crates/jolt-witness" } -jolt-backends = { path = "./crates/jolt-backends" } jolt-poly = { path = "./crates/jolt-poly" } jolt-r1cs = { path = "./crates/jolt-r1cs" } jolt-transcript = { path = "./crates/jolt-transcript" } diff --git a/crates/jolt-hyperkzg/src/scheme.rs b/crates/jolt-hyperkzg/src/scheme.rs index 32dd351975..2efa67b5a9 100644 --- a/crates/jolt-hyperkzg/src/scheme.rs +++ b/crates/jolt-hyperkzg/src/scheme.rs @@ -73,7 +73,6 @@ struct V2Bases { /// [`AdditivelyHomomorphic`] from `jolt-openings`. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(bound = "")] - pub struct HyperKZGScheme { #[serde(skip)] _phantom: PhantomData

, diff --git a/crates/jolt-hyperkzg/src/types.rs b/crates/jolt-hyperkzg/src/types.rs index 8d10c54101..80fcf9c7bc 100644 --- a/crates/jolt-hyperkzg/src/types.rs +++ b/crates/jolt-hyperkzg/src/types.rs @@ -58,7 +58,6 @@ where } impl HomomorphicCommitment for HyperKZGCommitment

{ - #[inline] fn add(c1: &Self, c2: &Self) -> Self { Self {