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 b85f195b4b..f76decb7dd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -388,6 +388,7 @@ 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-verifier = { path = "./crates/jolt-verifier" }
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