diff --git a/CHANGELOG.md b/CHANGELOG.md index deb849ab..4c63b09a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Features - [\#82](https://github.com/arkworks-rs/poly-commit/pull/82) Add multivariate opening challenge strategy. Integrate with sponge API. +- [\#171](https://github.com/arkworks-rs/poly-commit/pull/171) Add the pairing-based KZH-`k` multilinear polynomial commitment family. ### Improvements - [\#152](https://github.com/arkworks-rs/poly-commit/issues/152) Expose `kzg10::open_with_witness_polynomial` and `open` downstream. diff --git a/Cargo.toml b/Cargo.toml index fa43f384..68d1f0c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ ark-std = { version = "0.5.0", default-features = false } ark-relations = { version = "0.5.0", default-features = false } ark-r1cs-std = { version = "0.5.0", default-features = false } rand_chacha = { version = "0.3.0", default-features = false } +zeroize = { version = "1", default-features = false, features = ["alloc"] } [profile.release] opt-level = 3 diff --git a/README.md b/README.md index 50c57af2..05628c4d 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,18 @@ A polynomial commitment scheme is a cryptographic primitive that enables a party to commit to a polynomial over a given finite field, and then, later on, to reveal desired evaluations of the polynomial along with cryptographic proofs attesting to their correctness. -This library provides various constructions of polynomial commitment schemes. These constructions support committing to multiple polynomials at a time with differing degree bounds, batching multiple evaluation proofs for the same evaluation point into a single one, and batch verification of proofs. +This library provides several polynomial commitment constructions through a +shared interface. Support for degree bounds, hiding, and specialized batching +depends on the selected construction. -The key properties satisfied by the polynomial commitment schemes are **succinctness**, **extractability**, and **hiding**. See [the Marlin paper][marlin] for definitions of these properties. +The constructions target **succinctness** and **extractability**; some also +provide **hiding**. See [the Marlin paper][marlin] for definitions of these +properties. ### Supported Polynomial Commitment Schemes -The library supports six polynomial commitment schemes. +The library supports seven polynomial commitment schemes. #### Inner-product-argument PC @@ -75,6 +79,25 @@ Multilinear polynomial commitment, introduced with Hyrax zkSNARK. Relies on Pede Riad S. Wahby, Ioanna Tzialla, abhi shelat, Justin Thaler, Michael Walfish 2018 IEEE Symposium on Security and Privacy +#### KZH-k multilinear PC + +Pairing-based multilinear polynomial commitment family parameterized by tensor +arity `k`. This is the non-hiding construction from the papers below. Setup +creates a trusted SRS for one exact number of variables and does not provide +an updatable-ceremony interface. See the +[`kzh` module documentation](https://docs.rs/ark-poly-commit/latest/ark_poly_commit/kzh/) +for setup assumptions and the ways this implementation differs from the papers. + +Select the family member with the const generic `KZH`; +`KZH2`, `KZH3`, and `KZH4` are provided as convenience aliases. + +[KZH-Fold: Accountable Voting from Sublinear Accumulation][kzh], George +Kadianakis, Arantxa Zapico, Hossein Hafezi, and Benedikt Bünz, CCS 2025. + +[IronDict: Transparent Dictionaries from Polynomial Commitments][irondict], +Hossein Hafezi, Alireza Shirzad, Benedikt Bünz, and Joseph Bonneau, +USENIX Security 2026. + #### Ligero and Brakedown Polynomial commitments based on linear codes and cryptographic hash functions. Construction details in the following papers. @@ -292,6 +315,8 @@ Unless you explicitly state otherwise, any contribution that you submit to this [brakedown]: https://ia.cr/2021/1043 [ligero]: https://ia.cr/2022/1608 [hyrax]: https://eprint.iacr.org/2017/1132 +[kzh]: https://ia.cr/2025/144 +[irondict]: https://eprint.iacr.org/2025/1580 ## Reference papers @@ -327,6 +352,13 @@ CCS 2017 Riad S. Wahby, Ioanna Tzialla, abhi shelat, Justin Thaler, Michael Walfish 2018 IEEE Symposium on Security and Privacy +[KZH-Fold: Accountable Voting from Sublinear Accumulation][kzh], George +Kadianakis, Arantxa Zapico, Hossein Hafezi, and Benedikt Bünz, CCS 2025. + +[IronDict: Transparent Dictionaries from Polynomial Commitments][irondict], +Hossein Hafezi, Alireza Shirzad, Benedikt Bünz, and Joseph Bonneau, +USENIX Security 2026. + [Brakedown: Linear-time and field-agnostic SNARKs for R1CS][brakedown] Alexander Golovnev, Jonathan Lee, Srinath Setty, Justin Thaler, Riad S. Wahby CRYPTO 2023 diff --git a/poly-commit/Cargo.toml b/poly-commit/Cargo.toml index f619574e..dcbfa90c 100644 --- a/poly-commit/Cargo.toml +++ b/poly-commit/Cargo.toml @@ -16,6 +16,7 @@ ark-ec.workspace = true ark-poly.workspace = true ark-crypto-primitives = { workspace = true, features = ["sponge", "merkle_tree"] } ark-std.workspace = true +zeroize.workspace = true ark-relations = { workspace = true, optional = true } ark-r1cs-std = { workspace = true, optional = true } @@ -47,6 +48,11 @@ name = "hyrax_times" path = "benches/hyrax_times.rs" harness = false +[[bench]] +name = "kzh_times" +path = "benches/kzh_times.rs" +harness = false + [[bench]] name = "size" path = "benches/size.rs" diff --git a/poly-commit/benches/kzh_times.rs b/poly-commit/benches/kzh_times.rs new file mode 100644 index 00000000..61cc65c0 --- /dev/null +++ b/poly-commit/benches/kzh_times.rs @@ -0,0 +1,309 @@ +//! End-to-end KZH setup, commitment, opening, and verification benchmarks. +//! +//! The complete KZH-2/3/4/6 × n=12/16/20 matrix is intentionally expensive. +//! Set `KZH_BENCH_K` and/or `KZH_BENCH_N` to benchmark one family member or +//! evaluation-table size while tuning an implementation. + +use ark_bls12_381::{Bls12_381, Fr}; +use ark_crypto_primitives::sponge::{ + poseidon::{PoseidonConfig, PoseidonSponge}, + CryptographicSponge, +}; +use ark_ff::{One, Zero}; +use ark_pcs_bench_templates::*; +use ark_poly::{DenseMultilinearExtension, MultilinearExtension}; +use ark_poly_commit::{ + kzh::{PreparedVerifierKey, KZH}, + LabeledPolynomial, PolynomialCommitment, +}; +use ark_serialize::{CanonicalSerialize, Compress}; +use ark_std::UniformRand; +use rand_chacha::{rand_core::SeedableRng, ChaCha20Rng}; +use std::time::Duration; + +type Mle = DenseMultilinearExtension; +type Kzh = KZH; + +const NUM_VARS: [usize; 3] = [12, 16, 20]; + +fn matches_requested_value(requested: Option, value: usize) -> bool { + match requested { + Some(requested) => requested == value, + None => true, + } +} + +/// Returns deterministic benchmark-only sponge parameters. +fn benchmark_sponge_config() -> PoseidonConfig { + let full_rounds = 8; + let partial_rounds = 31; + let alpha = 17; + let mds = vec![ + vec![Fr::one(), Fr::zero(), Fr::one()], + vec![Fr::one(), Fr::one(), Fr::zero()], + vec![Fr::zero(), Fr::one(), Fr::one()], + ]; + + let mut rng = ChaCha20Rng::from_seed([91u8; 32]); + let ark = (0..full_rounds + partial_rounds) + .map(|_| (0..3).map(|_| Fr::rand(&mut rng)).collect()) + .collect(); + PoseidonConfig::new(full_rounds, partial_rounds, alpha, mds, ark, 2, 1) +} + +fn bench_family(criterion: &mut Criterion, num_vars: usize) { + let num_evaluations = 1usize << num_vars; + let seed = (K as u8).wrapping_mul(31).wrapping_add(num_vars as u8); + let mut fixture_rng = ChaCha20Rng::from_seed([seed; 32]); + + // KZH setup is tied to one exact number of variables. It is non-hiding, so + // both trim and commitment use a zero hiding bound and no commitment RNG. + let params = Kzh::::setup(1, Some(num_vars), &mut fixture_rng).unwrap(); + let params_size = params.serialized_size(Compress::Yes); + let block_sizes = params.num_vars_per_block().to_vec(); + let (committer_key, verifier_key) = Kzh::::trim(¶ms, 1, 0, None).unwrap(); + // At n=20 the SRS contains roughly one million G1 points. `trim` clones + // the committer material, so release the universal copy before measuring. + drop(params); + let polynomial = LabeledPolynomial::new( + format!("kzh-{K}-n-{num_vars}"), + Mle::rand(num_vars, &mut fixture_rng), + None, + None, + ); + let point = (0..num_vars) + .map(|_| Fr::rand(&mut fixture_rng)) + .collect::>(); + let value = polynomial.evaluate(&point); + let (commitments, states) = Kzh::::commit(&committer_key, [&polynomial], None).unwrap(); + + let sponge_config = benchmark_sponge_config(); + let mut opening_sponge = PoseidonSponge::new(&sponge_config); + let proof = Kzh::::open( + &committer_key, + [&polynomial], + &commitments, + &point, + &mut opening_sponge, + &states, + None, + ) + .unwrap(); + let mut checking_sponge = PoseidonSponge::new(&sponge_config); + assert!(Kzh::::check( + &verifier_key, + &commitments, + &point, + [value], + &proof, + &mut checking_sponge, + None, + ) + .unwrap()); + let prepared_verifier_key = PreparedVerifierKey::prepare(&verifier_key); + let mut prepared_checking_sponge = PoseidonSponge::new(&sponge_config); + assert!(Kzh::::check_prepared( + &prepared_verifier_key, + &commitments, + &point, + [value], + &proof, + &mut prepared_checking_sponge, + None, + ) + .unwrap()); + + println!( + "KZH-{K}, n={num_vars}, N={num_evaluations}, blocks={block_sizes:?}: params={} B, ck={} B, vk={} B, commitment={} B, state={} B, proof={} B (compressed)", + params_size, + committer_key.serialized_size(Compress::Yes), + verifier_key.serialized_size(Compress::Yes), + commitments[0] + .commitment() + .serialized_size(Compress::Yes), + states[0].serialized_size(Compress::Yes), + proof.serialized_size(Compress::Yes), + ); + + let mut group = criterion.benchmark_group(format!("KZH-{K}/n={num_vars}")); + if num_vars == 20 { + group + .sample_size(10) + .warm_up_time(Duration::from_millis(100)) + .measurement_time(Duration::from_secs(1)) + .sampling_mode(SamplingMode::Flat); + } + + // Setup is useful at the smaller sizes, but ten repeated million-point + // SRS generations obscure the commit/open/check scaling of interest. + if num_vars < 20 { + group.bench_function("setup", |bencher| { + bencher.iter_batched( + || ChaCha20Rng::from_seed([seed; 32]), + |mut rng| { + black_box(Kzh::::setup(1, Some(num_vars), &mut rng).unwrap()); + }, + BatchSize::SmallInput, + ); + }); + } + + group.bench_function("commit", |bencher| { + bencher.iter(|| { + black_box(Kzh::::commit(&committer_key, [&polynomial], None).unwrap()); + }); + }); + + group.bench_function("open", |bencher| { + bencher.iter_batched( + || PoseidonSponge::new(&sponge_config), + |mut sponge| { + black_box( + Kzh::::open( + &committer_key, + [&polynomial], + &commitments, + &point, + &mut sponge, + &states, + None, + ) + .unwrap(), + ); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("check", |bencher| { + bencher.iter_batched( + || PoseidonSponge::new(&sponge_config), + |mut sponge| { + assert!(black_box( + Kzh::::check( + &verifier_key, + &commitments, + &point, + [value], + &proof, + &mut sponge, + None, + ) + .unwrap() + )); + }, + BatchSize::SmallInput, + ); + }); + + // Preparation is intentionally measured separately: applications that + // reuse a verifier key should prepare it once and benchmark the amortized + // `check_prepared` path independently from this one-time cost. + group.bench_function("prepare_vk", |bencher| { + bencher.iter(|| { + black_box(PreparedVerifierKey::prepare(black_box(&verifier_key))); + }); + }); + + group.bench_function("check_prepared", |bencher| { + bencher.iter_batched( + || PoseidonSponge::new(&sponge_config), + |mut sponge| { + assert!(black_box( + Kzh::::check_prepared( + &prepared_verifier_key, + &commitments, + &point, + [value], + &proof, + &mut sponge, + None, + ) + .unwrap() + )); + }, + BatchSize::SmallInput, + ); + }); + + group.bench_function("prepare_and_check", |bencher| { + bencher.iter_batched( + || PoseidonSponge::new(&sponge_config), + |mut sponge| { + let one_shot_key = PreparedVerifierKey::prepare(&verifier_key); + assert!(black_box( + Kzh::::check_prepared( + &one_shot_key, + &commitments, + &point, + [value], + &proof, + &mut sponge, + None, + ) + .unwrap() + )); + }, + BatchSize::SmallInput, + ); + }); + + group.finish(); +} + +fn bench_kzh(criterion: &mut Criterion) { + let requested_k = std::env::var("KZH_BENCH_K").ok().map(|value| { + value + .parse::() + .expect("KZH_BENCH_K must be an integer") + }); + let requested_num_vars = std::env::var("KZH_BENCH_N").ok().map(|value| { + value + .parse::() + .expect("KZH_BENCH_N must be an integer") + }); + if let Some(k) = requested_k { + assert!( + [2, 3, 4, 6].contains(&k), + "KZH_BENCH_K must be 2, 3, 4, or 6" + ); + } + if let Some(num_vars) = requested_num_vars { + assert!( + NUM_VARS.contains(&num_vars), + "KZH_BENCH_N must be one of {:?}", + NUM_VARS + ); + } + + // K=3 is an odd arity; K=2, 4, and 6 are even. n increases by four so that + // each step multiplies the evaluation table by 16. + for num_vars in NUM_VARS + .iter() + .copied() + .filter(|num_vars| matches_requested_value(requested_num_vars, *num_vars)) + { + if matches_requested_value(requested_k, 2) { + bench_family::<2>(criterion, num_vars); + } + if matches_requested_value(requested_k, 3) { + bench_family::<3>(criterion, num_vars); + } + if matches_requested_value(requested_k, 4) { + bench_family::<4>(criterion, num_vars); + } + if matches_requested_value(requested_k, 6) { + bench_family::<6>(criterion, num_vars); + } + } +} + +criterion_group! { + name = benches; + config = Criterion::default() + .sample_size(10) + .warm_up_time(Duration::from_secs(1)) + .measurement_time(Duration::from_secs(3)); + targets = bench_kzh +} +criterion_main!(benches); diff --git a/poly-commit/src/kzg10/mod.rs b/poly-commit/src/kzg10/mod.rs index 81a7b59f..7d4c9405 100644 --- a/poly-commit/src/kzg10/mod.rs +++ b/poly-commit/src/kzg10/mod.rs @@ -488,10 +488,10 @@ mod tests { impl> KZG10 { /// Specializes the public parameters for a given maximum degree `d` for polynomials /// `d` should be less that `pp.max_degree()`. - pub(crate) fn trim( - pp: &UniversalParams, + pub(crate) fn trim<'a>( + pp: &'a UniversalParams, mut supported_degree: usize, - ) -> Result<(Powers, VerifierKey), Error> { + ) -> Result<(Powers<'a, E>, VerifierKey), Error> { if supported_degree == 1 { supported_degree += 1; } diff --git a/poly-commit/src/kzh/data_structures.rs b/poly-commit/src/kzh/data_structures.rs new file mode 100644 index 00000000..c7348738 --- /dev/null +++ b/poly-commit/src/kzh/data_structures.rs @@ -0,0 +1,411 @@ +use crate::{ + PCCommitment, PCCommitmentState, PCCommitterKey, PCPreparedCommitment, PCPreparedVerifierKey, + PCUniversalParams, PCVerifierKey, +}; +use ark_ec::{pairing::Pairing, AffineRepr}; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use ark_std::{rand::RngCore, vec::Vec}; + +/// Universal parameters for the KZH-`K` commitment scheme. +/// +/// Tensor blocks and `h` layers follow arkworks' little-endian variable order, +/// from the lowest variables (`x_0` first) to the highest. The `j`-th `h` layer +/// contains the flattened KZH tensor for the suffix of blocks beginning at +/// block `j`, with that suffix's first block varying fastest. The `v_tau` +/// vectors cover only the first `K - 1` blocks because the highest/final block +/// is checked from the disclosed final evaluation vector and never uses a +/// pairing layer. +#[derive(Derivative, CanonicalSerialize, CanonicalDeserialize)] +#[derivative( + Clone(bound = ""), + Debug(bound = ""), + PartialEq(bound = ""), + Eq(bound = "") +)] +pub struct UniversalParams { + /// Runtime copy of the family parameter, included in the serialized form. + pub(crate) k: usize, + /// Number of variables supported by these parameters. + pub(crate) num_vars: usize, + /// Number of variables in each of the `K` balanced tensor blocks, ordered + /// low-to-high in arkworks' variable order. + pub(crate) num_vars_per_block: Vec, + /// Flattened suffix commitment tensors, one for each tensor block. + pub(crate) h: Vec>, + /// The verifier's base generator in `G2`. + pub(crate) v: E::G2Affine, + /// Trapdoor-scaled `G2` bases for every tensor block except the last. + pub(crate) v_tau: Vec>, +} + +impl UniversalParams { + /// Returns the exact number of variables supported by these parameters. + #[inline] + pub const fn num_vars(&self) -> usize { + self.num_vars + } + + /// Returns the low-to-high variable counts of the balanced tensor blocks. + #[inline] + pub fn num_vars_per_block(&self) -> &[usize] { + &self.num_vars_per_block + } +} + +impl PCUniversalParams for UniversalParams { + fn max_degree(&self) -> usize { + // KZH commits to multilinear polynomials. + 1 + } +} + +/// Committer key for the KZH-`K` commitment scheme. +#[derive(Derivative, CanonicalSerialize, CanonicalDeserialize)] +#[derivative( + Clone(bound = ""), + Debug(bound = ""), + PartialEq(bound = ""), + Eq(bound = "") +)] +pub struct CommitterKey { + /// Runtime copy of the family parameter, included in the serialized form. + pub(crate) k: usize, + /// Number of variables supported by this key. + pub(crate) num_vars: usize, + /// Number of variables in each of the `K` balanced tensor blocks, ordered + /// low-to-high in arkworks' variable order. + pub(crate) num_vars_per_block: Vec, + /// Flattened suffix commitment tensors, one for each tensor block. + pub(crate) h: Vec>, +} + +impl CommitterKey { + /// Returns the exact number of variables supported by this key. + #[inline] + pub const fn num_vars(&self) -> usize { + self.num_vars + } + + /// Returns the low-to-high variable counts of the balanced tensor blocks. + #[inline] + pub fn num_vars_per_block(&self) -> &[usize] { + &self.num_vars_per_block + } +} + +impl PCCommitterKey for CommitterKey { + fn max_degree(&self) -> usize { + // KZH commits to multilinear polynomials. + 1 + } + + fn supported_degree(&self) -> usize { + // KZH commits to multilinear polynomials. + 1 + } +} + +/// Verifier key for the KZH-`K` commitment scheme. +#[derive(Derivative, CanonicalSerialize, CanonicalDeserialize)] +#[derivative( + Clone(bound = ""), + Debug(bound = ""), + PartialEq(bound = ""), + Eq(bound = "") +)] +pub struct VerifierKey { + /// Runtime copy of the family parameter, included in the serialized form. + pub(crate) k: usize, + /// Number of variables supported by this key. + pub(crate) num_vars: usize, + /// Number of variables in each of the `K` balanced tensor blocks, ordered + /// low-to-high in arkworks' variable order. + pub(crate) num_vars_per_block: Vec, + /// The commitment layer for the highest/final tensor block. + pub(crate) h_last: Vec, + /// The verifier's base generator in `G2`. + pub(crate) v: E::G2Affine, + /// Trapdoor-scaled `G2` bases for every tensor block except the last. + pub(crate) v_tau: Vec>, +} + +impl VerifierKey { + /// Returns the exact number of variables supported by this key. + #[inline] + pub const fn num_vars(&self) -> usize { + self.num_vars + } + + /// Returns the low-to-high variable counts of the balanced tensor blocks. + #[inline] + pub fn num_vars_per_block(&self) -> &[usize] { + &self.num_vars_per_block + } +} + +impl PCVerifierKey for VerifierKey { + fn max_degree(&self) -> usize { + // KZH commits to multilinear polynomials. + 1 + } + + fn supported_degree(&self) -> usize { + // KZH commits to multilinear polynomials. + 1 + } +} + +/// Prepared verifier key for repeated KZH verification. +/// +/// This is an ephemeral runtime cache: every `G2` input used by the pairing +/// equations is converted to Arkworks' prepared representation once and then +/// reused by [`KZH::check_prepared`](super::KZH::check_prepared). Prepared line +/// coefficients are deliberately not canonically serializable; deserialize +/// and validate an ordinary [`VerifierKey`] and prepare it locally instead. +#[derive(Derivative)] +#[derivative(Clone(bound = ""), Debug(bound = ""))] +pub struct PreparedVerifierKey { + verifier_key: VerifierKey, + prepared_v: E::G2Prepared, + prepared_v_tau: Vec>, +} + +impl PreparedVerifierKey { + /// Prepares all `G2` pairing inputs in `vk` for repeated verification. + pub fn prepare(vk: &VerifierKey) -> Self { + Self::from(vk) + } + + /// Returns the ordinary verifier key from which this cache was derived. + #[inline] + pub fn verifier_key(&self) -> &VerifierKey { + &self.verifier_key + } + + /// Returns the exact number of variables supported by this key. + #[inline] + pub const fn num_vars(&self) -> usize { + self.verifier_key.num_vars + } + + /// Returns the low-to-high variable counts of the balanced tensor blocks. + #[inline] + pub fn num_vars_per_block(&self) -> &[usize] { + &self.verifier_key.num_vars_per_block + } + + #[inline] + pub(crate) fn prepared_v(&self) -> &E::G2Prepared { + &self.prepared_v + } + + #[inline] + pub(crate) fn prepared_v_tau(&self) -> &[Vec] { + &self.prepared_v_tau + } +} + +impl From<&VerifierKey> for PreparedVerifierKey { + fn from(vk: &VerifierKey) -> Self { + let prepared_v = E::G2Prepared::from(&vk.v); + let prepared_v_tau = vk + .v_tau + .iter() + .map(|layer| layer.iter().map(E::G2Prepared::from).collect()) + .collect(); + + Self { + verifier_key: vk.clone(), + prepared_v, + prepared_v_tau, + } + } +} + +impl PCPreparedVerifierKey> + for PreparedVerifierKey +{ + fn prepare(vk: &VerifierKey) -> Self { + Self::from(vk) + } +} + +/// A KZH commitment. +#[derive(Derivative, CanonicalSerialize, CanonicalDeserialize)] +#[derivative( + Clone(bound = ""), + Copy(bound = ""), + Debug(bound = ""), + PartialEq(bound = ""), + Eq(bound = "") +)] +pub struct Commitment { + /// Runtime copy of the family parameter, included in the serialized form. + pub(crate) k: usize, + /// Number of variables in the committed polynomial. + pub(crate) num_vars: usize, + /// The commitment group element. + pub(crate) comm: E::G1Affine, +} + +impl Commitment { + /// Returns the number of variables in the committed polynomial. + #[inline] + pub const fn num_vars(&self) -> usize { + self.num_vars + } + + /// Returns the underlying commitment group element. + #[inline] + pub fn comm(&self) -> &E::G1Affine { + &self.comm + } +} + +impl Default for Commitment { + fn default() -> Self { + Self { + k: K, + num_vars: 0, + comm: E::G1Affine::zero(), + } + } +} + +impl PCCommitment for Commitment { + #[inline] + fn empty() -> Self { + Self::default() + } + + fn has_degree_bound(&self) -> bool { + // KZH enforces multilinearity through the polynomial type, but does + // not authenticate strict degree-bound metadata on commitments. + false + } +} + +/// Prepared KZH commitment. +/// +/// KZH currently performs no additional commitment preparation. +pub type PreparedCommitment = Commitment; + +impl PCPreparedCommitment> + for PreparedCommitment +{ + fn prepare(commitment: &Commitment) -> Self { + *commitment + } +} + +/// Private state cached while committing to a polynomial. +/// +/// `auxiliary_tables[j]` stores higher-variable suffix commitments for all +/// assignments to low blocks `0..=j`, current-block-index first and +/// prior-prefix-index second. Only tables used by generic openings are kept; +/// later layers are committed from the partially evaluated polynomial. +/// Same-point batches may use a shorter prefix of these tables. +#[derive(Derivative, CanonicalSerialize, CanonicalDeserialize)] +#[derivative( + Clone(bound = ""), + Debug(bound = ""), + PartialEq(bound = ""), + Eq(bound = "") +)] +pub struct CommitmentState { + /// Runtime copy of the family parameter, included in the serialized form. + pub(crate) k: usize, + /// Number of variables in the committed polynomial. + pub(crate) num_vars: usize, + /// Suffix-commitment tables for the beneficial low-block prefix. + pub(crate) auxiliary_tables: Vec>, +} + +impl CommitmentState { + /// Returns the number of variables supported by this commitment state. + #[inline] + pub const fn num_vars(&self) -> usize { + self.num_vars + } +} + +impl Default for CommitmentState { + fn default() -> Self { + Self { + k: K, + num_vars: 0, + auxiliary_tables: Vec::new(), + } + } +} + +impl PCCommitmentState for CommitmentState { + type Randomness = (); + + fn empty() -> Self { + Self::default() + } + + fn rand( + _num_queries: usize, + _has_degree_bound: bool, + _num_vars: Option, + _rng: &mut R, + ) -> Self::Randomness { + // KZH is non-hiding, so its commitment randomness is the unit type. + } +} + +/// An opening proof for the KZH-`K` scheme. +/// +/// The proof contains one vector of slice commitments for each of the lowest +/// `K - 1` tensor blocks, plus the final canonical arkworks evaluation table +/// for the highest block. +#[derive(Derivative, CanonicalSerialize, CanonicalDeserialize)] +#[derivative( + Clone(bound = ""), + Debug(bound = ""), + PartialEq(bound = ""), + Eq(bound = "") +)] +pub struct Proof { + /// Runtime copy of the family parameter, included in the serialized form. + pub(crate) k: usize, + /// Number of variables in the opened polynomial. + pub(crate) num_vars: usize, + /// Slice commitments for every low-to-high tensor block except the last. + pub(crate) layer_commitments: Vec>, + /// Canonical evaluations remaining for the highest/final block. + pub(crate) final_evaluations: Vec, +} + +impl Proof { + /// Returns the number of variables opened by this proof. + #[inline] + pub const fn num_vars(&self) -> usize { + self.num_vars + } + + /// Returns the low-to-high slice-commitment layers in this proof. + #[inline] + pub fn layers(&self) -> &[Vec] { + &self.layer_commitments + } + + /// Returns the disclosed evaluation table for the highest tensor block. + #[inline] + pub fn final_evaluations(&self) -> &[E::ScalarField] { + &self.final_evaluations + } +} + +impl Default for Proof { + fn default() -> Self { + Self { + k: K, + num_vars: 0, + layer_commitments: Vec::new(), + final_evaluations: Vec::new(), + } + } +} diff --git a/poly-commit/src/kzh/mod.rs b/poly-commit/src/kzh/mod.rs new file mode 100644 index 00000000..0cad3121 --- /dev/null +++ b/poly-commit/src/kzh/mod.rs @@ -0,0 +1,961 @@ +//! The KZH-`k` multilinear polynomial commitment family. +//! +//! This module implements the non-hiding construction in Appendix C.1 of +//! [KZH-Fold][kzh], with the generic-opening auxiliary tables described in +//! Appendix E of [IronDict][irondict]. The const generic `K` is the tensor +//! arity. Protocol costs are those of the papers; this documentation records +//! only where the implementation differs from them or from the crate's default +//! PCS APIs. +//! +//! Tensor blocks follow arkworks' little-endian MLE order from `x_0` upward, +//! and partial evaluation uses [`MultilinearExtension::fix_variables`]. Same- +//! point batching is native. Multi-point and linear-combination queries use the +//! crate's default APIs, which issue one same-point opening per distinct point. +//! Hiding commitments, strict degree bounds, a Boolean-specialized opening API, +//! and an R1CS gadget are not implemented and are rejected where they would +//! otherwise apply. +//! +//! Commitment state stores only the auxiliary tables used by generic openings. +//! Tables that exist solely for free Boolean openings in the papers are omitted. +//! +//! # Security and setup +//! +//! `setup` produces an honestly generated, trusted SRS for one exact number of +//! variables. Every operation boundary validates dimensions and family +//! metadata, but this does not prove the algebraic consistency of an externally +//! supplied SRS or implement an updatable ceremony. The caller must use a +//! cryptographically secure RNG and destroy its recoverable state; materialized +//! trapdoor buffers are zeroized on drop. The security argument is the one in +//! the paper's algebraic-group model, under its `(q1, q2)` discrete-log and +//! Setup-find-representation assumptions. Batched openings additionally use +//! the sponge as a Fiat--Shamir random oracle with 128-bit challenges. +//! +//! Figure 12 presents equal tensor sides. This implementation uses the +//! corresponding balanced rectangular decomposition when the number of +//! variables is not divisible by `K`. In particular, `KZH2` is the `K = 2` +//! member of Appendix C.1's uniform family, not a claim of SRS compatibility +//! with the separately parameterized presentation in the paper's main text. +//! +//! [kzh]: https://eprint.iacr.org/2025/144 +//! [irondict]: https://eprint.iacr.org/2025/1580 + +use crate::{Error, LabeledCommitment, LabeledPolynomial, PolynomialCommitment, CHALLENGE_SIZE}; +use ark_crypto_primitives::sponge::{Absorb, CryptographicSponge}; +use ark_ec::{ + pairing::Pairing, scalar_mul::BatchMulPreprocessing, AffineRepr, CurveGroup, VariableBaseMSM, +}; +use ark_ff::{One, PrimeField, Zero}; +use ark_poly::{DenseMultilinearExtension, MultilinearExtension}; +use ark_serialize::serialize_to_vec; +use ark_std::{marker::PhantomData, rand::RngCore, string::ToString, vec::Vec, UniformRand}; +#[cfg(feature = "parallel")] +use rayon::prelude::*; +use zeroize::Zeroizing; + +mod data_structures; +pub use data_structures::*; +mod utils; +use utils::{ + arkworks_lagrange_evaluations, auxiliary_prefix_lengths, balanced_block_sizes, + block_dimensions, opening_work_plan, product, split_point_low_to_high, + validate_commitment_shape, validate_committer_key_shape, validate_params_shape, + validate_proof_shape, validate_state_shape, validate_verifier_key_shape, OpeningLayerSource, +}; + +#[cfg(test)] +mod tests; + +const BATCH_OPENING_DOMAIN_SEPARATOR: &[u8] = b"ark-poly-commit/KZH-k/batch-opening/v2"; + +struct PairingInputs<'a, Q> { + v: &'a Q, + v_tau: &'a [Vec], +} + +/// KZH-`K`, a pairing-based multilinear polynomial commitment scheme. +/// +/// `K` must satisfy `2 <= K <= num_vars`. It is part of the Rust type so that +/// different members of the KZH family cannot accidentally share keys. A +/// runtime copy of `K` is also serialized in every public object and checked +/// at each operation boundary. Setup, opening, and verification all use exactly +/// these `K` balanced tensor blocks. +pub struct KZH, const K: usize> { + _phantom: PhantomData<(E, P)>, +} + +/// The two-dimensional member of the KZH family. +pub type KZH2 = KZH; + +/// The three-dimensional member of the KZH family. +pub type KZH3 = KZH; + +/// The four-dimensional member of the KZH family. +pub type KZH4 = KZH; + +impl KZH +where + E: Pairing, + E::ScalarField: Absorb, + P: MultilinearExtension, +{ + fn invalid_input_length(message: &str) -> Error { + Error::IncorrectInputLength(message.to_string()) + } + + fn msm(bases: &[E::G1Affine], scalars: &[E::ScalarField]) -> Result { + ::msm(bases, scalars).map_err(|_| { + Self::invalid_input_length("KZH MSM bases and scalars have different lengths") + }) + } + + fn msm_bigint( + bases: &[E::G1Affine], + scalars: &[::BigInt], + ) -> Result { + if bases.len() != scalars.len() { + return Err(Self::invalid_input_length( + "KZH MSM bases and scalars have different lengths", + )); + } + Ok(::msm_bigint(bases, scalars)) + } + + fn compute_auxiliary_tables( + ck: &CommitterKey, + evaluations: &[::BigInt], + ) -> Result>, Error> { + let dimensions = block_dimensions(&ck.num_vars_per_block)?; + let auxiliary_lengths = auxiliary_prefix_lengths(&dimensions)?; + let mut auxiliary_tables = Vec::with_capacity(auxiliary_lengths.len()); + for (level, &expected_table_len) in auxiliary_lengths.iter().enumerate() { + let bases = &ck.h[level + 1]; + let suffix_len = bases.len(); + if suffix_len == 0 || evaluations.len() % suffix_len != 0 { + return Err(Self::invalid_input_length( + "KZH evaluation table does not match the commitment key", + )); + } + + let table_len = evaluations.len() / suffix_len; + if table_len != expected_table_len { + return Err(Self::invalid_input_length( + "KZH auxiliary table dimensions do not match the commitment key", + )); + } + + let compute_row = + |row: usize, coefficients: &mut Vec<::BigInt>| { + // Arkworks' low variables are the least-significant tensor + // axes. Hence a fixed low-prefix assignment is strided + // across the higher-variable suffix. + coefficients.clear(); + coefficients.extend( + (0..suffix_len) + .map(|suffix_index| evaluations[row + table_len * suffix_index]), + ); + Self::msm_bigint(bases, coefficients) + }; + + #[cfg(feature = "parallel")] + let row_commitments = { + // Large MSMs already saturate Arkworks' Rayon pool internally. + // Parallelize only tables made of many smaller MSMs, and bound + // the number of scratch buffers to approximately one per worker. + const MAX_PARALLEL_SUFFIX_LEN: usize = 1 << 12; + let num_threads = rayon::current_num_threads(); + if num_threads > 1 + && table_len >= num_threads + && suffix_len <= MAX_PARALLEL_SUFFIX_LEN + { + let min_rows_per_task = table_len.div_ceil(num_threads); + (0..table_len) + .into_par_iter() + .with_min_len(min_rows_per_task) + .map_init( + || Vec::with_capacity(suffix_len), + |coefficients, row| compute_row(row, coefficients), + ) + .collect::, _>>()? + } else { + let mut rows = Vec::with_capacity(table_len); + let mut coefficients = Vec::with_capacity(suffix_len); + for row in 0..table_len { + rows.push(compute_row(row, &mut coefficients)?); + } + rows + } + }; + + #[cfg(not(feature = "parallel"))] + let row_commitments = { + let mut rows = Vec::with_capacity(table_len); + let mut coefficients = Vec::with_capacity(suffix_len); + for row in 0..table_len { + rows.push(compute_row(row, &mut coefficients)?); + } + rows + }; + auxiliary_tables.push(E::G1::normalize_batch(&row_commitments)); + } + Ok(auxiliary_tables) + } + + fn commitment_matches_num_vars(commitment: &Commitment, num_vars: usize) -> bool { + commitment.num_vars == num_vars || (commitment.num_vars == 0 && commitment.comm.is_zero()) + } + + fn cached_opening_layer( + states: &[&CommitmentState], + challenges: &[E::ScalarField], + prefix_weights: &[E::ScalarField], + level: usize, + dimension: usize, + expected_scalar_terms: usize, + ) -> Result, Error> { + // The cached table stores one contiguous column per current block + // index. Fuse the polynomial-batching challenges with + // eq(point_prefix), so no aggregated auxiliary table is allocated or + // contracted a second time. + let expected_len = prefix_weights.len().checked_mul(dimension).ok_or_else(|| { + Error::InvalidParameters("KZH prefix contraction size overflow".to_string()) + })?; + for state in states { + if state.auxiliary_tables[level].len() != expected_len { + return Err(Self::invalid_input_length( + "KZH auxiliary table does not match its prefix weights", + )); + } + } + + if states.len() == 1 && level == 0 { + // There is no prior point prefix to contract at the first level, + // so the cached table is already the first proof layer. + return Ok(states[0].auxiliary_tables[0].clone()); + } + + if states.len() == 1 { + if expected_len != expected_scalar_terms { + return Err(Error::InvalidParameters( + "KZH cached opening plan has an incorrect size".to_string(), + )); + } + let projective = states[0].auxiliary_tables[level] + .chunks_exact(prefix_weights.len()) + .map(|column| Self::msm(column, prefix_weights)) + .collect::, _>>()?; + return Ok(E::G1::normalize_batch(&projective)); + } + + let combined_len = states + .len() + .checked_mul(prefix_weights.len()) + .ok_or_else(|| { + Error::InvalidParameters("KZH batched prefix contraction size overflow".to_string()) + })?; + if combined_len.checked_mul(dimension) != Some(expected_scalar_terms) { + return Err(Error::InvalidParameters( + "KZH batched opening plan has an incorrect size".to_string(), + )); + } + + let mut combined_weights = Vec::with_capacity(combined_len); + for challenge in challenges { + combined_weights.extend( + prefix_weights + .iter() + .map(|prefix_weight| *challenge * prefix_weight), + ); + } + + let mut projective = Vec::with_capacity(dimension); + let mut combined_bases = Vec::with_capacity(combined_len); + for current_index in 0..dimension { + let start = current_index * prefix_weights.len(); + let end = start + prefix_weights.len(); + combined_bases.clear(); + for state in states { + combined_bases.extend_from_slice(&state.auxiliary_tables[level][start..end]); + } + projective.push(Self::msm(&combined_bases, &combined_weights)?); + } + Ok(E::G1::normalize_batch(&projective)) + } + + fn direct_opening_layer( + ck: &CommitterKey, + partially_evaluated: &DenseMultilinearExtension, + level: usize, + dimension: usize, + expected_scalar_terms: usize, + ) -> Result, Error> { + if partially_evaluated.evaluations.len() != expected_scalar_terms { + return Err(Error::InvalidParameters( + "KZH direct opening plan has an incorrect size".to_string(), + )); + } + + let bases = &ck.h[level + 1]; + let suffix_len = bases.len(); + if suffix_len == 0 + || partially_evaluated.evaluations.len() % suffix_len != 0 + || partially_evaluated.evaluations.len() / suffix_len != dimension + { + return Err(Self::invalid_input_length( + "KZH partial evaluation does not match the commitment key", + )); + } + + let mut projective = Vec::with_capacity(dimension); + let mut coefficients = Vec::with_capacity(suffix_len); + for current_index in 0..dimension { + coefficients.clear(); + coefficients.extend((0..suffix_len).map(|suffix_index| { + partially_evaluated.evaluations[current_index + dimension * suffix_index] + })); + projective.push(Self::msm(bases, &coefficients)?); + } + Ok(E::G1::normalize_batch(&projective)) + } + + fn open_evaluations( + ck: &CommitterKey, + evaluations: Vec, + point: &[E::ScalarField], + states: &[&CommitmentState], + challenges: &[E::ScalarField], + ) -> Result, Error> { + validate_committer_key_shape(ck)?; + if states.is_empty() || states.len() != challenges.len() { + return Err(Self::invalid_input_length( + "KZH states and batching challenges have different lengths or are empty", + )); + } + for state in states { + validate_state_shape(state)?; + if state.num_vars != ck.num_vars { + return Err(Self::invalid_input_length( + "KZH state and committer key support different numbers of variables", + )); + } + } + + let dimensions = block_dimensions(&ck.num_vars_per_block)?; + let expected_evaluations = product(&dimensions)?; + if evaluations.len() != expected_evaluations { + return Err(Self::invalid_input_length( + "KZH polynomial has an incorrect evaluation-table length", + )); + } + let point_blocks = split_point_low_to_high(point, &ck.num_vars_per_block)?; + let work_plan = opening_work_plan(&dimensions, states.len())?; + + let mut proof_layers = Vec::with_capacity(work_plan.len()); + let mut partially_evaluated = + DenseMultilinearExtension::from_evaluations_vec(ck.num_vars, evaluations); + let mut prefix_weights = vec![E::ScalarField::one()]; + for (level, (point_block, layer_plan)) in point_blocks.iter().zip(&work_plan).enumerate() { + let dimension = dimensions[level]; + let proof_layer = match layer_plan.source { + OpeningLayerSource::Cached => Self::cached_opening_layer( + states, + challenges, + &prefix_weights, + level, + dimension, + layer_plan.scalar_terms, + )?, + OpeningLayerSource::Direct => Self::direct_opening_layer( + ck, + &partially_evaluated, + level, + dimension, + layer_plan.scalar_terms, + )?, + }; + proof_layers.push(proof_layer); + partially_evaluated = partially_evaluated.fix_variables(point_block); + + if matches!( + work_plan.get(level + 1), + Some(next) if next.source == OpeningLayerSource::Cached + ) { + let block_weights = arkworks_lagrange_evaluations(point_block)?; + let capacity = prefix_weights + .len() + .checked_mul(block_weights.len()) + .ok_or_else(|| { + Error::InvalidParameters( + "KZH prefix weight table size overflow".to_string(), + ) + })?; + let mut next_weights = Vec::with_capacity(capacity); + // In arkworks' little-endian order the already-fixed prefix is + // the inner (faster) tensor axis and this new block is outer. + for block_weight in &block_weights { + for prefix_weight in &prefix_weights { + next_weights.push(*prefix_weight * block_weight); + } + } + prefix_weights = next_weights; + } + } + + let proof = Proof { + k: K, + num_vars: ck.num_vars, + layer_commitments: proof_layers, + final_evaluations: partially_evaluated.evaluations, + }; + validate_proof_shape(&proof)?; + Ok(proof) + } + + fn verify_opening_with_g2( + vk: &VerifierKey, + pairing_v: &Q, + pairing_v_tau: &[Vec], + commitment: E::G1Affine, + point: &[E::ScalarField], + value: E::ScalarField, + proof: &Proof, + ) -> Result + where + Q: Clone + Into, + { + validate_verifier_key_shape(vk)?; + if pairing_v_tau.len() != vk.v_tau.len() + || pairing_v_tau + .iter() + .zip(&vk.v_tau) + .any(|(prepared, affine)| prepared.len() != affine.len()) + { + return Err(Error::InvalidParameters( + "incorrect KZH prepared verifier-key dimensions".to_string(), + )); + } + validate_proof_shape(proof)?; + if proof.num_vars != vk.num_vars { + return Err(Self::invalid_input_length( + "KZH proof and verifier key support different numbers of variables", + )); + } + + let point_blocks = split_point_low_to_high(point, &vk.num_vars_per_block)?; + let mut current_commitment = commitment; + for (level, point_block) in point_blocks + .iter() + .take(point_blocks.len().saturating_sub(1)) + .enumerate() + { + let proof_layer = &proof.layer_commitments[level]; + + let mut g1_terms = Vec::with_capacity(proof_layer.len() + 1); + let mut g2_terms = Vec::with_capacity(proof_layer.len() + 1); + g1_terms.push(current_commitment); + g2_terms.push(pairing_v.clone()); + for (d_i, v_i) in proof_layer.iter().zip(&pairing_v_tau[level]) { + g1_terms.push((-d_i.into_group()).into_affine()); + g2_terms.push(v_i.clone()); + } + + if !E::multi_pairing(g1_terms, g2_terms).is_zero() { + return Ok(false); + } + + let equality_vector = arkworks_lagrange_evaluations(point_block)?; + current_commitment = Self::msm(proof_layer, &equality_vector)?.into_affine(); + } + + let alleged_last_commitment = + Self::msm(&vk.h_last, &proof.final_evaluations)?.into_affine(); + if current_commitment != alleged_last_commitment { + return Ok(false); + } + + let last_point_block = point_blocks.last().ok_or(Error::InvalidNumberOfVariables)?; + let final_polynomial = DenseMultilinearExtension::from_evaluations_vec( + last_point_block.len(), + proof.final_evaluations.clone(), + ); + let alleged_value = final_polynomial.fix_variables(last_point_block)[0]; + Ok(alleged_value == value) + } + + fn absorb_length_prefixed_bytes(sponge: &mut impl CryptographicSponge, bytes: &[u8]) { + sponge.absorb(&(bytes.len() as u64).to_le_bytes().to_vec()); + sponge.absorb(&bytes.to_vec()); + } + + fn batch_challenges( + sponge: &mut impl CryptographicSponge, + commitments: &[&LabeledCommitment>], + point: &[E::ScalarField], + values: &[E::ScalarField], + num_vars: usize, + ) -> Result, Error> { + if commitments.is_empty() || commitments.len() != values.len() { + return Err(Self::invalid_input_length( + "KZH commitments and evaluations have different lengths or are empty", + )); + } + + Self::absorb_length_prefixed_bytes(sponge, BATCH_OPENING_DOMAIN_SEPARATOR); + Self::absorb_length_prefixed_bytes(sponge, &(K as u64).to_le_bytes()); + Self::absorb_length_prefixed_bytes(sponge, &(num_vars as u64).to_le_bytes()); + Self::absorb_length_prefixed_bytes(sponge, &(commitments.len() as u64).to_le_bytes()); + for (commitment, value) in commitments.iter().zip(values) { + Self::absorb_length_prefixed_bytes(sponge, commitment.label().as_bytes()); + let encoded = serialize_to_vec!(commitment.commitment().comm) + .map_err(|_| Error::TranscriptError)?; + Self::absorb_length_prefixed_bytes(sponge, &encoded); + let encoded_value = serialize_to_vec!(*value).map_err(|_| Error::TranscriptError)?; + Self::absorb_length_prefixed_bytes(sponge, &encoded_value); + } + sponge.absorb(&point.to_vec()); + + let mut challenges = Vec::with_capacity(commitments.len()); + challenges.push(E::ScalarField::one()); + if commitments.len() > 1 { + challenges.extend(sponge.squeeze_field_elements_with_sizes::( + &vec![CHALLENGE_SIZE; commitments.len() - 1], + )); + } + Ok(challenges) + } + + fn check_with_g2<'a, Q>( + vk: &VerifierKey, + pairing_inputs: PairingInputs<'_, Q>, + commitments: impl IntoIterator>>, + point: &'a P::Point, + values: impl IntoIterator, + proof: &Proof, + sponge: &mut impl CryptographicSponge, + ) -> Result + where + Q: Clone + Into, + Commitment: 'a, + { + validate_verifier_key_shape(vk)?; + if pairing_inputs.v_tau.len() != vk.v_tau.len() + || pairing_inputs + .v_tau + .iter() + .zip(&vk.v_tau) + .any(|(prepared, affine)| prepared.len() != affine.len()) + { + return Err(Error::InvalidParameters( + "incorrect KZH prepared verifier-key dimensions".to_string(), + )); + } + + let commitments: Vec<_> = commitments.into_iter().collect(); + let values: Vec<_> = values.into_iter().collect(); + if commitments.is_empty() || commitments.len() != values.len() { + return Err(Self::invalid_input_length( + "KZH commitments and claimed values have different lengths or are empty", + )); + } + for commitment in &commitments { + if let Some(bound) = commitment.degree_bound() { + return Err(Error::UnsupportedDegreeBound(bound)); + } + validate_commitment_shape(commitment.commitment())?; + if !Self::commitment_matches_num_vars(commitment.commitment(), vk.num_vars) { + return Err(Self::invalid_input_length( + "KZH commitment and verifier key support different numbers of variables", + )); + } + } + + split_point_low_to_high(point, &vk.num_vars_per_block)?; + validate_proof_shape(proof)?; + if proof.num_vars != vk.num_vars { + return Err(Self::invalid_input_length( + "KZH proof and verifier key support different numbers of variables", + )); + } + + // All validation is complete before the caller's sponge is mutated. + // This path is shared by ordinary and prepared verification so their + // Fiat--Shamir transcript and commitment aggregation cannot diverge. + let challenges = Self::batch_challenges(sponge, &commitments, point, &values, vk.num_vars)?; + let (aggregate_commitment, aggregate_value) = if commitments.len() == 1 { + (commitments[0].commitment().comm.into_group(), values[0]) + } else { + let mut aggregate_commitment = E::G1::zero(); + let mut aggregate_value = E::ScalarField::zero(); + for ((commitment, value), challenge) in commitments.iter().zip(values).zip(challenges) { + aggregate_commitment += commitment.commitment().comm * challenge; + aggregate_value += value * challenge; + } + (aggregate_commitment, aggregate_value) + }; + + Self::verify_opening_with_g2( + vk, + pairing_inputs.v, + pairing_inputs.v_tau, + aggregate_commitment.into_affine(), + point, + aggregate_value, + proof, + ) + } + + /// Checks a KZH opening using a verifier key whose `G2` inputs have + /// already been prepared for pairing. + /// + /// Uses the same transcript, aggregation, pairing equations, and result as + /// [`PolynomialCommitment::check`]. Prepare the key once when it will be + /// reused. + pub fn check_prepared<'a>( + prepared_vk: &PreparedVerifierKey, + commitments: impl IntoIterator>>, + point: &'a P::Point, + values: impl IntoIterator, + proof: &Proof, + sponge: &mut impl CryptographicSponge, + _rng: Option<&mut dyn RngCore>, + ) -> Result + where + Commitment: 'a, + { + Self::check_with_g2( + prepared_vk.verifier_key(), + PairingInputs { + v: prepared_vk.prepared_v(), + v_tau: prepared_vk.prepared_v_tau(), + }, + commitments, + point, + values, + proof, + sponge, + ) + } +} + +impl PolynomialCommitment for KZH +where + E: Pairing, + E::ScalarField: Absorb, + P: MultilinearExtension, +{ + type UniversalParams = UniversalParams; + type CommitterKey = CommitterKey; + type VerifierKey = VerifierKey; + type Commitment = Commitment; + type CommitmentState = CommitmentState; + type Proof = Proof; + type BatchProof = Vec; + type Error = Error; + + fn setup( + max_degree: usize, + num_vars: Option, + rng: &mut R, + ) -> Result { + if max_degree == 0 { + return Err(Error::DegreeIsZero); + } + if max_degree != 1 { + return Err(Error::InvalidParameters( + "KZH supports only multilinear polynomials of individual degree one".to_string(), + )); + } + + let num_vars = num_vars.ok_or(Error::InvalidNumberOfVariables)?; + let num_vars_per_block = balanced_block_sizes(num_vars, K)?; + let dimensions = block_dimensions(&num_vars_per_block)?; + let num_blocks = dimensions.len(); + let total_evaluations = product(&dimensions)?; + + let g = E::G1::rand(rng); + let v = E::G2::rand(rng); + let tau = Zeroizing::new( + dimensions + .iter() + .map(|&dimension| (0..dimension).map(|_| E::ScalarField::rand(rng)).collect()) + .collect::>>(), + ); + + // Every H_j is a fixed-base table at g. Build each suffix in arkworks' + // little-endian order, with its first/lowest tensor axis varying + // fastest. + let g_table = BatchMulPreprocessing::new(g, total_evaluations); + let mut h = Vec::with_capacity(num_blocks); + for suffix_start in 0..num_blocks { + let mut suffix_exponents = Zeroizing::new(vec![E::ScalarField::one()]); + for block_trapdoors in &tau[suffix_start..] { + let mut next = Zeroizing::new(Vec::with_capacity( + block_trapdoors.len() * suffix_exponents.len(), + )); + for trapdoor in block_trapdoors { + for suffix_exponent in suffix_exponents.iter() { + next.push(*trapdoor * suffix_exponent); + } + } + suffix_exponents = next; + } + h.push(g_table.batch_mul(&suffix_exponents)); + } + + let transition_count = num_blocks.saturating_sub(1); + let max_dimension = dimensions[..transition_count] + .iter() + .copied() + .max() + .unwrap_or(1); + let v_affine = v.into_affine(); + let v_table = BatchMulPreprocessing::new(v, max_dimension); + let v_tau = tau + .iter() + .take(transition_count) + .map(|trapdoors| v_table.batch_mul(trapdoors)) + .collect(); + + let params = UniversalParams { + k: K, + num_vars, + num_vars_per_block, + h, + v: v_affine, + v_tau, + }; + validate_params_shape(¶ms)?; + Ok(params) + } + + fn trim( + pp: &Self::UniversalParams, + supported_degree: usize, + supported_hiding_bound: usize, + enforced_degree_bounds: Option<&[usize]>, + ) -> Result<(Self::CommitterKey, Self::VerifierKey), Self::Error> { + validate_params_shape(pp)?; + if supported_degree == 0 { + return Err(Error::InvalidParameters( + "KZH supported degree must be one".to_string(), + )); + } + if supported_degree > 1 { + return Err(Error::TrimmingDegreeTooLarge); + } + if supported_hiding_bound != 0 { + return Err(Error::InvalidParameters( + "KZH does not support hiding commitments".to_string(), + )); + } + if let Some(bounds) = enforced_degree_bounds { + if bounds.is_empty() { + return Err(Error::EmptyDegreeBounds); + } + return Err(Error::UnsupportedDegreeBound(bounds[0])); + } + + let ck = CommitterKey { + k: pp.k, + num_vars: pp.num_vars, + num_vars_per_block: pp.num_vars_per_block.clone(), + h: pp.h.clone(), + }; + let vk = VerifierKey { + k: pp.k, + num_vars: pp.num_vars, + num_vars_per_block: pp.num_vars_per_block.clone(), + h_last: pp.h.last().ok_or(Error::InvalidNumberOfVariables)?.clone(), + v: pp.v, + v_tau: pp.v_tau.clone(), + }; + validate_committer_key_shape(&ck)?; + validate_verifier_key_shape(&vk)?; + Ok((ck, vk)) + } + + fn commit<'a>( + ck: &Self::CommitterKey, + polynomials: impl IntoIterator>, + _rng: Option<&mut dyn RngCore>, + ) -> Result< + ( + Vec>, + Vec, + ), + Self::Error, + > + where + P: 'a, + { + validate_committer_key_shape(ck)?; + let mut commitments = Vec::new(); + let mut states = Vec::new(); + for polynomial in polynomials { + if let Some(bound) = polynomial.degree_bound() { + return Err(Error::UnsupportedDegreeBound(bound)); + } + if polynomial.is_hiding() { + return Err(Error::InvalidParameters( + "KZH does not support hiding commitments".to_string(), + )); + } + if polynomial.num_vars() != ck.num_vars { + return Err(Error::MismatchedNumVars { + poly_nv: polynomial.num_vars(), + point_nv: ck.num_vars, + }); + } + + let evaluations = polynomial.to_evaluations(); + let evaluation_bigints = ark_std::cfg_into_iter!(evaluations) + .map(|evaluation| evaluation.into_bigint()) + .collect::>(); + let commitment = Commitment { + k: K, + num_vars: ck.num_vars, + comm: Self::msm_bigint(&ck.h[0], &evaluation_bigints)?.into_affine(), + }; + let state = CommitmentState { + k: K, + num_vars: ck.num_vars, + auxiliary_tables: Self::compute_auxiliary_tables(ck, &evaluation_bigints)?, + }; + validate_commitment_shape(&commitment)?; + validate_state_shape(&state)?; + commitments.push(LabeledCommitment::new( + polynomial.label().clone(), + commitment, + None, + )); + states.push(state); + } + Ok((commitments, states)) + } + + fn open<'a>( + ck: &Self::CommitterKey, + labeled_polynomials: impl IntoIterator>, + commitments: impl IntoIterator>, + point: &'a P::Point, + sponge: &mut impl CryptographicSponge, + states: impl IntoIterator, + _rng: Option<&mut dyn RngCore>, + ) -> Result + where + P: 'a, + Self::CommitmentState: 'a, + Self::Commitment: 'a, + { + validate_committer_key_shape(ck)?; + let polynomials: Vec<_> = labeled_polynomials.into_iter().collect(); + let commitments: Vec<_> = commitments.into_iter().collect(); + let states: Vec<_> = states.into_iter().collect(); + if polynomials.is_empty() + || polynomials.len() != commitments.len() + || polynomials.len() != states.len() + { + return Err(Self::invalid_input_length( + "KZH opening inputs have different lengths or are empty", + )); + } + for ((polynomial, commitment), state) in polynomials.iter().zip(&commitments).zip(&states) { + if polynomial.label() != commitment.label() { + return Err(Error::MismatchedLabels { + commitment_label: commitment.label().clone(), + polynomial_label: polynomial.label().clone(), + }); + } + if let Some(bound) = polynomial.degree_bound() { + return Err(Error::UnsupportedDegreeBound(bound)); + } + if polynomial.is_hiding() { + return Err(Error::InvalidParameters( + "KZH does not support hiding commitments".to_string(), + )); + } + if let Some(bound) = commitment.degree_bound() { + return Err(Error::UnsupportedDegreeBound(bound)); + } + if polynomial.num_vars() != ck.num_vars { + return Err(Error::MismatchedNumVars { + poly_nv: polynomial.num_vars(), + point_nv: ck.num_vars, + }); + } + validate_commitment_shape(commitment.commitment())?; + validate_state_shape(state)?; + if !Self::commitment_matches_num_vars(commitment.commitment(), ck.num_vars) + || state.num_vars != ck.num_vars + { + return Err(Self::invalid_input_length( + "KZH opening inputs support different numbers of variables", + )); + } + } + + split_point_low_to_high(point, &ck.num_vars_per_block)?; + let dimensions = block_dimensions(&ck.num_vars_per_block)?; + let evaluation_count = product(&dimensions)?; + let mut evaluation_tables = Vec::with_capacity(polynomials.len()); + for polynomial in &polynomials { + let evaluations = polynomial.to_evaluations(); + if evaluations.len() != evaluation_count { + return Err(Self::invalid_input_length( + "KZH polynomial has an incorrect evaluation-table length", + )); + } + evaluation_tables.push(evaluations); + } + + // Bind each claimed evaluation into the Fiat--Shamir challenges. If + // values were omitted, a prover could offset false claims within a + // batch while preserving the aggregate claim. All input validation is + // complete before the caller's sponge is mutated. + let claimed_values: Vec<_> = polynomials + .iter() + .map(|polynomial| polynomial.evaluate(point)) + .collect(); + let challenges = + Self::batch_challenges(sponge, &commitments, point, &claimed_values, ck.num_vars)?; + + // The first Fiat--Shamir coefficient is one, so use its evaluation + // table as the accumulator instead of multiplying it or allocating a + // separate zero-filled table. + let mut evaluation_tables = evaluation_tables.into_iter(); + let mut aggregate_evaluations = evaluation_tables.next().ok_or_else(|| { + Self::invalid_input_length("KZH opening requires at least one polynomial") + })?; + for (evaluations, challenge) in evaluation_tables.zip(challenges.iter().skip(1)) { + for (target, evaluation) in aggregate_evaluations.iter_mut().zip(evaluations) { + *target += *challenge * evaluation; + } + } + Self::open_evaluations(ck, aggregate_evaluations, point, &states, &challenges) + } + + fn check<'a>( + vk: &Self::VerifierKey, + commitments: impl IntoIterator>, + point: &'a P::Point, + values: impl IntoIterator, + proof: &Self::Proof, + sponge: &mut impl CryptographicSponge, + _rng: Option<&mut dyn RngCore>, + ) -> Result + where + Self::Commitment: 'a, + { + Self::check_with_g2( + vk, + PairingInputs { + v: &vk.v, + v_tau: &vk.v_tau, + }, + commitments, + point, + values, + proof, + sponge, + ) + } +} diff --git a/poly-commit/src/kzh/tests.rs b/poly-commit/src/kzh/tests.rs new file mode 100644 index 00000000..365ffb97 --- /dev/null +++ b/poly-commit/src/kzh/tests.rs @@ -0,0 +1,122 @@ +#[cfg(not(feature = "std"))] +use ark_std::{string::ToString, vec::Vec}; + +use crate::{tests::poseidon_sponge_for_test, LabeledPolynomial, PolynomialCommitment}; +use ark_bls12_381::{Bls12_381, Fr}; +use ark_ff::One; +use ark_poly::{DenseMultilinearExtension, MultilinearExtension, SparseMultilinearExtension}; +use ark_std::{test_rng, UniformRand}; +use rand_chacha::{rand_core::SeedableRng, ChaCha20Rng}; + +use super::KZH; + +type DenseKZH = KZH, K>; + +fn commit_open_check>( + num_vars: usize, + polynomial: &LabeledPolynomial, + rng: &mut ChaCha20Rng, +) { + let params = KZH::::setup(1, Some(num_vars), rng).unwrap(); + let (ck, vk) = KZH::::trim(¶ms, 1, 0, None).unwrap(); + let point: Vec = (0..num_vars).map(|_| Fr::rand(rng)).collect(); + let value = polynomial.evaluate(&point); + let (commitments, states) = KZH::::commit(&ck, [polynomial], None).unwrap(); + let base_sponge = poseidon_sponge_for_test::(); + let proof = KZH::::open( + &ck, + [polynomial], + &commitments, + &point, + &mut base_sponge.clone(), + &states, + None, + ) + .unwrap(); + assert!(KZH::::check( + &vk, + &commitments, + &point, + [value], + &proof, + &mut base_sponge.clone(), + None, + ) + .unwrap()); +} + +#[test] +fn commit_open_check_supports_dense_and_sparse_polynomials() { + let mut rng = ChaCha20Rng::from_rng(test_rng()).unwrap(); + const NUM_VARS: usize = 8; + + let dense = LabeledPolynomial::new( + "dense".to_string(), + DenseMultilinearExtension::rand(NUM_VARS, &mut rng), + None, + None, + ); + commit_open_check::<4, _>(NUM_VARS, &dense, &mut rng); + + let sparse = LabeledPolynomial::new( + "sparse".to_string(), + SparseMultilinearExtension::rand_with_config(NUM_VARS, 1 << 4, &mut rng), + None, + None, + ); + commit_open_check::<4, _>(NUM_VARS, &sparse, &mut rng); +} + +#[test] +fn commit_open_check_supports_an_uneven_tensor_partition() { + let mut rng = ChaCha20Rng::from_rng(test_rng()).unwrap(); + const NUM_VARS: usize = 5; + + let dense = LabeledPolynomial::new( + "uneven".to_string(), + DenseMultilinearExtension::rand(NUM_VARS, &mut rng), + None, + None, + ); + commit_open_check::<3, _>(NUM_VARS, &dense, &mut rng); +} + +#[test] +fn check_rejects_an_incorrect_evaluation() { + let mut rng = ChaCha20Rng::from_rng(test_rng()).unwrap(); + const NUM_VARS: usize = 8; + + let params = DenseKZH::<4>::setup(1, Some(NUM_VARS), &mut rng).unwrap(); + let (ck, vk) = DenseKZH::<4>::trim(¶ms, 1, 0, None).unwrap(); + let polynomial = LabeledPolynomial::new( + "wrong-value".to_string(), + DenseMultilinearExtension::rand(NUM_VARS, &mut rng), + None, + None, + ); + let point: Vec = (0..NUM_VARS).map(|_| Fr::rand(&mut rng)).collect(); + let value = polynomial.evaluate(&point); + let (commitments, states) = DenseKZH::<4>::commit(&ck, [&polynomial], None).unwrap(); + let base_sponge = poseidon_sponge_for_test::(); + let proof = DenseKZH::<4>::open( + &ck, + [&polynomial], + &commitments, + &point, + &mut base_sponge.clone(), + &states, + None, + ) + .unwrap(); + + assert!(!DenseKZH::<4>::check( + &vk, + &commitments, + &point, + [value + Fr::one()], + &proof, + &mut base_sponge.clone(), + None, + ) + .unwrap()); +} diff --git a/poly-commit/src/kzh/utils.rs b/poly-commit/src/kzh/utils.rs new file mode 100644 index 00000000..44bb7d39 --- /dev/null +++ b/poly-commit/src/kzh/utils.rs @@ -0,0 +1,465 @@ +use crate::{ + kzh::data_structures::{ + Commitment, CommitmentState, CommitterKey, Proof, UniversalParams, VerifierKey, + }, + Error, +}; +use ark_ec::{pairing::Pairing, AffineRepr}; +use ark_ff::Field; +use ark_poly::{DenseMultilinearExtension, MultilinearExtension}; +use ark_std::vec::Vec; + +/// Splits `num_vars` into `k` nonempty, balanced tensor blocks ordered +/// low-to-high, following ark-poly's variable order. +/// +/// When the division is uneven, the earliest (lowest-variable) blocks receive +/// one additional variable. +pub(crate) fn balanced_block_sizes(num_vars: usize, k: usize) -> Result, Error> { + if k < 2 || num_vars < k { + return Err(Error::InvalidNumberOfVariables); + } + + let quotient = num_vars / k; + let remainder = num_vars % k; + let mut sizes = vec![quotient; k]; + for size in sizes.iter_mut().take(remainder) { + *size += 1; + } + Ok(sizes) +} + +/// Converts variable counts into the corresponding Boolean-hypercube dimensions. +pub(crate) fn block_dimensions(num_vars_per_block: &[usize]) -> Result, Error> { + num_vars_per_block + .iter() + .map(|&num_vars| { + if num_vars == 0 || num_vars >= usize::BITS as usize { + Err(Error::InvalidParameters( + "KZH block dimension does not fit in usize".into(), + )) + } else { + Ok(1usize << num_vars) + } + }) + .collect() +} + +/// Computes a checked product of dimensions. +pub(crate) fn product(values: &[usize]) -> Result { + values.iter().try_fold(1usize, |accumulator, &value| { + accumulator.checked_mul(value).ok_or_else(|| { + Error::InvalidParameters("KZH tensor dimension does not fit in usize".into()) + }) + }) +} + +/// Returns the lengths of the auxiliary tables that reduce single-opening work. +/// +/// At level `j`, contracting a cached prefix table costs one group-scalar term +/// per entry in `prod(dimensions[..=j])`. Recommitting the current partially +/// evaluated tensor costs `prod(dimensions[j..])` terms. The first quantity is +/// increasing and the second decreasing, so useful auxiliary layers form a +/// prefix. A tie is left to the direct path to avoid equal-cost preprocessing +/// and storage. +pub(crate) fn auxiliary_prefix_lengths(dimensions: &[usize]) -> Result, Error> { + opening_auxiliary_prefix_lengths(dimensions, 1) +} + +/// Returns the auxiliary-table prefix worth using for a batched opening. +/// +/// The commitment state is independent of the eventual batch size, so it +/// stores every table useful to a single opening. When opening `batch_size` +/// polynomials together, directly fusing the batching challenge and point +/// contraction costs `batch_size * prod(dimensions[..=j])` group-scalar terms +/// at level `j`. This function selects only the stored prefix for which that +/// remains strictly cheaper than recommitting the aggregated field tensor. +pub(crate) fn opening_auxiliary_prefix_lengths( + dimensions: &[usize], + batch_size: usize, +) -> Result, Error> { + if batch_size == 0 { + return Err(Error::InvalidParameters( + "KZH opening batch must not be empty".into(), + )); + } + + let mut current_len = product(dimensions)?; + let mut prefix_len = 1usize; + let mut lengths = Vec::new(); + + for &dimension in dimensions.iter().take(dimensions.len().saturating_sub(1)) { + prefix_len = prefix_len.checked_mul(dimension).ok_or_else(|| { + Error::InvalidParameters("KZH auxiliary table size does not fit in usize".into()) + })?; + // `batch_size * prefix_len < current_len`, written with division to + // avoid overflowing usize for large batches. + if prefix_len > (current_len - 1) / batch_size { + break; + } + lengths.push(prefix_len); + current_len /= dimension; + } + Ok(lengths) +} + +/// How an opening proof layer is constructed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum OpeningLayerSource { + /// Contract a commitment-time auxiliary table. + Cached, + /// Commit the current partially evaluated field tensor directly. + Direct, +} + +/// The exact online group-scalar work selected for one opening layer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct OpeningLayerPlan { + pub(crate) source: OpeningLayerSource, + pub(crate) scalar_terms: usize, +} + +/// Plans the cached/direct opening crossover used by the prover. +pub(crate) fn opening_work_plan( + dimensions: &[usize], + batch_size: usize, +) -> Result, Error> { + let cached_levels = opening_auxiliary_prefix_lengths(dimensions, batch_size)?.len(); + let mut current_len = product(dimensions)?; + let mut prefix_len = 1usize; + let mut plan = Vec::with_capacity(dimensions.len().saturating_sub(1)); + + for (level, &dimension) in dimensions + .iter() + .take(dimensions.len().saturating_sub(1)) + .enumerate() + { + prefix_len = prefix_len + .checked_mul(dimension) + .ok_or_else(|| Error::InvalidParameters("KZH opening prefix size overflow".into()))?; + if level < cached_levels { + // For a single polynomial, the first cached table already is the + // first proof layer, so opening performs no online MSM here. + let scalar_terms = if batch_size == 1 && level == 0 { + 0 + } else { + batch_size.checked_mul(prefix_len).ok_or_else(|| { + Error::InvalidParameters("KZH cached opening work overflow".into()) + })? + }; + plan.push(OpeningLayerPlan { + source: OpeningLayerSource::Cached, + scalar_terms, + }); + } else { + plan.push(OpeningLayerPlan { + source: OpeningLayerSource::Direct, + scalar_terms: current_len, + }); + } + current_len /= dimension; + } + Ok(plan) +} + +/// Validates serialized family metadata and returns the block dimensions. +pub(crate) fn validate_family_metadata( + encoded_k: usize, + num_vars: usize, + num_vars_per_block: &[usize], +) -> Result, Error> { + if encoded_k != K { + return Err(Error::InvalidParameters( + "serialized KZH family parameter does not match its Rust type".into(), + )); + } + + let expected = balanced_block_sizes(num_vars, K)?; + if num_vars_per_block != expected.as_slice() { + return Err(Error::InvalidParameters( + "invalid KZH variable-block decomposition".into(), + )); + } + block_dimensions(num_vars_per_block) +} + +fn object_dimensions( + encoded_k: usize, + num_vars: usize, +) -> Result, Error> { + if encoded_k != K { + return Err(Error::InvalidParameters( + "serialized KZH family parameter does not match its Rust type".into(), + )); + } + let sizes = balanced_block_sizes(num_vars, K)?; + block_dimensions(&sizes) +} + +fn validate_h_layers(h: &[Vec], dimensions: &[usize]) -> Result<(), Error> { + if h.len() != dimensions.len() { + return Err(Error::InvalidParameters( + "incorrect number of KZH commitment-key layers".into(), + )); + } + + for (layer, suffix) in h + .iter() + .zip((0..dimensions.len()).map(|j| &dimensions[j..])) + { + let expected = product(suffix)?; + if layer.len() != expected { + return Err(Error::IncorrectCommitmentSize { + encountered: layer.len(), + expected, + }); + } + } + Ok(()) +} + +fn validate_v_tau(v_tau: &[Vec], dimensions: &[usize]) -> Result<(), Error> { + if v_tau.len() != dimensions.len() { + return Err(Error::InvalidParameters( + "incorrect number of KZH verifier-key layers".into(), + )); + } + + for (layer, &expected) in v_tau.iter().zip(dimensions) { + if layer.len() != expected { + return Err(Error::IncorrectCommitmentSize { + encountered: layer.len(), + expected, + }); + } + } + Ok(()) +} + +/// Validates the dimensions of KZH universal parameters. +pub(crate) fn validate_params_shape( + params: &UniversalParams, +) -> Result<(), Error> { + let dimensions = + validate_family_metadata::(params.k, params.num_vars, ¶ms.num_vars_per_block)?; + validate_h_layers(¶ms.h, &dimensions)?; + validate_v_tau( + ¶ms.v_tau, + &dimensions[..dimensions.len().saturating_sub(1)], + ) +} + +/// Validates the dimensions of a KZH committer key. +pub(crate) fn validate_committer_key_shape( + ck: &CommitterKey, +) -> Result<(), Error> { + let dimensions = validate_family_metadata::(ck.k, ck.num_vars, &ck.num_vars_per_block)?; + validate_h_layers(&ck.h, &dimensions) +} + +/// Validates the dimensions of a KZH verifier key. +pub(crate) fn validate_verifier_key_shape( + vk: &VerifierKey, +) -> Result<(), Error> { + let dimensions = validate_family_metadata::(vk.k, vk.num_vars, &vk.num_vars_per_block)?; + let expected_last = *dimensions.last().ok_or(Error::InvalidNumberOfVariables)?; + if vk.h_last.len() != expected_last { + return Err(Error::IncorrectCommitmentSize { + encountered: vk.h_last.len(), + expected: expected_last, + }); + } + validate_v_tau(&vk.v_tau, &dimensions[..dimensions.len().saturating_sub(1)]) +} + +/// Validates the metadata of a KZH commitment. +pub(crate) fn validate_commitment_shape( + commitment: &Commitment, +) -> Result<(), Error> { + // `PCCommitment::empty()` cannot know the key's number of variables. A + // zero group element with `num_vars == 0` is therefore an explicit empty + // sentinel that is valid for every key in the same KZH family. + if commitment.num_vars == 0 { + if commitment.k != K { + return Err(Error::InvalidParameters( + "serialized KZH family parameter does not match its Rust type".into(), + )); + } + if !commitment.comm.is_zero() { + return Err(Error::InvalidParameters( + "a KZH commitment with zero variables must be empty".into(), + )); + } + return Ok(()); + } + object_dimensions::(commitment.k, commitment.num_vars).map(|_| ()) +} + +/// Validates the dimensions of cached KZH commitment state. +pub(crate) fn validate_state_shape( + state: &CommitmentState, +) -> Result<(), Error> { + let dimensions = object_dimensions::(state.k, state.num_vars)?; + let expected_lengths = auxiliary_prefix_lengths(&dimensions)?; + if state.auxiliary_tables.len() != expected_lengths.len() { + return Err(Error::InvalidParameters( + "incorrect number of KZH auxiliary tables".into(), + )); + } + + for (table, &expected) in state.auxiliary_tables.iter().zip(&expected_lengths) { + if table.len() != expected { + return Err(Error::IncorrectCommitmentSize { + encountered: table.len(), + expected, + }); + } + } + Ok(()) +} + +/// Validates the dimensions of a KZH opening proof. +pub(crate) fn validate_proof_shape( + proof: &Proof, +) -> Result<(), Error> { + let dimensions = object_dimensions::(proof.k, proof.num_vars)?; + let transition_count = dimensions.len().saturating_sub(1); + if proof.layer_commitments.len() != transition_count { + return Err(Error::InvalidParameters( + "incorrect number of KZH proof layers".into(), + )); + } + + for (layer, &expected) in proof + .layer_commitments + .iter() + .zip(&dimensions[..transition_count]) + { + if layer.len() != expected { + return Err(Error::IncorrectCommitmentSize { + encountered: layer.len(), + expected, + }); + } + } + + let expected_final = *dimensions.last().ok_or(Error::InvalidNumberOfVariables)?; + if proof.final_evaluations.len() != expected_final { + return Err(Error::IncorrectCommitmentSize { + encountered: proof.final_evaluations.len(), + expected: expected_final, + }); + } + Ok(()) +} + +/// Splits a little-endian MLE point into low-to-high variable blocks. +/// +/// This is the same order used by ark-poly's `fix_variables`. For example, +/// sizes `[3, 2]` split `[x0, x1, x2, x3, x4]` into +/// `[[x0, x1, x2], [x3, x4]]`. +pub(crate) fn split_point_low_to_high<'a, F>( + point: &'a [F], + sizes: &[usize], +) -> Result, Error> { + let total = sizes.iter().try_fold(0usize, |accumulator, &size| { + if size == 0 { + return Err(Error::InvalidNumberOfVariables); + } + accumulator.checked_add(size).ok_or_else(|| { + Error::InvalidParameters("KZH point length does not fit in usize".into()) + }) + })?; + if total != point.len() { + return Err(Error::MismatchedNumVars { + poly_nv: total, + point_nv: point.len(), + }); + } + + let mut start = 0usize; + let mut blocks = Vec::with_capacity(sizes.len()); + for &size in sizes { + let end = start + .checked_add(size) + .filter(|&end| end <= point.len()) + .ok_or_else(|| Error::InvalidParameters("invalid KZH point decomposition".into()))?; + blocks.push(&point[start..end]); + start = end; + } + Ok(blocks) +} + +/// Builds the Boolean Lagrange evaluations using ark-poly's dense-MLE layout. +/// +/// Ark-poly does not expose its internal equality-table routine. Constructing +/// the table through `DenseMultilinearExtension::concat` makes each point +/// coordinate the next variable in ark-poly's canonical little-endian order. +pub(crate) fn arkworks_lagrange_evaluations(point: &[F]) -> Result, Error> { + if point.len() >= usize::BITS as usize { + return Err(Error::InvalidParameters( + "KZH equality table dimension does not fit in usize".into(), + )); + } + + let mut equality = DenseMultilinearExtension::from_evaluations_vec(0, vec![F::one()]); + for &coordinate in point { + let num_vars = equality.num_vars(); + let dimension = 1usize << num_vars; + let low_scalar = F::one() - coordinate; + + // ark-poly represents a zero polynomial as a zero-variate object when + // using scalar multiplication by zero. Preserve the current arity so + // `concat` still introduces exactly one new variable at Boolean points. + let low = if low_scalar.is_zero() { + DenseMultilinearExtension::from_evaluations_vec(num_vars, vec![F::zero(); dimension]) + } else { + &equality * &low_scalar + }; + let high = if coordinate.is_zero() { + DenseMultilinearExtension::from_evaluations_vec(num_vars, vec![F::zero(); dimension]) + } else { + &equality * &coordinate + }; + equality = DenseMultilinearExtension::concat(&[&low, &high]); + } + Ok(equality.evaluations) +} + +#[cfg(test)] +mod tests { + use super::{ + auxiliary_prefix_lengths, balanced_block_sizes, opening_auxiliary_prefix_lengths, + split_point_low_to_high, + }; + use ark_bls12_381::Fr; + + #[test] + fn balances_blocks_and_splits_low_to_high() { + assert_eq!(balanced_block_sizes(8, 3).unwrap(), [3, 3, 2]); + assert_eq!(balanced_block_sizes(6, 3).unwrap(), [2, 2, 2]); + let sizes = balanced_block_sizes(8, 3).unwrap(); + + let point: Vec<_> = (0u64..8).map(Fr::from).collect(); + let blocks = split_point_low_to_high(&point, &sizes).unwrap(); + assert_eq!(blocks[0], &point[0..3]); + assert_eq!(blocks[1], &point[3..6]); + assert_eq!(blocks[2], &point[6..8]); + } + + #[test] + fn caches_only_strictly_beneficial_auxiliary_prefixes() { + assert_eq!(auxiliary_prefix_lengths(&[4, 4, 4, 4]).unwrap(), [4, 16]); + assert_eq!(auxiliary_prefix_lengths(&[4, 4, 4]).unwrap(), [4]); + assert_eq!(auxiliary_prefix_lengths(&[4, 4, 2, 2]).unwrap(), [4]); + + assert_eq!( + opening_auxiliary_prefix_lengths(&[4, 4, 4, 4], 3).unwrap(), + [4, 16] + ); + assert_eq!( + opening_auxiliary_prefix_lengths(&[4, 4, 4, 4], 4).unwrap(), + [4] + ); + assert!(opening_auxiliary_prefix_lengths(&[4, 4], 0).is_err()); + } +} diff --git a/poly-commit/src/lib.rs b/poly-commit/src/lib.rs index 8a2381fe..a10c996d 100644 --- a/poly-commit/src/lib.rs +++ b/poly-commit/src/lib.rs @@ -111,6 +111,18 @@ pub mod ipa_pc; /// [zgkpp]: https://ieeexplore.ieee.org/document/8418645 pub mod multilinear_pc; +/// The pairing-based KZH-`k` multilinear polynomial commitment family. +/// +/// The type-level parameter `K` selects the tensor arity. This is the +/// non-hiding construction from [KZH-Fold][kzh], with the generic-opening +/// auxiliary tables described by [IronDict][irondict]. See the [`kzh`] module +/// for setup assumptions and the ways this implementation differs from the +/// papers. +/// +/// [kzh]: https://eprint.iacr.org/2025/144 +/// [irondict]: https://eprint.iacr.org/2025/1580 +pub mod kzh; + use ark_crypto_primitives::sponge::{CryptographicSponge, FieldElementSize}; /// Multivariate polynomial commitment based on the construction in /// [[PST13]][pst] with batching and (optional) hiding property inspired diff --git a/poly-commit/src/sonic_pc/data_structures.rs b/poly-commit/src/sonic_pc/data_structures.rs index 4ed8e500..bfc19e68 100644 --- a/poly-commit/src/sonic_pc/data_structures.rs +++ b/poly-commit/src/sonic_pc/data_structures.rs @@ -70,7 +70,7 @@ pub struct CommitterKey { impl CommitterKey { /// Obtain powers for the underlying KZG10 construction - pub fn powers(&self) -> kzg10::Powers { + pub fn powers<'a>(&'a self) -> kzg10::Powers<'a, E> { kzg10::Powers { powers_of_g: self.powers_of_g.as_slice().into(), powers_of_gamma_g: self.powers_of_gamma_g.as_slice().into(), @@ -78,10 +78,10 @@ impl CommitterKey { } /// Obtain powers for committing to shifted polynomials. - pub fn shifted_powers( - &self, + pub fn shifted_powers<'a>( + &'a self, degree_bound: impl Into>, - ) -> Option> { + ) -> Option> { match (&self.shifted_powers_of_g, &self.shifted_powers_of_gamma_g) { (Some(shifted_powers_of_g), Some(shifted_powers_of_gamma_g)) => { let max_bound = self