From f96aba24b1ef42e7f640303b11a713a25db31178 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 13 Oct 2026 12:58:46 +0200 Subject: [PATCH 01/12] feat(common): add two-witness selector forms and claims --- provekit/common/src/joint_claims.rs | 331 +++++++++++++++++++++++++ provekit/common/src/joint_covector.rs | 340 ++++++++++++++++++++++++++ provekit/common/src/lib.rs | 3 + 3 files changed, 674 insertions(+) create mode 100644 provekit/common/src/joint_claims.rs create mode 100644 provekit/common/src/joint_covector.rs diff --git a/provekit/common/src/joint_claims.rs b/provekit/common/src/joint_claims.rs new file mode 100644 index 000000000..101618768 --- /dev/null +++ b/provekit/common/src/joint_claims.rs @@ -0,0 +1,331 @@ +//! Linear claims for the virtual two-stage witness `[w1, w2]`. +//! +//! Each block has size `2^m`. Matrix columns split at `w1_size` before padding. +//! Claim order is A, B, C, public inputs, then challenges. + +use { + crate::{ + JointLinearForm, + prefix_covector::{build_prefix_covectors, make_challenge_weight, make_public_weight}, + }, + anyhow::{Result, ensure}, + ark_ff::Field, + whir::{ + algebra::{embedding::Embedding, mixed_dot}, + buffer::{Buffer, BufferOps}, + }, +}; + +/// Compute matrix claims from both source buffers without concatenating the +/// witnesses. +pub fn compute_joint_matrix_evals( + embedding: &M, + alphas: &[Vec; 3], + w1_size: usize, + witnesses: [&Buffer; 2], +) -> Result<[M::Target; 3]> { + ensure!( + alphas.iter().all(|alpha| alpha.len() == alphas[0].len()), + "Joint matrix covectors must have equal lengths" + ); + ensure!( + w1_size <= witnesses[0].len(), + "First witness buffer is too short" + ); + for alpha in alphas { + ensure!( + alpha.len() >= w1_size, + "Witness split exceeds matrix columns" + ); + ensure!( + alpha.len() - w1_size <= witnesses[1].len(), + "Second witness buffer is too short" + ); + } + Ok(alphas.each_ref().map(|alpha| { + let (left, right) = alpha.split_at(w1_size); + mixed_dot(embedding, left, &witnesses[0].to_slice()[..left.len()]) + + mixed_dot(embedding, right, &witnesses[1].to_slice()[..right.len()]) + })) +} + +/// Build combined matrix forms and optional binding forms. +/// +/// Public inputs belong to the first block. Challenge offsets refer to the +/// second block. The R1CS constant at index zero accompanies each public-input +/// form. +pub fn build_joint_forms( + m: usize, + w1_size: usize, + alphas: [Vec; 3], + binding_challenge: F, + num_public_inputs: usize, + challenge_offsets: &[usize], +) -> Result>> { + ensure!( + m > 0 && m < usize::BITS as usize - 1, + "Invalid joint block dimension" + ); + let block_size = 1usize << m; + let total_size = alphas[0].len(); + ensure!( + alphas.iter().all(|alpha| alpha.len() == total_size), + "Joint matrix covectors must have equal lengths" + ); + ensure!( + w1_size <= total_size, + "Witness split exceeds matrix columns" + ); + ensure!( + w1_size <= block_size && total_size - w1_size <= block_size, + "Witness block exceeds the joint block size" + ); + if num_public_inputs > 0 { + ensure!( + num_public_inputs < w1_size, + "Public inputs exceed the first witness block" + ); + } + ensure!( + challenge_offsets + .iter() + .all(|&offset| offset < total_size - w1_size), + "Challenge offset exceeds the second witness block" + ); + + let mut forms = Vec::with_capacity(5); + for mut alpha in alphas { + let right = alpha.split_off(w1_size); + let mut blocks = build_prefix_covectors(m, [alpha, right]).into_iter(); + let left = blocks.next().expect("first matrix block"); + let right = blocks.next().expect("second matrix block"); + forms.push(JointLinearForm::new(left, right)); + } + if num_public_inputs > 0 { + forms.push(JointLinearForm::left_only(make_public_weight( + binding_challenge, + num_public_inputs, + m, + ))); + } + if !challenge_offsets.is_empty() { + forms.push(JointLinearForm::right_only(make_challenge_weight( + binding_challenge, + challenge_offsets, + m, + ))); + } + Ok(forms) +} + +#[cfg(test)] +mod tests { + use { + super::*, + whir::algebra::{fields::Field64 as F, linear_form::LinearForm, multilinear_extend}, + }; + + fn fields(values: &[u64]) -> Vec { + values.iter().copied().map(F::from).collect() + } + + fn dense(form: &JointLinearForm) -> Vec { + let mut weights = vec![F::from(0); form.size()]; + form.accumulate(&mut weights, F::from(1)); + weights + } + + fn dot(weights: &[F], witness: &[F]) -> F { + weights.iter().zip(witness).map(|(a, b)| *a * b).sum() + } + + #[test] + fn joint_claims_prove_three_times_four_equals_twelve() { + // Original columns: w1 = [1, 3, 12], w2 = [4, 9]. + // A selects 3. B selects 4. C selects 12. + let forms = build_joint_forms( + 2, + 3, + [ + fields(&[0, 1, 0, 0, 0]), + fields(&[0, 0, 0, 1, 0]), + fields(&[0, 0, 1, 0, 0]), + ], + F::from(2), + 0, + &[], + ) + .unwrap(); + let witness = fields(&[1, 3, 12, 0, 4, 9, 0, 0]); + let values: Vec = forms + .iter() + .map(|form| dot(&dense(form), &witness)) + .collect(); + assert_eq!(values, fields(&[3, 4, 12])); + assert_eq!(values[0] * values[1], values[2]); + + // Changing either selected witness breaks the same constraint. + for changed_index in [1, 4] { + let mut changed = witness.clone(); + changed[changed_index] += F::from(1); + let values: Vec = forms + .iter() + .map(|form| dot(&dense(form), &changed)) + .collect(); + assert_ne!(values[0] * values[1], values[2]); + } + } + + #[test] + fn joint_claims_add_stage_contributions_without_selector_weights() { + let forms = build_joint_forms( + 2, + 3, + [ + fields(&[2, 3, 4, 11, 13]), + fields(&[0, 1, 0, 0, 1]), + fields(&[0, 0, 1, 1, 0]), + ], + F::from(2), + 0, + &[], + ) + .unwrap(); + let witness = fields(&[1, 2, 3, 0, 5, 7, 0, 0]); + // A = (2 + 6 + 12) + (55 + 91) = 166. + let values: Vec = forms + .iter() + .map(|form| dot(&dense(form), &witness)) + .collect(); + assert_eq!(values, fields(&[166, 9, 8])); + } + + #[test] + fn joint_matrix_evaluations_use_both_buffers_without_concatenation() { + use whir::algebra::embedding::Identity; + + let alphas = [ + fields(&[2, 3, 4, 11, 13]), + fields(&[0, 1, 0, 0, 1]), + fields(&[0, 0, 1, 1, 0]), + ]; + let left = Buffer::from(fields(&[1, 2, 3, 0])); + let right = Buffer::from(fields(&[5, 7, 0, 0])); + let values = + compute_joint_matrix_evals(&Identity::new(), &alphas, 3, [&left, &right]).unwrap(); + assert_eq!(values, [F::from(166), F::from(9), F::from(8)]); + assert!( + compute_joint_matrix_evals(&Identity::new(), &alphas, 3, [ + &Buffer::from(fields(&[1, 2])), + &right, + ]) + .is_err() + ); + assert!( + compute_joint_matrix_evals(&Identity::new(), &alphas, 3, [ + &left, + &Buffer::from(fields(&[5])), + ]) + .is_err() + ); + } + + #[test] + fn joint_matrix_evaluations_preserve_extension_field_coefficients() { + use whir::algebra::{embedding::Basefield, fields::Field64_3 as E}; + + let coefficient = + E::from_base_prime_field_elems([F::from(2), F::from(3), F::from(5)]).unwrap(); + let alphas = std::array::from_fn(|_| vec![coefficient, coefficient * E::from(2)]); + let values = compute_joint_matrix_evals(&Basefield::::new(), &alphas, 1, [ + &Buffer::from(fields(&[3])), + &Buffer::from(fields(&[7])), + ]) + .unwrap(); + // Each result is 3*c + 7*(2*c) = 17*c, including non-base coefficients. + assert_eq!(values, [coefficient * E::from(17); 3]); + } + + #[test] + fn joint_claims_keep_public_inputs_left_and_scattered_challenges_right() { + let forms = build_joint_forms( + 2, + 3, + [fields(&[0; 6]), fields(&[0; 6]), fields(&[0; 6])], + F::from(2), + 1, + &[2, 0], + ) + .unwrap(); + assert_eq!(forms.len(), 5); + assert_eq!(dense(&forms[3]), fields(&[1, 2, 0, 0, 0, 0, 0, 0])); + assert_eq!(dense(&forms[4]), fields(&[0, 0, 0, 0, 2, 0, 1, 0])); + + let witness = fields(&[1, 3, 12, 0, 4, 9, 5, 0]); + assert_eq!(dot(&dense(&forms[3]), &witness), F::from(7)); // 1 + 2*3 + assert_eq!(dot(&dense(&forms[4]), &witness), F::from(13)); // 5 + 2*4 + } + + #[test] + fn joint_claims_final_matrix_evaluations_match_the_combined_dense_matrix() { + let forms = build_joint_forms( + 2, + 3, + [ + fields(&[2, 3, 4, 11, 13]), + fields(&[0, 1, 0, 0, 1]), + fields(&[0, 0, 1, 1, 0]), + ], + F::from(2), + 0, + &[], + ) + .unwrap(); + let point = fields(&[2, 3, 5]); + for form in &forms { + assert_eq!( + form.mle_evaluate(&point), + multilinear_extend(&dense(form), &point) + ); + } + } + + #[test] + fn joint_claims_support_a_larger_second_stage_and_empty_stage() { + for split in [0, 1, 3, 4] { + let forms = build_joint_forms( + 2, + split, + std::array::from_fn(|_| fields(&[1, 2, 3, 4])), + F::from(2), + 0, + &[], + ) + .unwrap(); + let mut expected = vec![F::from(0); 8]; + expected[..split].copy_from_slice(&fields(&[1, 2, 3, 4])[..split]); + expected[4..4 + 4 - split].copy_from_slice(&fields(&[1, 2, 3, 4])[split..]); + assert_eq!(dense(&forms[0]), expected); + } + } + + #[test] + fn joint_claims_reject_invalid_layouts_and_binding_positions() { + let alphas = || std::array::from_fn(|_| fields(&[1, 2, 3, 4, 5])); + assert!(build_joint_forms(2, 6, alphas(), F::from(2), 0, &[]).is_err()); + assert!(build_joint_forms(2, 0, alphas(), F::from(2), 0, &[]).is_err()); + assert!(build_joint_forms(2, 3, alphas(), F::from(2), 3, &[]).is_err()); + assert!(build_joint_forms(2, 3, alphas(), F::from(2), 0, &[2]).is_err()); + assert!( + build_joint_forms( + 2, + 3, + [fields(&[1; 5]), fields(&[1; 4]), fields(&[1; 5])], + F::from(2), + 0, + &[] + ) + .is_err() + ); + } +} diff --git a/provekit/common/src/joint_covector.rs b/provekit/common/src/joint_covector.rs new file mode 100644 index 000000000..47a4d935b --- /dev/null +++ b/provekit/common/src/joint_covector.rs @@ -0,0 +1,340 @@ +use {ark_ff::Field, whir::algebra::linear_form::LinearForm}; + +/// A linear form over two equal witness blocks, without a combined allocation. +/// +/// The first coordinate selects a block: `L(s, x) = (1 - s) left(x) + s +/// right(x)`. Each missing branch represents the zero linear form. +pub struct JointLinearForm { + left: Option>>, + right: Option>>, + domain_size: usize, +} + +impl JointLinearForm { + /// Combine two linear forms with equal domain sizes. + /// + /// # Panics + /// + /// Panics if sizes differ, a size is not a nonzero power of two, or their + /// sum overflows. + #[must_use] + pub fn new, R: LinearForm>(left: L, right: R) -> Self { + assert_eq!( + left.size(), + right.size(), + "JointLinearForm: branch sizes must match" + ); + let domain_size = Self::checked_domain_size(left.size()); + Self { + left: Some(Box::new(left)), + right: Some(Box::new(right)), + domain_size, + } + } + + /// Place a linear form in the first block, with a zero second block. + /// + /// # Panics + /// + /// Panics if the size is not a nonzero power of two or doubling it + /// overflows. + #[must_use] + pub fn left_only>(left: L) -> Self { + let domain_size = Self::checked_domain_size(left.size()); + Self { + left: Some(Box::new(left)), + right: None, + domain_size, + } + } + + /// Place a linear form in the second block, with a zero first block. + /// + /// # Panics + /// + /// Panics if the size is not a nonzero power of two or doubling it + /// overflows. + #[must_use] + pub fn right_only>(right: R) -> Self { + let domain_size = Self::checked_domain_size(right.size()); + Self { + left: None, + right: Some(Box::new(right)), + domain_size, + } + } + + fn checked_domain_size(block_size: usize) -> usize { + assert!( + block_size.is_power_of_two(), + "JointLinearForm: block size must be a nonzero power of two" + ); + block_size + .checked_mul(2) + .expect("JointLinearForm: combined domain size overflows") + } +} + +impl LinearForm for JointLinearForm { + fn size(&self) -> usize { + self.domain_size + } + + fn mle_evaluate(&self, point: &[F]) -> F { + assert_eq!( + point.len(), + self.domain_size.trailing_zeros() as usize, + "JointLinearForm: point dimension must match the combined domain" + ); + let (selector, rest) = point + .split_first() + .expect("the combined domain has two blocks"); + let left = self + .left + .as_ref() + .map_or(F::ZERO, |form| form.mle_evaluate(rest)); + let right = self + .right + .as_ref() + .map_or(F::ZERO, |form| form.mle_evaluate(rest)); + (F::ONE - selector) * left + *selector * right + } + + fn accumulate(&self, accumulator: &mut [F], scalar: F) { + assert_eq!( + accumulator.len(), + self.domain_size, + "JointLinearForm: accumulator size must match the combined domain" + ); + let (left, right) = accumulator.split_at_mut(self.domain_size / 2); + if let Some(form) = &self.left { + form.accumulate(left, scalar); + } + if let Some(form) = &self.right { + form.accumulate(right, scalar); + } + } +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::PrefixCovector, + ark_ff::AdditiveGroup, + whir::algebra::{fields::Field64, multilinear_extend}, + }; + + fn field(value: u64) -> Field64 { + Field64::from(value) + } + + fn prefix(values: &[u64], block_size: usize) -> PrefixCovector { + PrefixCovector::new(values.iter().copied().map(field).collect(), block_size) + } + + fn example() -> JointLinearForm { + JointLinearForm::new(prefix(&[2, 4], 2), prefix(&[10, 14], 2)) + } + + #[test] + fn selector_zero_reads_the_first_block_and_selector_one_reads_the_second_block() { + let form = example(); + + // At x = 3, the first block evaluates to 8, and the second evaluates to 22. + assert_eq!(form.size(), 4); + assert_eq!(form.mle_evaluate(&[field(0), field(3)]), field(8)); + assert_eq!(form.mle_evaluate(&[field(1), field(3)]), field(22)); + } + + #[test] + fn non_boolean_selector_uses_the_same_linear_combination() { + let form = example(); + + // At s = 2 and x = 3, the result is (1 - 2) * 8 + 2 * 22 = 36. + assert_eq!(form.mle_evaluate(&[field(2), field(3)]), field(36)); + } + + #[test] + fn joint_evaluation_matches_the_dense_vector_at_every_boolean_point() { + let form = JointLinearForm::new(prefix(&[2, 3, 5, 7], 4), prefix(&[11, 13, 17, 19], 4)); + let dense: Vec<_> = [2, 3, 5, 7, 11, 13, 17, 19].map(field).into(); + + for index in 0..8 { + let point = [ + field((index >> 2) & 1), + field((index >> 1) & 1), + field(index & 1), + ]; + assert_eq!( + form.mle_evaluate(&point), + dense[index as usize], + "index {index}" + ); + assert_eq!( + form.mle_evaluate(&point), + multilinear_extend(&dense, &point) + ); + } + } + + #[test] + fn joint_evaluation_matches_the_dense_vector_at_non_boolean_points() { + let form = JointLinearForm::new(prefix(&[2, 3, 5, 7], 4), prefix(&[11, 13, 17, 19], 4)); + let dense: Vec<_> = [2, 3, 5, 7, 11, 13, 17, 19].map(field).into(); + + for point in [[2, 3, 5], [7, 11, 13], [17, 19, 23]] { + let point = point.map(field); + assert_eq!( + form.mle_evaluate(&point), + multilinear_extend(&dense, &point) + ); + } + } + + #[test] + fn selector_and_weights_preserve_non_base_extension_components() { + use whir::algebra::fields::Field64_3 as E; + + // u has a nonzero extension component. It does not belong to Field64. + let u = E::from_base_prime_field_elems([field(0), field(1), field(0)]).unwrap(); + let form = JointLinearForm::new( + PrefixCovector::new(vec![E::from(2), u], 2), + PrefixCovector::new(vec![E::from(3) * u, E::from(7)], 2), + ); + let point = [u + E::from(1), u + E::from(2)]; + let dense = [E::from(2), u, E::from(3) * u, E::from(7)]; + + assert_eq!( + form.mle_evaluate(&point), + multilinear_extend(&dense, &point) + ); + let mut actual = [E::ZERO; 4]; + form.accumulate(&mut actual, u); + assert_eq!(actual, dense.map(|weight| u * weight)); + } + + #[test] + fn unequal_prefix_lengths_have_separate_zero_padding() { + let form = JointLinearForm::new(prefix(&[2, 3, 5, 7], 4), prefix(&[11, 13], 4)); + let dense: Vec<_> = [2, 3, 5, 7, 11, 13, 0, 0].map(field).into(); + let mut actual = vec![Field64::ZERO; 8]; + form.accumulate(&mut actual, Field64::ONE); + + assert_eq!(actual, dense); + for point in [[0, 1, 1], [1, 1, 1], [2, 3, 5]] { + let point = point.map(field); + assert_eq!( + form.mle_evaluate(&point), + multilinear_extend(&dense, &point) + ); + } + + // Swapping the blocks preserves each block's padding. + let swapped = JointLinearForm::new(prefix(&[11, 13], 4), prefix(&[2, 3, 5, 7], 4)); + let mut actual = vec![Field64::ZERO; 8]; + swapped.accumulate(&mut actual, Field64::ONE); + assert_eq!(actual, [11, 13, 0, 0, 2, 3, 5, 7].map(field)); + } + + #[test] + fn accumulation_adds_scaled_weights_to_existing_values() { + let form = example(); + let mut accumulator = [100, 200, 300, 400].map(field); + + form.accumulate(&mut accumulator, field(3)); + + assert_eq!(accumulator, [106, 212, 330, 442].map(field)); + form.accumulate(&mut accumulator, Field64::ZERO); + assert_eq!(accumulator, [106, 212, 330, 442].map(field)); + } + + #[test] + fn one_branch_forms_leave_the_other_block_unchanged() { + let left = JointLinearForm::left_only(prefix(&[2, 4], 2)); + let right = JointLinearForm::right_only(prefix(&[10, 14], 2)); + let point = [field(2), field(3)]; + + assert_eq!(left.mle_evaluate(&point), -field(8)); + assert_eq!(right.mle_evaluate(&point), field(44)); + + let mut left_accumulator = [100, 200, 300, 400].map(field); + left.accumulate(&mut left_accumulator, field(3)); + assert_eq!(left_accumulator, [106, 212, 300, 400].map(field)); + + let mut right_accumulator = [100, 200, 300, 400].map(field); + right.accumulate(&mut right_accumulator, field(3)); + assert_eq!(right_accumulator, [100, 200, 330, 442].map(field)); + } + + #[test] + fn one_entry_blocks_need_only_the_selector_coordinate() { + let form = JointLinearForm::new(prefix(&[2], 1), prefix(&[10], 1)); + assert_eq!(form.mle_evaluate(&[field(3)]), field(26)); + } + + // This form checks sizes without allocating a witness or weight vector. + struct SizeOnly(usize); + + impl LinearForm for SizeOnly { + fn size(&self) -> usize { + self.0 + } + + fn mle_evaluate(&self, _point: &[Field64]) -> Field64 { + unreachable!() + } + + fn accumulate(&self, _accumulator: &mut [Field64], _scalar: Field64) { + unreachable!() + } + } + + #[test] + #[should_panic(expected = "branch sizes must match")] + fn construction_rejects_different_block_sizes() { + let _ = JointLinearForm::new(SizeOnly(2), SizeOnly(4)); + } + + #[test] + #[should_panic(expected = "block size must be a nonzero power of two")] + fn construction_rejects_zero_block_size() { + let _ = JointLinearForm::left_only(SizeOnly(0)); + } + + #[test] + #[should_panic(expected = "block size must be a nonzero power of two")] + fn construction_rejects_non_power_of_two_block_size() { + let _ = JointLinearForm::right_only(SizeOnly(3)); + } + + #[test] + #[should_panic(expected = "combined domain size overflows")] + fn construction_rejects_combined_size_overflow() { + let _ = JointLinearForm::left_only(SizeOnly(1 << (usize::BITS - 1))); + } + + #[test] + #[should_panic(expected = "point dimension must match the combined domain")] + fn evaluation_rejects_a_missing_selector_coordinate() { + example().mle_evaluate(&[field(3)]); + } + + #[test] + #[should_panic(expected = "point dimension must match the combined domain")] + fn evaluation_rejects_extra_coordinates() { + example().mle_evaluate(&[field(2), field(3), field(5)]); + } + + #[test] + #[should_panic(expected = "accumulator size must match the combined domain")] + fn accumulation_rejects_a_short_accumulator() { + example().accumulate(&mut [Field64::ZERO; 3], Field64::ONE); + } + + #[test] + #[should_panic(expected = "accumulator size must match the combined domain")] + fn accumulation_rejects_a_long_accumulator() { + example().accumulate(&mut [Field64::ZERO; 5], Field64::ONE); + } +} diff --git a/provekit/common/src/lib.rs b/provekit/common/src/lib.rs index 26a738079..56eed920e 100644 --- a/provekit/common/src/lib.rs +++ b/provekit/common/src/lib.rs @@ -6,6 +6,8 @@ pub use file::binary_format; pub mod field; pub mod hash_config; mod interner; +pub mod joint_claims; +pub mod joint_covector; mod logging; pub mod prefix_covector; pub mod public_inputs; @@ -24,6 +26,7 @@ pub use { compress::CompressedR1CS, field::{Base, Ext, FieldHash, ProofField}, hash_config::{HashConfig, POSEIDON2, SKYSCRAPER}, + joint_covector::JointLinearForm, logging::log_commit_input, prefix_covector::{OffsetCovector, PrefixCovector, SparseCovector}, public_inputs::{PublicInputs, PublicInputsHash}, From 64e9cc278a100a0fcdb3e2bad6d0cf14efdae5a0 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 13 Oct 2026 14:25:36 +0200 Subject: [PATCH 02/12] feat(common): define checked paired opening backend contract --- provekit/common/src/file/binary_format.rs | 8 +- provekit/common/src/joint_claims.rs | 48 +-- provekit/common/src/joint_opening.rs | 475 ++++++++++++++++++++++ provekit/common/src/lib.rs | 4 + provekit/common/src/whir_r1cs.rs | 68 +++- 5 files changed, 571 insertions(+), 32 deletions(-) create mode 100644 provekit/common/src/joint_opening.rs diff --git a/provekit/common/src/file/binary_format.rs b/provekit/common/src/file/binary_format.rs index 3e36b1906..a14bcf788 100644 --- a/provekit/common/src/file/binary_format.rs +++ b/provekit/common/src/file/binary_format.rs @@ -18,16 +18,16 @@ pub const XZ_MAGIC: [u8; 6] = [0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]; // --------------------------------------------------------------------------- pub const PROVER_FORMAT: [u8; 8] = *b"PrvKitPr"; -pub const PROVER_VERSION: (u16, u16) = (2, 0); +pub const PROVER_VERSION: (u16, u16) = (3, 0); pub const VERIFIER_FORMAT: [u8; 8] = *b"PrvKitVr"; -pub const VERIFIER_VERSION: (u16, u16) = (2, 1); +pub const VERIFIER_VERSION: (u16, u16) = (3, 0); pub const NOIR_PROOF_SCHEME_FORMAT: [u8; 8] = *b"NrProScm"; -pub const NOIR_PROOF_SCHEME_VERSION: (u16, u16) = (2, 0); +pub const NOIR_PROOF_SCHEME_VERSION: (u16, u16) = (3, 0); pub const NOIR_PROOF_FORMAT: [u8; 8] = *b"NPSProof"; -pub const NOIR_PROOF_VERSION: (u16, u16) = (2, 0); +pub const NOIR_PROOF_VERSION: (u16, u16) = (3, 0); pub const SPARK_PROOF_FORMAT: [u8; 8] = *b"SparkPrf"; pub const SPARK_PROOF_VERSION: (u16, u16) = (1, 0); diff --git a/provekit/common/src/joint_claims.rs b/provekit/common/src/joint_claims.rs index 101618768..33133f330 100644 --- a/provekit/common/src/joint_claims.rs +++ b/provekit/common/src/joint_claims.rs @@ -5,10 +5,10 @@ use { crate::{ - JointLinearForm, prefix_covector::{build_prefix_covectors, make_challenge_weight, make_public_weight}, + JointLinearForm, }, - anyhow::{Result, ensure}, + anyhow::{ensure, Result}, ark_ff::Field, whir::{ algebra::{embedding::Embedding, mixed_dot}, @@ -214,20 +214,16 @@ mod tests { let values = compute_joint_matrix_evals(&Identity::new(), &alphas, 3, [&left, &right]).unwrap(); assert_eq!(values, [F::from(166), F::from(9), F::from(8)]); - assert!( - compute_joint_matrix_evals(&Identity::new(), &alphas, 3, [ - &Buffer::from(fields(&[1, 2])), - &right, - ]) - .is_err() - ); - assert!( - compute_joint_matrix_evals(&Identity::new(), &alphas, 3, [ - &left, - &Buffer::from(fields(&[5])), - ]) - .is_err() - ); + assert!(compute_joint_matrix_evals(&Identity::new(), &alphas, 3, [ + &Buffer::from(fields(&[1, 2])), + &right, + ]) + .is_err()); + assert!(compute_joint_matrix_evals(&Identity::new(), &alphas, 3, [ + &left, + &Buffer::from(fields(&[5])), + ]) + .is_err()); } #[test] @@ -316,16 +312,14 @@ mod tests { assert!(build_joint_forms(2, 0, alphas(), F::from(2), 0, &[]).is_err()); assert!(build_joint_forms(2, 3, alphas(), F::from(2), 3, &[]).is_err()); assert!(build_joint_forms(2, 3, alphas(), F::from(2), 0, &[2]).is_err()); - assert!( - build_joint_forms( - 2, - 3, - [fields(&[1; 5]), fields(&[1; 4]), fields(&[1; 5])], - F::from(2), - 0, - &[] - ) - .is_err() - ); + assert!(build_joint_forms( + 2, + 3, + [fields(&[1; 5]), fields(&[1; 4]), fields(&[1; 5])], + F::from(2), + 0, + &[] + ) + .is_err()); } } diff --git a/provekit/common/src/joint_opening.rs b/provekit/common/src/joint_opening.rs new file mode 100644 index 000000000..258c5f364 --- /dev/null +++ b/provekit/common/src/joint_opening.rs @@ -0,0 +1,475 @@ +//! Interface for one opening of two ordered witness commitments. +//! +//! ProveKit supplies the two source commitments and forms over their combined +//! domain. WHIR supplies the joint protocol through [`JointOpeningBackend`]. + +use { + crate::{Ext, FieldHash}, + anyhow::{ensure, Result}, + ark_ff::Field, + serde::{Deserialize, Serialize}, + whir::{ + algebra::{embedding::Embedding, linear_form::LinearForm}, + protocols::{ + params::Mode, + zook::{ + Commitment, CommittedWitness, FinalClaim, ProtocolConfig as ZookConfig, ProverClaim, + }, + }, + transcript::{ProverState, VerifierState}, + }, +}; + +/// Select separate openings or one joint opening for a two-stage witness. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum WitnessOpeningMode { + /// Use the existing opening for each commitment. + #[default] + Separate, + /// Use the joint opening backend for both commitments. + Joint, +} + +impl std::str::FromStr for WitnessOpeningMode { + type Err = String; + + fn from_str(value: &str) -> std::result::Result { + match value.to_ascii_lowercase().as_str() { + "separate" => Ok(Self::Separate), + "joint" => Ok(Self::Joint), + _ => Err(format!( + "Invalid witness opening mode: '{value}'. Valid options: separate, joint" + )), + } + } +} + +/// Dimensions of the virtual witness `[w1 | w2]`. +/// +/// Each source commitment has `block_size` entries. +/// The first evaluation coordinate selects a block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JointOpeningLayout { + block_size: usize, + size: usize, + num_variables: usize, +} + +impl JointOpeningLayout { + /// Check the block size and derive the joint dimensions. + pub fn new(block_size: usize) -> Result { + ensure!( + block_size.is_power_of_two(), + "Joint opening block size must be a nonzero power of two" + ); + let size = block_size + .checked_mul(2) + .ok_or_else(|| anyhow::anyhow!("Joint opening domain size exceeds usize"))?; + Ok(Self { + block_size, + size, + num_variables: size.trailing_zeros() as usize, + }) + } + + /// Return the size of each source commitment. + pub const fn block_size(self) -> usize { + self.block_size + } + + /// Return the combined form size. + pub const fn size(self) -> usize { + self.size + } + + /// Return the evaluation point length, including the selector. + pub const fn num_variables(self) -> usize { + self.num_variables + } + + /// Check source dimensions and the existing source configuration + /// invariants. + /// + /// This check does not establish security for the joint protocol. + /// The backend must also validate its joint configuration. + pub fn validate_source_config(self, source: &ZookConfig) -> Result<()> { + ensure_joint_standard(source)?; + ensure!( + source.tuning().vector_size == self.block_size, + "Joint opening source size {} does not match block size {}", + source.tuning().vector_size, + self.block_size + ); + let first = source + .first_round() + .ok_or_else(|| anyhow::anyhow!("Joint opening requires a first code-switch round"))?; + ensure!( + first.code_switch().source().vector_size() == self.block_size, + "Joint opening first-round source size does not match the witness block" + ); + source + .validate() + .map_err(|error| anyhow::anyhow!("Invalid joint opening source configuration: {error}")) + } + + /// Check that every value has one form over the combined domain. + pub fn validate_claims( + self, + forms: &[&dyn LinearForm], + values: &[F], + ) -> Result<()> { + ensure!( + !forms.is_empty(), + "Joint opening requires at least one claim" + ); + ensure!( + forms.len() == values.len(), + "Joint opening form count {} does not match value count {}", + forms.len(), + values.len() + ); + for (index, form) in forms.iter().enumerate() { + ensure!( + form.size() == self.size, + "Joint opening form {index} has size {}, expected {}", + form.size(), + self.size + ); + } + Ok(()) + } + + /// Check the complete evaluation point before evaluating combined forms. + pub fn validate_point(self, point: &[F]) -> Result<()> { + ensure!( + point.len() == self.num_variables, + "Joint opening point has {} coordinates, expected {}", + point.len(), + self.num_variables + ); + Ok(()) + } + + /// Check the dimensions of the backend prover result. + pub fn validate_prover_claim( + self, + claim: &ProverClaim, + num_claims: usize, + ) -> Result<()> { + self.validate_point(&claim.evaluation_point)?; + self.validate_coefficients(claim.rlc_coefficients.len(), num_claims) + } + + /// Check the backend verifier result before its final form check. + pub fn validate_final_claim( + self, + claim: &FinalClaim, + num_claims: usize, + ) -> Result<()> { + self.validate_point(&claim.evaluation_point)?; + self.validate_coefficients(claim.rlc_coefficients.len(), num_claims) + } + + fn validate_coefficients(self, actual: usize, expected: usize) -> Result<()> { + ensure!( + actual == expected, + "Joint opening coefficient count {actual} does not match claim count {expected}" + ); + Ok(()) + } +} + +/// Reject joint ZK before any backend call or transcript interaction. +pub(crate) fn ensure_joint_standard(source: &ZookConfig) -> Result<()> { + ensure!( + source.security().mode == Mode::Standard, + "Joint zero-knowledge opening is unavailable: WHIR must fix the code-switch privacy leak \ + before joint ZK proofs are supported" + ); + Ok(()) +} + +/// Backend contract for one proof over two existing commitments. +/// +/// Array order fixes the selector: index 0 selects `w1`, and index 1 selects +/// `w2`. The backend must bind both commitments and all supplied form values. +/// It must sample shared claim coefficients after binding every claim value. +/// Public data or prior transcript messages must fix the forms before sampling. +/// ZeroKnowledge mode remains unavailable until WHIR fixes code-switch privacy. +/// A future ZeroKnowledge backend must preserve both source commitment masks. +/// It must return the complete point, with the selector coordinate first. +/// It must bind any derived joint configuration into the transcript. +/// Source configuration security alone does not establish joint protocol +/// security. Callers must validate the result dimensions and finish the +/// verifier's final form check. +pub trait JointOpeningBackend { + /// Check availability, source invariants, and security of the derived joint + /// configuration. + /// + /// Call this method before committing or changing the transcript. + fn validate(&self, source: &ZookConfig, layout: JointOpeningLayout) + -> Result<()>; + + /// Prove the supplied claims against the two ordered source witnesses. + #[allow(clippy::too_many_arguments)] + fn prove( + &self, + transcript: &mut ProverState, + source: &ZookConfig, + layout: JointOpeningLayout, + witnesses: [CommittedWitness; 2], + forms: &[&dyn LinearForm>], + values: &[Ext

], + ) -> Result>>; + + /// Verify the supplied claims against the two ordered source commitments. + #[allow(clippy::too_many_arguments)] + fn verify( + &self, + transcript: &mut VerifierState<'_, P::Sponge>, + source: &ZookConfig, + layout: JointOpeningLayout, + commitments: [Commitment; 2], + forms: &[&dyn LinearForm>], + values: &[Ext

], + ) -> Result>>; +} + +/// Explicit rejection until WHIR supplies a joint opening implementation. +#[derive(Debug, Clone, Copy, Default)] +pub struct UnavailableJointOpening; + +impl JointOpeningBackend

for UnavailableJointOpening { + fn validate( + &self, + source: &ZookConfig, + layout: JointOpeningLayout, + ) -> Result<()> { + layout.validate_source_config(source)?; + Err(joint_opening_unavailable()) + } + + fn prove( + &self, + _transcript: &mut ProverState, + source: &ZookConfig, + layout: JointOpeningLayout, + _witnesses: [CommittedWitness; 2], + forms: &[&dyn LinearForm>], + values: &[Ext

], + ) -> Result>> { + layout.validate_source_config(source)?; + layout.validate_claims(forms, values)?; + Err(joint_opening_unavailable()) + } + + fn verify( + &self, + _transcript: &mut VerifierState<'_, P::Sponge>, + source: &ZookConfig, + layout: JointOpeningLayout, + _commitments: [Commitment; 2], + forms: &[&dyn LinearForm>], + values: &[Ext

], + ) -> Result>> { + layout.validate_source_config(source)?; + layout.validate_claims(forms, values)?; + Err(joint_opening_unavailable()) + } +} + +fn joint_opening_unavailable() -> anyhow::Error { + anyhow::anyhow!( + "Joint witness opening is unavailable: WHIR must supply the joint opening backend; use \ + separate witness openings" + ) +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::PrefixCovector, + whir::{ + algebra::{ + embedding::{Basefield, Identity}, + fields::{Field64, Field64_3}, + }, + protocols::params::{ + DecodingRegime, FoldingFactor, Mode, PowBudget, RateSchedule, SecuritySpec, + TuningSpec, + }, + }, + }; + + fn field(value: u64) -> Field64 { + Field64::from(value) + } + + #[test] + fn two_blocks_of_four_need_eight_entries_and_three_coordinates() { + let layout = JointOpeningLayout::new(4).unwrap(); + assert_eq!(layout.block_size(), 4); + assert_eq!(layout.size(), 8); + assert_eq!(layout.num_variables(), 3); + } + + #[test] + fn a_single_entry_block_still_needs_a_selector() { + let layout = JointOpeningLayout::new(1).unwrap(); + assert_eq!(layout.size(), 2); + assert_eq!(layout.num_variables(), 1); + } + + #[test] + fn zero_and_non_power_of_two_blocks_are_rejected() { + for block_size in [0, 3, 6] { + assert!(JointOpeningLayout::new(block_size).is_err()); + } + } + + #[test] + fn an_overflowing_joint_domain_is_rejected() { + let largest_power_of_two = 1usize << (usize::BITS - 1); + assert!(JointOpeningLayout::new(largest_power_of_two).is_err()); + } + + #[test] + fn the_source_configuration_must_match_each_block() { + let source = ZookConfig::>::derive( + SecuritySpec { + mode: Mode::Standard, + decoding_regime: DecodingRegime::Johnson, + target_security_bits: 40, + pow_budget: PowBudget::per_slot(10), + hash_id: whir::hash::BLAKE3, + }, + TuningSpec { + vector_size: 32, + starting_log_inv_rate: 1, + folding_factor: FoldingFactor::Constant(2), + rate_schedule: RateSchedule::Stepping, + }, + ) + .unwrap(); + + assert!(JointOpeningLayout::new(32) + .unwrap() + .validate_source_config(&source) + .is_ok()); + assert!(JointOpeningLayout::new(4) + .unwrap() + .validate_source_config(&source) + .is_err()); + } + + fn extension_source(mode: Mode, size: usize) -> ZookConfig> { + ZookConfig::derive(SecuritySpec::new(128).with_mode(mode), TuningSpec { + vector_size: size, + starting_log_inv_rate: 2, + folding_factor: FoldingFactor::Constant(2), + rate_schedule: RateSchedule::Stepping, + }) + .unwrap() + } + + #[test] + fn joint_zero_knowledge_fails_preflight_even_with_valid_source_parameters() { + let source = extension_source(Mode::ZeroKnowledge, 32); + source.validate().unwrap(); + let error = JointOpeningLayout::new(32) + .unwrap() + .validate_source_config(&source) + .unwrap_err(); + assert!(error.to_string().contains("code-switch privacy leak")); + } + + #[test] + fn joint_standard_accepts_extension_fields_at_128_bits() { + let source = extension_source(Mode::Standard, 32); + JointOpeningLayout::new(32) + .unwrap() + .validate_source_config(&source) + .unwrap(); + } + + #[test] + fn a_source_without_a_code_switch_fails_preflight() { + let source = extension_source(Mode::Standard, 8); + let error = JointOpeningLayout::new(8) + .unwrap() + .validate_source_config(&source) + .unwrap_err(); + assert!(error.to_string().contains("first code-switch round")); + } + + #[test] + fn each_claim_needs_a_value_and_an_eight_entry_form() { + let layout = JointOpeningLayout::new(4).unwrap(); + let correct = PrefixCovector::new(vec![field(2), field(3)], 8); + let source_only = PrefixCovector::new(vec![field(2), field(3)], 4); + + assert!(layout.validate_claims(&[&correct], &[field(11)]).is_ok()); + assert!(layout.validate_claims(&[&correct], &[]).is_err()); + assert!(layout + .validate_claims(&[&source_only], &[field(11)]) + .is_err()); + assert!(layout.validate_claims::(&[], &[]).is_err()); + } + + #[test] + fn a_point_without_the_selector_is_rejected() { + let layout = JointOpeningLayout::new(4).unwrap(); + assert!(layout.validate_point(&[field(2), field(3)]).is_err()); + assert!(layout + .validate_point(&[field(2), field(3), field(5)]) + .is_ok()); + assert!(layout + .validate_point(&[field(2), field(3), field(5), field(7)]) + .is_err()); + } + + #[test] + fn backend_results_need_one_coefficient_for_each_claim() { + let layout = JointOpeningLayout::new(4).unwrap(); + let prover = ProverClaim { + evaluation_point: vec![field(2), field(3), field(5)], + rlc_coefficients: vec![field(1), field(7)], + }; + let verifier = FinalClaim { + evaluation_point: prover.evaluation_point.clone(), + initial_claim_scale: field(1), + linear_forms_contribution: field(11), + rlc_coefficients: prover.rlc_coefficients.clone(), + }; + assert!(layout.validate_prover_claim(&prover, 2).is_ok()); + assert!(layout.validate_final_claim(&verifier, 2).is_ok()); + assert!(layout.validate_prover_claim(&prover, 3).is_err()); + assert!(layout.validate_final_claim(&verifier, 3).is_err()); + } + + #[test] + fn separate_is_the_default_and_joint_requires_an_explicit_choice() { + assert_eq!(WitnessOpeningMode::default(), WitnessOpeningMode::Separate); + assert_eq!("separate".parse(), Ok(WitnessOpeningMode::Separate)); + assert_eq!("joint".parse(), Ok(WitnessOpeningMode::Joint)); + assert_eq!("JOINT".parse(), Ok(WitnessOpeningMode::Joint)); + assert!("jointly".parse::().is_err()); + } + + #[test] + fn both_opening_modes_round_trip_through_json_and_postcard() { + for mode in [WitnessOpeningMode::Separate, WitnessOpeningMode::Joint] { + let json = serde_json::to_string(&mode).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + mode + ); + let postcard = postcard::to_allocvec(&mode).unwrap(); + assert_eq!( + postcard::from_bytes::(&postcard).unwrap(), + mode + ); + } + } +} diff --git a/provekit/common/src/lib.rs b/provekit/common/src/lib.rs index 56eed920e..bdc494790 100644 --- a/provekit/common/src/lib.rs +++ b/provekit/common/src/lib.rs @@ -8,6 +8,7 @@ pub mod hash_config; mod interner; pub mod joint_claims; pub mod joint_covector; +pub mod joint_opening; mod logging; pub mod prefix_covector; pub mod public_inputs; @@ -27,6 +28,9 @@ pub use { field::{Base, Ext, FieldHash, ProofField}, hash_config::{HashConfig, POSEIDON2, SKYSCRAPER}, joint_covector::JointLinearForm, + joint_opening::{ + JointOpeningBackend, JointOpeningLayout, UnavailableJointOpening, WitnessOpeningMode, + }, logging::log_commit_input, prefix_covector::{OffsetCovector, PrefixCovector, SparseCovector}, public_inputs::{PublicInputs, PublicInputsHash}, diff --git a/provekit/common/src/whir_r1cs.rs b/provekit/common/src/whir_r1cs.rs index 985feac3c..ef5997e6a 100644 --- a/provekit/common/src/whir_r1cs.rs +++ b/provekit/common/src/whir_r1cs.rs @@ -14,7 +14,8 @@ use { crate::{ field::{Base, Ext, FieldHash, ProofField}, utils::{next_power_of_two, serde_hex}, - HashConfig, PublicInputs, R1CS, + HashConfig, JointOpeningBackend, JointOpeningLayout, PublicInputs, WitnessOpeningMode, + R1CS, }, serde::{Deserialize, Serialize}, whir::{ @@ -159,6 +160,9 @@ pub struct WhirR1CSScheme { pub has_public_inputs: bool, /// Base-field witness commitment, via zook. pub whir_witness: ZookConfig, + /// Opening protocol for two-stage witnesses. Joint ZeroKnowledge mode is + /// unavailable. Standard single-stage witnesses use the existing opening. + pub witness_opening: WitnessOpeningMode, /// Separate ext-field commitment to the Spartan blinding polynomial `g`; /// masking the ext-valued sumcheck rounds needs ext randomness, so `g` /// cannot ride on the base witness commitment. @@ -181,6 +185,25 @@ impl WhirR1CSScheme

{ 1usize << self.whir_blinding.initial_num_variables() } + /// Reject a missing or extra witness commitment before indexing + /// commitments. + pub fn validate_witness_commitment_count(&self, count: usize) -> anyhow::Result<()> { + let expected = if self.num_challenges > 0 { 2 } else { 1 }; + anyhow::ensure!( + count == expected, + "Expected {expected} witness commitments, received {count}" + ); + Ok(()) + } + + /// Reject unsupported joint ZK before committing a witness. + pub fn validate_witness_opening_mode(&self) -> anyhow::Result<()> { + if self.witness_opening == WitnessOpeningMode::Joint { + crate::joint_opening::ensure_joint_standard(&self.whir_witness)?; + } + Ok(()) + } + /// Create a domain separator for the provekit outer protocol. /// /// The domain separator serializes the entire scheme (including @@ -197,6 +220,48 @@ impl WhirR1CSScheme

{ } impl WhirR1CSScheme

{ + /// Select the opening protocol before constructing a transcript or + /// committing witnesses. + #[must_use] + pub fn with_witness_opening(mut self, mode: WitnessOpeningMode) -> Self { + self.witness_opening = mode; + self + } + + /// Validate the joint backend before starting a two-stage proof or + /// verification. + /// + /// The source configuration retains the original block size. + pub fn validate_joint_backend>( + &self, + backend: &B, + ) -> anyhow::Result<()> { + self.validate_witness_opening_mode()?; + if self.witness_opening == WitnessOpeningMode::Joint && self.num_challenges > 0 { + anyhow::ensure!( + self.num_challenges == self.challenge_offsets.len(), + "Challenge count does not match challenge offsets" + ); + let block_size = 1usize + .checked_shl(u32::try_from(self.m)?) + .ok_or_else(|| anyhow::anyhow!("Invalid witness block dimension"))?; + anyhow::ensure!( + self.w1_size <= block_size, + "First witness size exceeds its commitment block" + ); + anyhow::ensure!( + self.challenge_offsets + .iter() + .all(|offset| *offset < block_size), + "Challenge offset exceeds the second commitment block" + ); + let layout = JointOpeningLayout::new(block_size)?; + layout.validate_source_config(&self.whir_witness)?; + backend.validate(&self.whir_witness, layout)?; + } + Ok(()) + } + /// Build a scheme for a concrete R1CS instance, recording its hash so the /// transcript can later be bound to this instance (in /// [`Self::create_domain_separator`]). @@ -265,6 +330,7 @@ impl WhirR1CSScheme

{ hash_config.engine_id(), witness_mode, )?, + witness_opening: WitnessOpeningMode::default(), whir_blinding: Self::new_blinding_config_for_size(m_0, hash_config.engine_id()), w1_size, num_challenges, From 3ae770589b2ca3252d4a7c300e455052afcd620b Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 13 Oct 2026 14:25:36 +0200 Subject: [PATCH 03/12] feat(proof): route two witness claims through the joint backend --- Cargo.lock | 2 + provekit/prover/src/lib.rs | 5 +- provekit/prover/src/whir_r1cs.rs | 118 ++- provekit/verifier/src/lib.rs | 2 +- provekit/verifier/src/whir_r1cs.rs | 298 +++++--- tooling/provekit-fixtures/Cargo.toml | 2 + .../provekit-fixtures/tests/joint_opening.rs | 691 ++++++++++++++++++ 7 files changed, 994 insertions(+), 124 deletions(-) create mode 100644 tooling/provekit-fixtures/tests/joint_opening.rs diff --git a/Cargo.lock b/Cargo.lock index 7dea3cc57..4d2aeb8b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4652,11 +4652,13 @@ dependencies = [ "ark-ff", "ark-std", "inferno", + "postcard", "provekit-backend-bn254", "provekit-backend-goldilocks", "provekit-common", "provekit-prover", "provekit-verifier", + "serde_json", "tracing", "tracing-flame", "tracing-subscriber", diff --git a/provekit/prover/src/lib.rs b/provekit/prover/src/lib.rs index c6e4df7b7..0981cd7a7 100644 --- a/provekit/prover/src/lib.rs +++ b/provekit/prover/src/lib.rs @@ -1,6 +1,7 @@ mod whir_r1cs; pub use whir_r1cs::{ - prove_from_alphas, prove_from_alphas_ctx, run_zk_sumcheck_prover, ProveFromAlphasCtx, - SparkColQueryData, SparkQueryData, WhirR1CSCommitment, WhirR1CSProver, + prove_from_alphas, prove_from_alphas_ctx, prove_from_alphas_ctx_with_joint_backend, + prove_noir_with_joint_backend, run_zk_sumcheck_prover, ProveFromAlphasCtx, SparkColQueryData, + SparkQueryData, WhirR1CSCommitment, WhirR1CSProver, }; diff --git a/provekit/prover/src/whir_r1cs.rs b/provekit/prover/src/whir_r1cs.rs index bab35462f..e754df76a 100644 --- a/provekit/prover/src/whir_r1cs.rs +++ b/provekit/prover/src/whir_r1cs.rs @@ -7,6 +7,7 @@ use { Zero, }, provekit_common::{ + joint_claims::{build_joint_forms, compute_joint_matrix_evals}, prefix_covector::{ build_prefix_covectors, compute_alpha_evals, compute_challenge_eval, compute_public_eval, expand_powers, linear_form_refs, make_challenge_weight, @@ -20,8 +21,9 @@ use { multiply_transposed_by_eq_alpha, sumcheck_fold_map_reduce, transpose_r1cs_matrices, }, }, - Base, Ext, FieldHash, PrefixCovector, ProofField, PublicInputs, WhirR1CSProof, - WhirR1CSScheme, R1CS, + Base, Ext, FieldHash, JointOpeningBackend, JointOpeningLayout, PrefixCovector, ProofField, + PublicInputs, UnavailableJointOpening, WhirR1CSProof, WhirR1CSScheme, WitnessOpeningMode, + R1CS, }, whir::{ algebra::{ @@ -64,9 +66,9 @@ pub struct SparkColQueryData { } /// SPARK query material produced as a side output of proving: the Spartan -/// sumcheck point (shared row axis) plus one column query per witness -/// commitment. The concrete backend converts this into its serialized SPARK -/// query batch type. +/// sumcheck point (shared row axis) plus the final matrix queries. +/// Joint openings produce one query over the combined column domain. The +/// concrete backend converts this into its serialized SPARK query batch type. pub struct SparkQueryData { pub row: Vec>, pub queries: Vec>, @@ -116,6 +118,7 @@ where witness: Vec>, is_w1: bool, ) -> Result> { + self.validate_witness_opening_mode()?; let witness_size = if is_w1 { self.w1_size } else { @@ -187,7 +190,7 @@ where full_witness: Vec>, public_inputs: &PublicInputs>, ) -> Result { - let (proof, _) = prove_noir_inner( + let (proof, _) = prove_noir_with_joint_backend( self, merlin, r1cs, @@ -195,6 +198,7 @@ where full_witness, public_inputs, false, + &UnavailableJointOpening, )?; Ok(proof) } @@ -208,7 +212,7 @@ where full_witness: Vec>, public_inputs: &PublicInputs>, ) -> Result<(WhirR1CSProof, SparkQueryData

)> { - let (proof, queries) = prove_noir_inner( + let (proof, queries) = prove_noir_with_joint_backend( self, merlin, r1cs, @@ -216,6 +220,7 @@ where full_witness, public_inputs, true, + &UnavailableJointOpening, )?; let queries = queries .ok_or_else(|| anyhow::anyhow!("SPARK query data must be produced when requested"))?; @@ -223,8 +228,12 @@ where } } +/// Prove R1CS satisfaction with an external backend for joint witness openings. +/// +/// Separate and single-stage openings use the existing ZO0K implementation. #[instrument(skip_all)] -fn prove_noir_inner( +#[allow(clippy::too_many_arguments)] +pub fn prove_noir_with_joint_backend>( scheme: &WhirR1CSScheme

, mut merlin: ProverState, r1cs: R1CS>, @@ -232,11 +241,13 @@ fn prove_noir_inner( full_witness: Vec>, public_inputs: &PublicInputs>, produce_spark_query: bool, + backend: &B, ) -> Result<(WhirR1CSProof, Option>)> where Standard: Distribution> + Distribution>, { - ensure!(!commitments.is_empty(), "Need at least one commitment"); + scheme.validate_witness_commitment_count(commitments.len())?; + scheme.validate_joint_backend(backend)?; let (a, b, c) = calculate_witness_bounds(&r1cs, &full_witness); drop(full_witness); @@ -266,7 +277,7 @@ where let alphas = multiply_transposed_by_eq_alpha(&embedding, &at, &bt, &ct, &alpha, &r1cs); let blinding_weights = expand_powers::<4, _>(&alpha); - prove_from_alphas_ctx( + prove_from_alphas_ctx_with_joint_backend( scheme, merlin, ProveFromAlphasCtx { @@ -277,6 +288,7 @@ where spark_row: produce_spark_query.then_some(alpha), }, public_inputs, + backend, ) } @@ -321,14 +333,40 @@ where #[instrument(skip_all)] pub fn prove_from_alphas_ctx( + scheme: &WhirR1CSScheme

, + merlin: ProverState, + ctx: ProveFromAlphasCtx

, + public_inputs: &PublicInputs>, +) -> Result<(WhirR1CSProof, Option>)> +where + Standard: Distribution> + Distribution>, +{ + prove_from_alphas_ctx_with_joint_backend( + scheme, + merlin, + ctx, + public_inputs, + &UnavailableJointOpening, + ) +} + +/// Open post-sumcheck claims with an external joint backend. +/// +/// Noir and Mavros share this boundary. The scheme selects the opening +/// protocol. +#[instrument(skip_all)] +pub fn prove_from_alphas_ctx_with_joint_backend>( scheme: &WhirR1CSScheme

, mut merlin: ProverState, ctx: ProveFromAlphasCtx

, public_inputs: &PublicInputs>, + backend: &B, ) -> Result<(WhirR1CSProof, Option>)> where Standard: Distribution> + Distribution>, { + scheme.validate_witness_commitment_count(ctx.commitments.len())?; + scheme.validate_joint_backend(backend)?; let ProveFromAlphasCtx { alphas, blinding_eval, @@ -416,6 +454,66 @@ where }], } }) + } else if scheme.witness_opening == WitnessOpeningMode::Joint { + let layout = JointOpeningLayout::new(scheme.domain_size())?; + let [c1, c2]: [WhirR1CSCommitment

; 2] = commitments + .try_into() + .map_err(|_| anyhow::anyhow!("Joint opening requires two commitments"))?; + let matrix_evals = compute_joint_matrix_evals(&embedding, &alphas, scheme.w1_size, [ + &c1.polynomial, + &c2.polynomial, + ])?; + let forms = build_joint_forms( + scheme.m, + scheme.w1_size, + alphas, + x, + public_inputs_len, + &scheme.challenge_offsets, + )?; + let mut evaluations = matrix_evals.to_vec(); + if public_inputs_len > 0 { + evaluations.push(compute_public_eval( + &embedding, + x, + public_inputs_len, + &c1.polynomial, + )); + } + if !scheme.challenge_offsets.is_empty() { + evaluations.push(compute_challenge_eval( + &embedding, + x, + &scheme.challenge_offsets, + &c2.polynomial, + )); + } + let form_refs = linear_form_refs(&forms); + layout.validate_claims(&form_refs, &evaluations)?; + for evaluation in &evaluations { + merlin.prover_message(evaluation); + } + let final_claim = backend.prove( + &mut merlin, + &scheme.whir_witness, + layout, + [c1.witness, c2.witness], + &form_refs, + &evaluations, + )?; + layout.validate_prover_claim(&final_claim, forms.len())?; + spark_row.map(|row| { + let col = final_claim.evaluation_point; + SparkQueryData { + row, + queries: vec![SparkColQueryData { + claimed_a: forms[0].mle_evaluate(&col), + claimed_b: forms[1].mle_evaluate(&col), + claimed_c: forms[2].mle_evaluate(&col), + col, + }], + } + }) } else { // Dual commitment path let mut commitments = commitments.into_iter(); diff --git a/provekit/verifier/src/lib.rs b/provekit/verifier/src/lib.rs index 24f977cf2..8269f3340 100644 --- a/provekit/verifier/src/lib.rs +++ b/provekit/verifier/src/lib.rs @@ -1,3 +1,3 @@ mod whir_r1cs; -pub use whir_r1cs::WhirR1CSVerifier; +pub use whir_r1cs::{verify_with_joint_backend, WhirR1CSVerifier}; diff --git a/provekit/verifier/src/whir_r1cs.rs b/provekit/verifier/src/whir_r1cs.rs index 0ff7b8e6a..eafaf3ac6 100644 --- a/provekit/verifier/src/whir_r1cs.rs +++ b/provekit/verifier/src/whir_r1cs.rs @@ -6,6 +6,7 @@ use { One, }, provekit_common::{ + joint_claims::build_joint_forms, prefix_covector::{ build_prefix_covectors, expand_powers, linear_form_refs, make_challenge_weight, make_public_weight, OffsetCovector, @@ -13,7 +14,8 @@ use { utils::sumcheck::{ calculate_eq, eval_cubic_poly, multiply_transposed_by_eq_alpha, transpose_r1cs_matrices, }, - Base, Ext, FieldHash, ProofField, PublicInputs, WhirR1CSProof, WhirR1CSScheme, R1CS, + Base, Ext, FieldHash, JointOpeningBackend, JointOpeningLayout, ProofField, PublicInputs, + UnavailableJointOpening, WhirR1CSProof, WhirR1CSScheme, WitnessOpeningMode, R1CS, }, tracing::instrument, whir::{ @@ -50,84 +52,157 @@ where public_inputs: &PublicInputs>, r1cs: &R1CS>, ) -> Result<()> { - let actual_r1cs_hash = r1cs.hash(); - anyhow::ensure!( - self.r1cs_hash == actual_r1cs_hash, - "R1CS hash mismatch: scheme expects {:?}, got {:?}", - self.r1cs_hash, - actual_r1cs_hash - ); + verify_with_joint_backend(self, proof, public_inputs, r1cs, &UnavailableJointOpening) + } +} - let public_inputs_hash = P::hash_public_inputs(self.hash_config, &public_inputs.0); - let instance = P::ext_to_bytes_le(&public_inputs_hash); - let ds = self.create_domain_separator().instance(&instance); - let whir_proof = Proof { - narg_string: proof.narg_string.clone(), - hints: proof.hints.clone(), - #[cfg(debug_assertions)] - pattern: proof.pattern.clone(), - }; - let mut arthur = - VerifierState::new(&ds, &whir_proof, P::transcript_sponge(self.hash_config)); - - let commitment_1 = self - .whir_witness - .receive_commitment(&mut arthur) - .map_err(|_| anyhow::anyhow!("Failed to parse commitment 1"))?; +/// Verify R1CS satisfaction with an external backend for joint witness +/// openings. +/// +/// The scheme selects the protocol. The verifier also checks the backend's +/// final linear claim. +#[instrument(skip_all)] +pub fn verify_with_joint_backend>( + scheme: &WhirR1CSScheme

, + proof: &WhirR1CSProof, + public_inputs: &PublicInputs>, + r1cs: &R1CS>, + backend: &B, +) -> Result<()> +where + Standard: Distribution>, +{ + scheme.validate_joint_backend(backend)?; + let actual_r1cs_hash = r1cs.hash(); + anyhow::ensure!( + scheme.r1cs_hash == actual_r1cs_hash, + "R1CS hash mismatch: scheme expects {:?}, got {:?}", + scheme.r1cs_hash, + actual_r1cs_hash + ); - // Mirror the prover: the ext blinding commitment is absorbed immediately - // after the (w1) witness commitment, before any challenge sampling. - let blinding_commitment = self - .whir_blinding + let public_inputs_hash = P::hash_public_inputs(scheme.hash_config, &public_inputs.0); + let instance = P::ext_to_bytes_le(&public_inputs_hash); + let ds = scheme.create_domain_separator().instance(&instance); + let whir_proof = Proof { + narg_string: proof.narg_string.clone(), + hints: proof.hints.clone(), + #[cfg(debug_assertions)] + pattern: proof.pattern.clone(), + }; + let mut arthur = VerifierState::new(&ds, &whir_proof, P::transcript_sponge(scheme.hash_config)); + + let commitment_1 = scheme + .whir_witness + .receive_commitment(&mut arthur) + .map_err(|_| anyhow::anyhow!("Failed to parse commitment 1"))?; + + // Mirror the prover: the ext blinding commitment is absorbed immediately + // after the (w1) witness commitment, before any challenge sampling. + let blinding_commitment = scheme + .whir_blinding + .receive_commitment(&mut arthur) + .map_err(|_| anyhow::anyhow!("Failed to parse blinding commitment"))?; + + let (commitment_2, logup_challenges) = if scheme.num_challenges > 0 { + let challenges: Vec> = arthur.verifier_message_vec(scheme.num_challenges); + let commitment = scheme + .whir_witness .receive_commitment(&mut arthur) - .map_err(|_| anyhow::anyhow!("Failed to parse blinding commitment"))?; - - let (commitment_2, logup_challenges) = if self.num_challenges > 0 { - let challenges: Vec> = arthur.verifier_message_vec(self.num_challenges); - let commitment = self - .whir_witness - .receive_commitment(&mut arthur) - .map_err(|_| anyhow::anyhow!("Failed to parse commitment 2"))?; - (Some(commitment), Some(challenges)) - } else { - (None, None) - }; - let (transposed, sumcheck_result) = rayon::join( - || transpose_r1cs_matrices(r1cs), - || run_sumcheck_verifier::, _>(&mut arthur, self.m_0), - ); - let data_from_sumcheck_verifier = sumcheck_result.context("while verifying sumcheck")?; - let (at, bt, ct) = transposed; - - let public_inputs_hash_buf: Ext

= arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read public inputs hash"))?; - ensure!( - public_inputs_hash_buf == public_inputs_hash, - "Public inputs hash mismatch: expected {:?}, got {:?}", - public_inputs_hash, - public_inputs_hash_buf - ); - let x: Ext

= arthur.verifier_message(); - - let embedding = ::default(); - let alphas = multiply_transposed_by_eq_alpha( - &embedding, - &at, - &bt, - &ct, - &data_from_sumcheck_verifier.alpha, - r1cs, - ); + .map_err(|_| anyhow::anyhow!("Failed to parse commitment 2"))?; + (Some(commitment), Some(challenges)) + } else { + (None, None) + }; + let (transposed, sumcheck_result) = rayon::join( + || transpose_r1cs_matrices(r1cs), + || run_sumcheck_verifier::, _>(&mut arthur, scheme.m_0), + ); + let data_from_sumcheck_verifier = sumcheck_result.context("while verifying sumcheck")?; + let (at, bt, ct) = transposed; - let blinding_eval = data_from_sumcheck_verifier.blinding_eval; - let blinding_weights = expand_powers::<4, _>(&data_from_sumcheck_verifier.alpha); + let public_inputs_hash_buf: Ext

= arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read public inputs hash"))?; + ensure!( + public_inputs_hash_buf == public_inputs_hash, + "Public inputs hash mismatch: expected {:?}, got {:?}", + public_inputs_hash, + public_inputs_hash_buf + ); + let x: Ext

= arthur.verifier_message(); + + let embedding = ::default(); + let alphas = multiply_transposed_by_eq_alpha( + &embedding, + &at, + &bt, + &ct, + &data_from_sumcheck_verifier.alpha, + r1cs, + ); - let (az_at_alpha, bz_at_alpha, cz_at_alpha) = if let Some(commitment_2) = commitment_2 { + let blinding_eval = data_from_sumcheck_verifier.blinding_eval; + let blinding_weights = expand_powers::<4, _>(&data_from_sumcheck_verifier.alpha); + + let (az_at_alpha, bz_at_alpha, cz_at_alpha) = match (scheme.witness_opening, commitment_2) { + (WitnessOpeningMode::Joint, Some(commitment_2)) => { + let layout = JointOpeningLayout::new(scheme.domain_size())?; + let forms = build_joint_forms( + scheme.m, + scheme.w1_size, + alphas, + x, + public_inputs.len(), + &scheme.challenge_offsets, + )?; + let matrix_evals: [Ext

; 3] = [ + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read joint A evaluation"))?, + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read joint B evaluation"))?, + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read joint C evaluation"))?, + ]; + let mut evaluations = matrix_evals.to_vec(); + if !public_inputs.is_empty() { + let public_eval = arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read joint public evaluation"))?; + verify_public_input_binding(&embedding, public_eval, x, public_inputs)?; + evaluations.push(public_eval); + } + if let Some(ref challenges) = logup_challenges { + let challenge_eval = arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read joint challenge evaluation"))?; + verify_challenge_binding(challenge_eval, x, challenges)?; + evaluations.push(challenge_eval); + } + let form_refs = linear_form_refs(&forms); + layout.validate_claims(&form_refs, &evaluations)?; + let final_claim = backend.verify( + &mut arthur, + &scheme.whir_witness, + layout, + [commitment_1, commitment_2], + &form_refs, + &evaluations, + )?; + layout.validate_final_claim(&final_claim, forms.len())?; + final_claim + .verify(&form_refs) + .map_err(|_| anyhow::anyhow!("Joint WHIR final-claim check failed"))?; + (matrix_evals[0], matrix_evals[1], matrix_evals[2]) + } + (_, Some(commitment_2)) => { let (alphas_1, alphas_2): (Vec<_>, Vec<_>) = alphas .into_iter() .map(|mut v| { - let v2 = v.split_off(self.w1_size); + let v2 = v.split_off(scheme.w1_size); (v, v2) }) .unzip(); @@ -161,15 +236,15 @@ where .map_err(|_| anyhow::anyhow!("Failed to read evals_2[2]"))?, ]; - let mut weights_1 = build_prefix_covectors(self.m, alphas_1); - let weights_2 = build_prefix_covectors(self.m, alphas_2); + let mut weights_1 = build_prefix_covectors(scheme.m, alphas_1); + let weights_2 = build_prefix_covectors(scheme.m, alphas_2); let evaluations_1 = if !public_inputs.is_empty() { let public_1: Ext

= arthur .prover_message() .map_err(|_| anyhow::anyhow!("Failed to read public_1"))?; verify_public_input_binding(&embedding, public_1, x, public_inputs)?; - weights_1.insert(0, make_public_weight(x, public_inputs.len(), self.m)); + weights_1.insert(0, make_public_weight(x, public_inputs.len(), scheme.m)); vec![public_1, evals_1[0], evals_1[1], evals_1[2]] } else { evals_1.to_vec() @@ -183,7 +258,7 @@ where .prover_message() .map_err(|_| anyhow::anyhow!("Failed to read challenge_eval"))?; verify_challenge_binding(challenge_eval, x, challenges)?; - let cw = make_challenge_weight(x, &self.challenge_offsets, self.m); + let cw = make_challenge_weight(x, &scheme.challenge_offsets, scheme.m); evaluations_2.push(challenge_eval); Some(cw) } else { @@ -192,7 +267,7 @@ where let weight_refs_1 = linear_form_refs(&weights_1); - let fc_1 = self + let fc_1 = scheme .whir_witness .verify(&mut arthur, commitment_1, &weight_refs_1, &evaluations_1) .map_err(|_| anyhow::anyhow!("WHIR verification failed for c1"))?; @@ -203,7 +278,7 @@ where if let Some(ref cw) = challenge_covector { weight_refs_2.push(cw as &dyn LinearForm>); } - let fc_2 = self + let fc_2 = scheme .whir_witness .verify(&mut arthur, commitment_2, &weight_refs_2, &evaluations_2) .map_err(|_| anyhow::anyhow!("WHIR verification failed for c2"))?; @@ -215,7 +290,8 @@ where evals_1[1] + evals_2[1], evals_1[2] + evals_2[2], ) - } else { + } + (_, None) => { let evals: [Ext

; 3] = [ arthur .prover_message() @@ -228,14 +304,14 @@ where .map_err(|_| anyhow::anyhow!("Failed to read evals[2]"))?, ]; - let mut weights = build_prefix_covectors(self.m, alphas); + let mut weights = build_prefix_covectors(scheme.m, alphas); let evaluations = if !public_inputs.is_empty() { let public_eval: Ext

= arthur .prover_message() .map_err(|_| anyhow::anyhow!("Failed to read public eval"))?; verify_public_input_binding(&embedding, public_eval, x, public_inputs)?; - weights.insert(0, make_public_weight(x, public_inputs.len(), self.m)); + weights.insert(0, make_public_weight(x, public_inputs.len(), scheme.m)); vec![public_eval, evals[0], evals[1], evals[2]] } else { evals.to_vec() @@ -243,7 +319,7 @@ where let weight_refs = linear_form_refs(&weights); - let fc = self + let fc = scheme .whir_witness .verify(&mut arthur, commitment_1, &weight_refs, &evaluations) .map_err(|_| anyhow::anyhow!("WHIR verification failed"))?; @@ -251,39 +327,39 @@ where .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed"))?; (evals[0], evals[1], evals[2]) - }; - - // Open the ext blinding commitment: verify `blinding_eval` against the - // sumcheck power covector over the committed blinding vector (g at - // offset 0). Mirrors the prover's single blinding open after all base - // opens. - { - let blind_domain = self.blinding_domain_size(); - let blinding_covector = OffsetCovector::new(blinding_weights, 0, blind_domain); - let fc_b = self - .whir_blinding - .verify(&mut arthur, &[&blinding_commitment], &[blinding_eval]) - .map_err(|_| anyhow::anyhow!("WHIR verification failed for blinding"))?; - fc_b.verify(std::iter::once( - &blinding_covector as &dyn LinearForm>, - )) - .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed for blinding"))?; } + }; + + // Open the ext blinding commitment: verify `blinding_eval` against the + // sumcheck power covector over the committed blinding vector (g at + // offset 0). Mirrors the prover's single blinding open after all base + // opens. + { + let blind_domain = scheme.blinding_domain_size(); + let blinding_covector = OffsetCovector::new(blinding_weights, 0, blind_domain); + let fc_b = scheme + .whir_blinding + .verify(&mut arthur, &[&blinding_commitment], &[blinding_eval]) + .map_err(|_| anyhow::anyhow!("WHIR verification failed for blinding"))?; + fc_b.verify(std::iter::once( + &blinding_covector as &dyn LinearForm>, + )) + .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed for blinding"))?; + } - ensure!( - data_from_sumcheck_verifier.f_at_alpha - == (az_at_alpha * bz_at_alpha - cz_at_alpha) - * calculate_eq( - &data_from_sumcheck_verifier.r, - &data_from_sumcheck_verifier.alpha - ), - "last sumcheck value does not match" - ); + ensure!( + data_from_sumcheck_verifier.f_at_alpha + == (az_at_alpha * bz_at_alpha - cz_at_alpha) + * calculate_eq( + &data_from_sumcheck_verifier.r, + &data_from_sumcheck_verifier.alpha + ), + "last sumcheck value does not match" + ); - arthur - .check_eof() - .map_err(|_| anyhow::anyhow!("Proof contains unparsed trailing bytes")) - } + arthur + .check_eof() + .map_err(|_| anyhow::anyhow!("Proof contains unparsed trailing bytes")) } #[instrument(skip_all)] diff --git a/tooling/provekit-fixtures/Cargo.toml b/tooling/provekit-fixtures/Cargo.toml index fd6add37d..1a18644b1 100644 --- a/tooling/provekit-fixtures/Cargo.toml +++ b/tooling/provekit-fixtures/Cargo.toml @@ -21,6 +21,8 @@ ark-std.workspace = true whir.workspace = true [dev-dependencies] +serde_json.workspace = true +postcard.workspace = true # Concrete proof fields to instantiate the generic harness. provekit-backend-bn254.workspace = true provekit-backend-goldilocks.workspace = true diff --git a/tooling/provekit-fixtures/tests/joint_opening.rs b/tooling/provekit-fixtures/tests/joint_opening.rs new file mode 100644 index 000000000..beeb7415a --- /dev/null +++ b/tooling/provekit-fixtures/tests/joint_opening.rs @@ -0,0 +1,691 @@ +//! Readable tests for ProveKit's joint opening integration. +//! +//! The recording backend tests data routing only. +//! It does not implement a cryptographic opening and never verifies a proof. + +use { + anyhow::{bail, Result}, + ark_ff::Zero, + provekit_backend_bn254::{Bn254Field, FieldElement, TranscriptSponge}, + provekit_common::{ + FieldHash, HashConfig, JointOpeningBackend, JointOpeningLayout, PublicInputs, + PublicInputsHash, UnavailableJointOpening, WhirR1CSProof, WhirR1CSScheme, + WitnessCommitmentMode, WitnessOpeningMode, R1CS, + }, + provekit_fixtures::builders::{challenge_with_public_input, multi_challenge_inverses_w2}, + provekit_prover::{ + prove_from_alphas_ctx_with_joint_backend, prove_noir_with_joint_backend, + ProveFromAlphasCtx, WhirR1CSCommitment, WhirR1CSProver, + }, + provekit_verifier::verify_with_joint_backend, + std::sync::{ + atomic::{AtomicUsize, Ordering}, + Mutex, + }, + whir::{ + algebra::{embedding::Identity, linear_form::LinearForm}, + protocols::zook::{ + Commitment, CommittedWitness, FinalClaim, ProtocolConfig as ZookConfig, ProverClaim, + }, + transcript::{ProverState, VerifierMessage, VerifierState}, + }, +}; + +type Scheme = WhirR1CSScheme; +type SourceConfig = ZookConfig>; + +fn field(value: u64) -> FieldElement { + FieldElement::from(value) +} + +/// The matrix equation is `w[1] * w[3] = w[2]`, or `3 * 4 = 12`. +fn multiplication_r1cs() -> R1CS { + let mut r1cs = R1CS::new(); + r1cs.num_public_inputs = 1; + r1cs.add_witnesses(5); + r1cs.add_constraint(&[(field(1), 1)], &[(field(1), 3)], &[(field(1), 2)]); + r1cs +} + +fn scheme(opening_mode: WitnessOpeningMode) -> Scheme { + scheme_with_commitment_mode(opening_mode, WitnessCommitmentMode::Standard) +} + +fn scheme_with_commitment_mode( + opening_mode: WitnessOpeningMode, + witness_mode: WitnessCommitmentMode, +) -> Scheme { + Scheme::new_for_r1cs( + &multiplication_r1cs(), + 3, + 1, + vec![0], + true, + HashConfig::Sha256, + witness_mode, + ) + .unwrap() + .with_witness_opening(opening_mode) +} + +fn transcript( + scheme: &Scheme, + public_inputs: &PublicInputs, +) -> ProverState { + let instance = public_inputs.hash_bytes::(scheme.hash_config); + let ds = scheme.create_domain_separator().instance(&instance); + ProverState::new(&ds, Bn254Field::transcript_sponge(scheme.hash_config)) +} + +fn empty_context() -> ProveFromAlphasCtx { + ProveFromAlphasCtx { + alphas: [vec![], vec![], vec![]], + blinding_eval: field(0), + blinding_weights: vec![], + commitments: vec![], + spark_row: None, + } +} + +/// Commit both real source witnesses in their original order. +/// The fixed second witness tests routing and does not claim challenge +/// consistency. +fn routing_context( + scheme: &Scheme, + merlin: &mut ProverState, +) -> ProveFromAlphasCtx { + let w1 = scheme + .commit(merlin, 5, 1, vec![field(1), field(3), field(12)], true) + .unwrap(); + let _stage_challenge: FieldElement = merlin.verifier_message(); + let w2 = scheme + .commit(merlin, 5, 1, vec![field(4), field(9)], false) + .unwrap(); + ProveFromAlphasCtx { + alphas: [ + vec![field(0), field(1), field(0), field(0), field(0)], + vec![field(0), field(0), field(0), field(1), field(0)], + vec![field(0), field(0), field(1), field(0), field(0)], + ], + // A zero form honestly opens the separate blinding commitment to zero. + blinding_eval: field(0), + blinding_weights: vec![field(0); 4 * scheme.m_0], + commitments: vec![w1, w2], + spark_row: Some(vec![field(7)]), + } +} + +#[derive(Clone, Copy, Default)] +enum RecordingResult { + #[default] + CompletePoint, + MissingSelector, + MissingCoefficient, + Error, +} + +#[derive(Clone, Copy, Default)] +enum VerifierResult { + #[default] + SentinelError, + MissingSelector, + MissingCoefficient, + WrongContribution, +} + +#[derive(Clone)] +struct RecordedOpening { + source_size: usize, + layout: JointOpeningLayout, + forms: Vec>, + values: Vec, + point: Vec, +} + +/// This backend returns test data, not a cryptographic opening. +#[derive(Default)] +struct RecordingBackend { + calls: AtomicUsize, + verifier_calls: AtomicUsize, + recorded: Mutex>, + recorded_verification: Mutex>, + result: RecordingResult, + verifier_result: VerifierResult, +} + +impl JointOpeningBackend for RecordingBackend { + fn validate(&self, source: &SourceConfig, layout: JointOpeningLayout) -> Result<()> { + layout.validate_source_config(source) + } + + fn prove( + &self, + _transcript: &mut ProverState, + source: &SourceConfig, + layout: JointOpeningLayout, + _witnesses: [CommittedWitness>; 2], + forms: &[&dyn LinearForm], + values: &[FieldElement], + ) -> Result> { + self.calls.fetch_add(1, Ordering::SeqCst); + layout.validate_claims(forms, values)?; + if matches!(self.result, RecordingResult::Error) { + bail!("Recording backend opening error"); + } + + let coefficients = forms + .iter() + .map(|form| { + let mut vector = vec![field(0); layout.size()]; + form.accumulate(&mut vector, field(1)); + vector + }) + .collect(); + let mut point = vec![field(0); layout.num_variables()]; + point[0] = field(2); + if matches!(self.result, RecordingResult::MissingSelector) { + point.remove(0); + } + *self.recorded.lock().unwrap() = Some(RecordedOpening { + source_size: source.tuning().vector_size, + layout, + forms: coefficients, + values: values.to_vec(), + point: point.clone(), + }); + + let mut rlc_coefficients = vec![field(1); forms.len()]; + if matches!(self.result, RecordingResult::MissingCoefficient) { + rlc_coefficients.pop(); + } + Ok(ProverClaim { + evaluation_point: point, + rlc_coefficients, + }) + } + + fn verify( + &self, + _transcript: &mut VerifierState<'_, TranscriptSponge>, + source: &SourceConfig, + layout: JointOpeningLayout, + _commitments: [Commitment; 2], + forms: &[&dyn LinearForm], + values: &[FieldElement], + ) -> Result> { + self.verifier_calls.fetch_add(1, Ordering::SeqCst); + layout.validate_claims(forms, values)?; + let coefficients = forms + .iter() + .map(|form| { + let mut vector = vec![field(0); layout.size()]; + form.accumulate(&mut vector, field(1)); + vector + }) + .collect(); + let mut point = vec![field(0); layout.num_variables()]; + point[0] = field(2); + *self.recorded_verification.lock().unwrap() = Some(RecordedOpening { + source_size: source.tuning().vector_size, + layout, + forms: coefficients, + values: values.to_vec(), + point: point.clone(), + }); + + if matches!(self.verifier_result, VerifierResult::SentinelError) { + bail!("The recording backend never verifies a proof"); + } + let form_sum: FieldElement = forms.iter().map(|form| form.mle_evaluate(&point)).sum(); + let mut rlc_coefficients = vec![field(1); forms.len()]; + match self.verifier_result { + VerifierResult::MissingSelector => { + point.remove(0); + } + VerifierResult::MissingCoefficient => { + rlc_coefficients.pop(); + } + VerifierResult::WrongContribution => {} + VerifierResult::SentinelError => unreachable!(), + } + // Every returned final claim deliberately fails a ProveKit check. + Ok(FinalClaim { + evaluation_point: point, + initial_claim_scale: field(1), + linear_forms_contribution: form_sum + field(1), + rlc_coefficients, + }) + } +} + +#[test] +fn opening_mode_survives_scheme_json_and_postcard_round_trips() { + for opening_mode in [WitnessOpeningMode::Separate, WitnessOpeningMode::Joint] { + let original = scheme(opening_mode); + let json = serde_json::to_string(&original).unwrap(); + let from_json: Scheme = serde_json::from_str(&json).unwrap(); + assert_eq!(from_json, original); + + let postcard = postcard::to_allocvec(&original).unwrap(); + let from_postcard: Scheme = postcard::from_bytes(&postcard).unwrap(); + assert_eq!(from_postcard, original); + } +} + +#[test] +fn old_json_without_an_opening_mode_is_rejected_explicitly() { + let mut json = serde_json::to_value(scheme(WitnessOpeningMode::Separate)).unwrap(); + json.as_object_mut().unwrap().remove("witness_opening"); + let error = serde_json::from_value::(json).unwrap_err(); + assert!(error.to_string().contains("witness_opening")); +} + +#[test] +fn joint_and_separate_modes_produce_different_transcript_challenges() { + let separate = scheme(WitnessOpeningMode::Separate); + let joint = separate + .clone() + .with_witness_opening(WitnessOpeningMode::Joint); + let public = PublicInputs::from_vec(vec![field(3)]); + + // Fiat-Shamir challenges depend on the transcript, not the prover's private + // RNG. + let separate_challenge: FieldElement = transcript(&separate, &public).verifier_message(); + let repeated_challenge: FieldElement = transcript(&separate, &public).verifier_message(); + let joint_challenge: FieldElement = transcript(&joint, &public).verifier_message(); + assert_eq!(separate_challenge, repeated_challenge); + assert_ne!(separate_challenge, joint_challenge); +} + +#[test] +fn unavailable_backend_rejects_joint_and_keeps_existing_openings_available() { + let unavailable = UnavailableJointOpening; + let separate = scheme(WitnessOpeningMode::Separate); + assert!(separate.validate_joint_backend(&unavailable).is_ok()); + + let joint = separate.with_witness_opening(WitnessOpeningMode::Joint); + let error = joint.validate_joint_backend(&unavailable).unwrap_err(); + assert!(error + .to_string() + .contains("Joint witness opening is unavailable")); + + let single = Scheme::new_for_r1cs( + &multiplication_r1cs(), + 5, + 0, + vec![], + true, + HashConfig::Sha256, + WitnessCommitmentMode::Standard, + ) + .unwrap() + .with_witness_opening(WitnessOpeningMode::Joint); + assert!(single.validate_joint_backend(&unavailable).is_ok()); +} + +#[test] +fn an_empty_commitment_list_returns_an_error_before_indexing() { + let backend = RecordingBackend::default(); + let public = PublicInputs::new(); + for mode in [WitnessOpeningMode::Separate, WitnessOpeningMode::Joint] { + let scheme = scheme(mode); + let result = prove_from_alphas_ctx_with_joint_backend( + &scheme, + transcript(&scheme, &public), + empty_context(), + &public, + &backend, + ); + assert!(result.is_err()); + } + assert_eq!(backend.calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn a_two_stage_scheme_rejects_one_or_three_commitments() { + let scheme = scheme(WitnessOpeningMode::Joint); + let public = PublicInputs::from_vec(vec![field(3)]); + let backend = RecordingBackend::default(); + + for count in [1, 3] { + let mut merlin = transcript(&scheme, &public); + let mut ctx = routing_context(&scheme, &mut merlin); + if count == 1 { + ctx.commitments.truncate(1); + } else { + // This duplicate tests count validation before any opening starts. + let second = &ctx.commitments[1]; + ctx.commitments.push(WhirR1CSCommitment { + witness: second.witness.clone(), + polynomial: second.polynomial.clone(), + blinding: None, + }); + } + + let error = + prove_from_alphas_ctx_with_joint_backend(&scheme, merlin, ctx, &public, &backend) + .err() + .expect("A two-stage scheme requires two commitments"); + assert!(format!("{error:#}").contains("commitment"), "{error:#}"); + } + assert_eq!(backend.calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn joint_pipeline_calls_one_backend_with_three_matrix_and_two_binding_claims() { + for witness_mode in [WitnessCommitmentMode::Standard] { + assert_joint_pipeline_claims(witness_mode); + } +} + +fn assert_joint_pipeline_claims(witness_mode: WitnessCommitmentMode) { + let scheme = scheme_with_commitment_mode(WitnessOpeningMode::Joint, witness_mode); + let public = PublicInputs::from_vec(vec![field(3)]); + let backend = RecordingBackend::default(); + let mut merlin = transcript(&scheme, &public); + let ctx = routing_context(&scheme, &mut merlin); + let (_routing_payload, spark) = + prove_from_alphas_ctx_with_joint_backend(&scheme, merlin, ctx, &public, &backend) + .expect("The recording backend checks routing only"); + + assert_eq!(backend.calls.load(Ordering::SeqCst), 1); + let recorded = backend.recorded.lock().unwrap().as_ref().unwrap().clone(); + let block_size = scheme.domain_size(); + assert_eq!(recorded.source_size, block_size); + assert_eq!(recorded.layout.size(), 2 * block_size); + assert_eq!(recorded.forms.len(), 5); + assert!(recorded + .forms + .iter() + .all(|form| form.len() == 2 * block_size)); + + // A selects 3 from w1. B selects 4 from w2. C selects 12 from w1. + assert_eq!(&recorded.values[..3], &[field(3), field(4), field(12)]); + assert_eq!(recorded.values[0] * recorded.values[1], recorded.values[2]); + assert_eq!(recorded.forms[0][1], field(1)); + assert_eq!(recorded.forms[1][block_size], field(1)); + assert_eq!(recorded.forms[2][2], field(1)); + for (form, selected_index) in recorded.forms[..3].iter().zip([1, block_size, 2]) { + for (index, coefficient) in form.iter().enumerate() { + assert_eq!( + *coefficient, + if index == selected_index { + field(1) + } else { + field(0) + } + ); + } + } + + // Public binding gives 1 + 3*x on the first block. + let x = recorded.forms[3][1]; + assert_eq!(recorded.values[3], field(1) + field(3) * x); + assert!(recorded.forms[3][block_size..].iter().all(Zero::is_zero)); + // Challenge binding gives 4 at offset 0 of the second block. + assert_eq!(recorded.values[4], field(4)); + assert!(recorded.forms[4][..block_size].iter().all(Zero::is_zero)); + assert_eq!(recorded.forms[4][block_size], field(1)); + + let spark = spark.expect("SPARK query was requested"); + assert_eq!(spark.row, vec![field(7)]); + assert_eq!(spark.queries.len(), 1); + let query = &spark.queries[0]; + assert_eq!(query.col, recorded.point); + assert_eq!(query.col.len(), scheme.m + 1); + assert_eq!(query.col[0], field(2)); + // At beta=0, A and C are zero. The selector 2 multiplies B's second block. + assert_eq!(query.claimed_a, field(0)); + assert_eq!(query.claimed_b, field(2)); + assert_eq!(query.claimed_c, field(0)); +} + +#[test] +fn joint_pipeline_rejects_backend_points_without_the_selector() { + assert_bad_backend_result(RecordingResult::MissingSelector, "point has"); +} + +#[test] +fn joint_pipeline_rejects_backend_results_with_a_missing_claim_coefficient() { + assert_bad_backend_result(RecordingResult::MissingCoefficient, "coefficient count"); +} + +#[test] +fn joint_pipeline_preserves_backend_opening_errors() { + assert_bad_backend_result(RecordingResult::Error, "Recording backend opening error"); +} + +fn assert_bad_backend_result(result: RecordingResult, expected_error: &str) { + let scheme = scheme(WitnessOpeningMode::Joint); + let public = PublicInputs::from_vec(vec![field(3)]); + let backend = RecordingBackend { + result, + ..RecordingBackend::default() + }; + let mut merlin = transcript(&scheme, &public); + let ctx = routing_context(&scheme, &mut merlin); + let error = prove_from_alphas_ctx_with_joint_backend(&scheme, merlin, ctx, &public, &backend) + .err() + .expect("A malformed backend result must fail"); + assert!(format!("{error:#}").contains(expected_error), "{error:#}"); + assert_eq!(backend.calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn verifier_rejects_an_unavailable_backend_before_reading_a_proof() { + let scheme = scheme(WitnessOpeningMode::Joint); + let proof = WhirR1CSProof { + narg_string: vec![], + hints: vec![], + #[cfg(debug_assertions)] + pattern: vec![], + }; + let error = verify_with_joint_backend( + &scheme, + &proof, + &PublicInputs::from_vec(vec![field(3)]), + &multiplication_r1cs(), + &UnavailableJointOpening, + ) + .unwrap_err(); + assert!(format!("{error:#}").contains("Joint witness opening is unavailable")); +} + +/// Produce a complete outer transcript with correct constraint witnesses. +/// The recording backend supplies no cryptographic joint opening payload. +fn full_outer_routing_fixture( + backend: &RecordingBackend, + public_witness: u64, + change_challenge: bool, +) -> ( + Scheme, + WhirR1CSProof, + PublicInputs, + R1CS, +) { + full_outer_routing_fixture_with_mode( + backend, + public_witness, + change_challenge, + WitnessCommitmentMode::Standard, + ) +} + +fn full_outer_routing_fixture_with_mode( + backend: &RecordingBackend, + public_witness: u64, + change_challenge: bool, + witness_mode: WitnessCommitmentMode, +) -> ( + Scheme, + WhirR1CSProof, + PublicInputs, + R1CS, +) { + let (r1cs, mut w1, offsets) = challenge_with_public_input::(3); + w1[1] = field(public_witness); + let public = PublicInputs::from_vec(vec![field(3)]); + let scheme = Scheme::new_for_r1cs( + &r1cs, + w1.len(), + offsets.len(), + offsets, + true, + HashConfig::Sha256, + witness_mode, + ) + .unwrap() + .with_witness_opening(WitnessOpeningMode::Joint); + scheme.validate_joint_backend(backend).unwrap(); + let mut merlin = transcript(&scheme, &public); + let c1 = scheme + .commit( + &mut merlin, + r1cs.num_witnesses(), + r1cs.num_constraints(), + w1.clone(), + true, + ) + .unwrap(); + let mut challenges: Vec = merlin.verifier_message_vec(scheme.num_challenges); + if change_challenge { + challenges[0] += field(1); + } + let w2 = multi_challenge_inverses_w2(&challenges, &w1).unwrap(); + let c2 = scheme + .commit( + &mut merlin, + r1cs.num_witnesses(), + r1cs.num_constraints(), + w2.clone(), + false, + ) + .unwrap(); + let full_witness = w1.into_iter().chain(w2).collect(); + let (routing_payload, spark) = prove_noir_with_joint_backend( + &scheme, + merlin, + r1cs.clone(), + vec![c1, c2], + full_witness, + &public, + false, + backend, + ) + .expect("The recording backend tests the complete outer transcript only"); + assert!(spark.is_none()); + (scheme, routing_payload, public, r1cs) +} + +#[test] +fn the_outer_transcript_reaches_one_verifier_call_with_the_same_forms_and_values() { + for witness_mode in [WitnessCommitmentMode::Standard] { + assert_full_outer_transcript_agreement(witness_mode); + } +} + +fn assert_full_outer_transcript_agreement(witness_mode: WitnessCommitmentMode) { + let backend = RecordingBackend::default(); + let (scheme, routing_payload, public, r1cs) = + full_outer_routing_fixture_with_mode(&backend, 3, false, witness_mode); + let error = verify_with_joint_backend(&scheme, &routing_payload, &public, &r1cs, &backend) + .expect_err("The recording backend never verifies a proof"); + assert!(format!("{error:#}").contains("The recording backend never verifies a proof")); + assert_eq!(backend.calls.load(Ordering::SeqCst), 1); + assert_eq!(backend.verifier_calls.load(Ordering::SeqCst), 1); + + let prover = backend.recorded.lock().unwrap().as_ref().unwrap().clone(); + let verifier = backend + .recorded_verification + .lock() + .unwrap() + .as_ref() + .unwrap() + .clone(); + assert_eq!(prover.layout, verifier.layout); + assert_eq!(prover.source_size, verifier.source_size); + assert_eq!(prover.forms, verifier.forms); + assert_eq!(prover.values, verifier.values); + assert_eq!(verifier.values.len(), 5); +} + +#[test] +fn verifier_rejects_a_missing_selector_in_the_backend_final_point() { + assert_bad_verifier_result(VerifierResult::MissingSelector, "point has"); +} + +#[test] +fn verifier_rejects_a_missing_backend_final_claim_coefficient() { + assert_bad_verifier_result(VerifierResult::MissingCoefficient, "coefficient count"); +} + +#[test] +fn verifier_checks_the_combined_forms_at_the_backend_final_point() { + assert_bad_verifier_result( + VerifierResult::WrongContribution, + "Joint WHIR final-claim check failed", + ); +} + +fn assert_bad_verifier_result(verifier_result: VerifierResult, expected_error: &str) { + let backend = RecordingBackend { + verifier_result, + ..RecordingBackend::default() + }; + let (scheme, routing_payload, public, r1cs) = full_outer_routing_fixture(&backend, 3, false); + let error = verify_with_joint_backend(&scheme, &routing_payload, &public, &r1cs, &backend) + .expect_err("Every test final claim fails a ProveKit check"); + assert!(format!("{error:#}").contains(expected_error), "{error:#}"); + assert_eq!(backend.verifier_calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn verifier_rejects_a_wrong_public_witness_before_the_joint_backend() { + let backend = RecordingBackend::default(); + let (scheme, routing_payload, public, r1cs) = full_outer_routing_fixture(&backend, 4, false); + let error = verify_with_joint_backend(&scheme, &routing_payload, &public, &r1cs, &backend) + .expect_err("The witness contains 4, but the public input is 3"); + assert!( + format!("{error:#}").to_lowercase().contains("public"), + "{error:#}" + ); + assert_eq!(backend.verifier_calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn verifier_rejects_a_changed_challenge_before_the_joint_backend() { + let backend = RecordingBackend::default(); + let (scheme, routing_payload, public, r1cs) = full_outer_routing_fixture(&backend, 3, true); + let error = verify_with_joint_backend(&scheme, &routing_payload, &public, &r1cs, &backend) + .expect_err("The witness challenge differs from the transcript challenge"); + assert!( + format!("{error:#}").to_lowercase().contains("challenge"), + "{error:#}" + ); + assert_eq!(backend.verifier_calls.load(Ordering::SeqCst), 0); +} + +#[test] +fn joint_zero_knowledge_rejects_before_commitment_changes_the_transcript() { + let scheme = scheme_with_commitment_mode( + WitnessOpeningMode::Joint, + WitnessCommitmentMode::ZeroKnowledge, + ); + let public = PublicInputs::from_vec(vec![field(3)]); + let mut attempted = transcript(&scheme, &public); + let mut control = transcript(&scheme, &public); + let error = scheme + .commit( + &mut attempted, + 5, + 1, + vec![field(1), field(3), field(12)], + true, + ) + .err() + .expect("Joint ZK requires a privacy repair"); + assert!(error.to_string().contains("privacy leak")); + let after: FieldElement = attempted.verifier_message(); + let untouched: FieldElement = control.verifier_message(); + assert_eq!(after, untouched); +} From 9ccbdcfe3a3d0e7cffe3516c84d41a7f4fd56a31 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 13 Oct 2026 14:28:34 +0200 Subject: [PATCH 04/12] test(proof): exercise the real two-witness WHIR backend --- provekit/common/JOINT_OPENING.md | 98 +++++++ tooling/provekit-fixtures/tests/whir_pair.rs | 263 +++++++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 provekit/common/JOINT_OPENING.md create mode 100644 tooling/provekit-fixtures/tests/whir_pair.rs diff --git a/provekit/common/JOINT_OPENING.md b/provekit/common/JOINT_OPENING.md new file mode 100644 index 000000000..5aa5daead --- /dev/null +++ b/provekit/common/JOINT_OPENING.md @@ -0,0 +1,98 @@ +# Two-witness opening status + +The Standard two-witness path is implemented through `JointOpeningBackend`. +The matching WHIR branch implements the protocol. +ZeroKnowledge remains incomplete and fails before commitment or opening transcript changes. +The existing single-witness WHIR ZeroKnowledge path still exposes private witness information. + +## Branches and stages + +The existing `zkfr/combined-opening-witness` branch remains unchanged. +This branch, `zkfr/two-witness-opening`, starts from ProveKit `main` at `35324c0d`. +Its first three commits contain these stages: + +1. Selector forms and combined claims: `9f69ad9b`. +2. Backend contract, validation, and format changes: `53ee17f7`. +3. Prover and verifier integration with readable routing tests: `58003f57`. + +WHIR has a separate `zkfr/two-witness-opening` branch based on its `main` at `afec1fac`. +Its stages cover parameter derivation, paired execution, the zero-witness repair, and privacy documentation. +The WHIR checkout contains `docs/two-witness-opening.md` with exact protocol details. + +## ProveKit interface + +Select `WitnessOpeningMode::Joint` before creating the transcript. +Call `scheme.validate_joint_backend(&backend)` before committing witnesses. +Use these injected entry points: + +- `prove_noir_with_joint_backend` +- `prove_from_alphas_ctx_with_joint_backend` +- `verify_with_joint_backend` + +The default entry points use `UnavailableJointOpening` for Joint mode. +Separate mode remains the constructor default. +A Standard single-stage witness retains the existing opening path. +The serialized scheme requires an explicit opening mode. +All affected binary formats now use major version `3`. + +`JointOpeningBackend` receives two ordered existing commitment handles. +The source configuration describes each padded `N`-element witness. +Forms describe the virtual `2N`-element witness `[w1 | w2]`. +Claim order is A, B, C, optional public inputs, then optional stage challenges. +ProveKit binds these values before the backend samples shared claim coefficients. +WHIR also binds the values at its public API boundary. +This duplicates at most five field elements and prevents caller-dependent claim binding. + +The backend returns one selector-first point with `log2(N) + 1` coordinates. +It also returns one coefficient per supplied form. +ProveKit validates these dimensions and calls `FinalClaim::verify`. +SPARK receives one combined matrix query with the complete point. +The separate Spartan blinding opening follows the paired witness opening. + +## Tests + +```sh +cargo test --locked -p provekit-common joint_ --lib +cargo test --locked -p provekit-fixtures --test joint_opening +``` + +The algebra tests include `20 + 146 = 166` and `3 * 4 = 12`. +The routing tests use a recording backend and do not establish cryptographic correctness. +They check commitment count, claim order, binding checks, complete points, and backend errors. +A transcript-control test checks that Joint ZeroKnowledge rejection occurs before commitment messages. + +`tooling/provekit-fixtures/tests/whir_pair.rs` contains acceptance tests for the real WHIR backend. +Its explicit `whir_pair_backend` configuration requires the WHIR compatibility branch. +The normal dependency pin lacks the new API, so default builds skip this fixture. + +From a clean checkout, substitute the compatibility checkout path in this command: + +```sh +RUSTFLAGS='--cfg whir_pair_backend' cargo \ + --config 'patch."https://github.com/worldfnd/whir.git".whir.path="/path/to/whir-compat"' \ + test -p provekit-fixtures --test whir_pair +``` + +The override changes `Cargo.lock`; keep that local change outside the implementation commits. +The fixture proves full BN254 and extension-leaf Goldilocks R1CS instances. +It also opens base-field witnesses with genuine extension coefficients through the same adapter. +It checks public-input binding, stage challenges, complete SPARK points, and malformed proofs. + +## Remaining work + +WHIR `main` uses Arkworks 0.6, while ProveKit currently uses the 0.5 WHIR revision `78e9460`. +The WHIR compatibility branch carries the same implementation for current ProveKit integration tests. +[ProveKit PR #507](https://github.com/worldfnd/provekit/pull/507) already contains the Arkworks 0.6 upgrade. +That dependency update must precede direct use of the WHIR main-based branch. +The production backend then needs the small adapter currently exercised by the fixture. + +Base-committed Goldilocks cannot currently store an extension challenge in one base-field witness slot. +Full challenge-bearing R1CS tests therefore use `GoldilocksEfField`. +The WHIR paired protocol itself supports `Basefield` with genuine extension coefficients. + +A valid inherited WHIR ZeroKnowledge proof discloses an unclaimed coordinate through mask reconciliation. +The WHIR audit recovers `123456789` from a proof whose public claim equals zero. +Removing only the reconciliation scalar does not repair the later mask disclosures. +An efficient repair needs aggregate mask obligations, a terminal opening, and new security accounting. +A full-vector alternative needs uniform target-field blinders and separate soundness accounting. +Neither privacy repair is implemented in this branch. diff --git a/tooling/provekit-fixtures/tests/whir_pair.rs b/tooling/provekit-fixtures/tests/whir_pair.rs new file mode 100644 index 000000000..32e6fdf95 --- /dev/null +++ b/tooling/provekit-fixtures/tests/whir_pair.rs @@ -0,0 +1,263 @@ +//! Acceptance tests for the real two-witness WHIR backend. +//! +//! The normal WHIR pin lacks this API. Enable `whir_pair_backend` only with +//! the compatible WHIR branch patched into the workspace. This adapter is an +//! integration fixture, not the default production backend. +#![allow(unexpected_cfgs)] +#![cfg(whir_pair_backend)] + +use { + anyhow::Result, + ark_ff::{AdditiveGroup, Field}, + ark_std::rand::distributions::{Distribution, Standard}, + provekit_backend_bn254::Bn254Field, + provekit_backend_goldilocks::{GoldilocksEfField, GoldilocksField}, + provekit_common::{ + Base, Ext, FieldHash, HashConfig, JointLinearForm, JointOpeningBackend, JointOpeningLayout, + PrefixCovector, PublicInputs, PublicInputsHash, WhirR1CSScheme, WitnessCommitmentMode, + WitnessOpeningMode, + }, + provekit_fixtures::builders::{challenge_with_public_input, multi_challenge_inverses_w2}, + provekit_prover::{prove_noir_with_joint_backend, WhirR1CSProver}, + provekit_verifier::verify_with_joint_backend, + whir::{ + algebra::{ + embedding::Embedding, + fields::{Field64, Field64_3}, + linear_form::LinearForm, + }, + buffer::Buffer, + protocols::{ + params::{ + joint::JointConfig, FoldingFactor, PowBudget, RateSchedule, SecuritySpec, + TuningSpec, + }, + zook::{Commitment, CommittedWitness, FinalClaim, ProtocolConfig, ProverClaim}, + }, + transcript::{ + codecs::Empty, DomainSeparator, Proof, ProverState, VerifierMessage, VerifierState, + }, + }, +}; + +const HASH: HashConfig = HashConfig::Sha256; + +struct WhirPairBackend; + +impl JointOpeningBackend

for WhirPairBackend +where + Standard: Distribution>, +{ + fn validate( + &self, + source: &ProtocolConfig, + layout: JointOpeningLayout, + ) -> Result<()> { + layout.validate_source_config(source)?; + JointConfig::derive(source)?.validate()?; + Ok(()) + } + + fn prove( + &self, + transcript: &mut ProverState, + source: &ProtocolConfig, + layout: JointOpeningLayout, + witnesses: [CommittedWitness; 2], + forms: &[&dyn LinearForm>], + values: &[Ext

], + ) -> Result>> { + layout.validate_source_config(source)?; + layout.validate_claims(forms, values)?; + Ok(JointConfig::derive(source)?.prove_pair(transcript, witnesses, forms, values)?) + } + + fn verify( + &self, + transcript: &mut VerifierState<'_, P::Sponge>, + source: &ProtocolConfig, + layout: JointOpeningLayout, + commitments: [Commitment; 2], + forms: &[&dyn LinearForm>], + values: &[Ext

], + ) -> Result>> { + layout.validate_source_config(source)?; + layout.validate_claims(forms, values)?; + Ok(JointConfig::derive(source)?.verify_pair(transcript, commitments, forms, values)?) + } +} + +fn check_full_r1cs

() +where + P: FieldHash, + P::Embedding: Embedding>, + Standard: Distribution>, +{ + P::register(); + // The witness is [1, 3 | c, 1/c]. The constraints are 3*1=3 and c*(1/c)=1. + let (r1cs, left, offsets) = challenge_with_public_input::>(3); + let public = PublicInputs::from_vec(vec![Base::

::from(3)]); + let scheme = WhirR1CSScheme::

::new_for_r1cs( + &r1cs, + left.len(), + 1, + offsets, + true, + HASH, + WitnessCommitmentMode::Standard, + ) + .unwrap() + .with_witness_opening(WitnessOpeningMode::Joint); + scheme.validate_joint_backend(&WhirPairBackend).unwrap(); + assert_eq!(scheme.whir_witness.security().target_security_bits, 128); + let instance = public.hash_bytes::

(HASH); + let ds = scheme.create_domain_separator().instance(&instance); + let mut prover = ProverState::new(&ds, P::transcript_sponge(HASH)); + let first = scheme + .commit(&mut prover, 4, 2, left.clone(), true) + .unwrap(); + let challenges: Vec> = prover.verifier_message_vec(1); + let right = multi_challenge_inverses_w2(&challenges, &left).unwrap(); + assert_eq!(right[0] * right[1], Base::

::ONE); + let second = scheme + .commit(&mut prover, 4, 2, right.clone(), false) + .unwrap(); + let mut full_witness = left; + full_witness.extend(right); + let (proof, queries) = prove_noir_with_joint_backend( + &scheme, + prover, + r1cs.clone(), + vec![first, second], + full_witness, + &public, + true, + &WhirPairBackend, + ) + .unwrap(); + verify_with_joint_backend(&scheme, &proof, &public, &r1cs, &WhirPairBackend).unwrap(); + + // One combined matrix query keeps the selector and every source coordinate. + let queries = queries.unwrap(); + assert_eq!(queries.queries.len(), 1); + assert_eq!(queries.queries[0].col.len(), scheme.m + 1); + + let wrong_public = PublicInputs::from_vec(vec![Base::

::from(4)]); + assert!( + verify_with_joint_backend(&scheme, &proof, &wrong_public, &r1cs, &WhirPairBackend,) + .is_err() + ); + let mut truncated = proof.clone(); + truncated.narg_string.pop(); + assert!( + verify_with_joint_backend(&scheme, &truncated, &public, &r1cs, &WhirPairBackend,).is_err() + ); +} + +#[test] +fn bn254_joint_r1cs_proof_binds_public_inputs_and_stage_challenges() { + check_full_r1cs::(); +} + +#[test] +fn extension_leaf_joint_r1cs_proof_binds_public_inputs_and_stage_challenges() { + // Existing R1CS challenge slots store one field element, so this path uses + // Identity. The next test exercises Basefield directly. + check_full_r1cs::(); +} + +#[test] +fn base_field_witnesses_open_genuine_extension_claims_through_the_adapter() { + type P = GoldilocksField; + type F = Field64_3; + P::register(); + let n = 256; + let source = ProtocolConfig::<

::Embedding>::derive( + SecuritySpec::new(128) + .with_pow_budget(PowBudget::per_slot(10)) + .with_hash(HASH.engine_id()), + TuningSpec { + vector_size: n, + starting_log_inv_rate: 2, + folding_factor: FoldingFactor::Constant(2), + rate_schedule: RateSchedule::Stepping, + }, + ) + .unwrap(); + let layout = JointOpeningLayout::new(n).unwrap(); + let e = F::new(Field64::ZERO, Field64::ONE, Field64::ZERO); + let forms = [ + JointLinearForm::new( + PrefixCovector::new([2, 3, 4, 0].map(F::from).to_vec(), n), + PrefixCovector::new([11, 13].map(F::from).to_vec(), n), + ), + JointLinearForm::new( + PrefixCovector::new(vec![F::ZERO, e + F::ONE], n), + PrefixCovector::new(vec![e], n), + ), + ]; + let refs: Vec<&dyn LinearForm> = forms.iter().map(|form| form as _).collect(); + // Left [1,2,3] gives 20. Right [5,7] gives 146. The total is 166. + // The second claim is (e+1)*2 + e*5 = 7e+2, with e outside the base field. + let values = [F::from(166), e * F::from(7) + F::from(2)]; + assert_ne!(values[1].c1, Field64::ZERO); + let mut left = vec![Field64::ZERO; n]; + left[..3].copy_from_slice(&[1, 2, 3].map(Field64::from)); + let mut right = vec![Field64::ZERO; n]; + right[..2].copy_from_slice(&[5, 7].map(Field64::from)); + let ds = DomainSeparator::protocol(&"provekit-real-pair-adapter").instance(&Empty); + let mut prover = ProverState::new(&ds, P::transcript_sponge(HASH)); + let first = source.commit(&mut prover, Buffer::from(left)); + let challenge: F = prover.verifier_message(); + let second = source.commit(&mut prover, Buffer::from(right)); + let result = >::prove( + &WhirPairBackend, + &mut prover, + &source, + layout, + [first, second], + &refs, + &values, + ) + .unwrap(); + prover.prover_message(&F::from(987)); + let proof = prover.proof(); + + let verify = |proof: &Proof, values: &[F]| -> Result<()> { + let mut verifier = VerifierState::new(&ds, proof, P::transcript_sponge(HASH)); + let first = source + .receive_commitment(&mut verifier) + .map_err(|_| anyhow::anyhow!("first commitment"))?; + let received: F = verifier.verifier_message(); + assert_eq!(received, challenge); + let second = source + .receive_commitment(&mut verifier) + .map_err(|_| anyhow::anyhow!("second commitment"))?; + let final_claim = >::verify( + &WhirPairBackend, + &mut verifier, + &source, + layout, + [first, second], + &refs, + values, + )?; + layout.validate_final_claim(&final_claim, forms.len())?; + assert_eq!(final_claim.evaluation_point, result.evaluation_point); + final_claim + .verify(&refs) + .map_err(|_| anyhow::anyhow!("final form check"))?; + let trailing: F = verifier + .prover_message() + .map_err(|_| anyhow::anyhow!("trailing value"))?; + assert_eq!(trailing, F::from(987)); + verifier + .check_eof() + .map_err(|_| anyhow::anyhow!("trailing bytes"))?; + Ok(()) + }; + verify(&proof, &values).unwrap(); + let mut false_values = values; + false_values[1] += F::ONE; + assert!(verify(&proof, &false_values).is_err()); +} From c436966d8e93aca4567fde98cde89f29f9b3070e Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 13 Oct 2026 15:25:54 +0200 Subject: [PATCH 05/12] Simplify the joint opening contract and acceptance tests --- provekit/common/JOINT_OPENING.md | 108 +++++------------- provekit/common/src/joint_opening.rs | 75 +++--------- .../provekit-fixtures/tests/joint_opening.rs | 88 +++++--------- 3 files changed, 71 insertions(+), 200 deletions(-) diff --git a/provekit/common/JOINT_OPENING.md b/provekit/common/JOINT_OPENING.md index 5aa5daead..1fd4bb3ab 100644 --- a/provekit/common/JOINT_OPENING.md +++ b/provekit/common/JOINT_OPENING.md @@ -1,98 +1,46 @@ -# Two-witness opening status +# Joint witness opening -The Standard two-witness path is implemented through `JointOpeningBackend`. -The matching WHIR branch implements the protocol. -ZeroKnowledge remains incomplete and fails before commitment or opening transcript changes. -The existing single-witness WHIR ZeroKnowledge path still exposes private witness information. +`WitnessOpeningMode::Joint` opens two existing witness commitments through `JointOpeningBackend`. +Separate mode remains the default. +Single-stage witnesses keep their existing opening path. -## Branches and stages - -The existing `zkfr/combined-opening-witness` branch remains unchanged. -This branch, `zkfr/two-witness-opening`, starts from ProveKit `main` at `35324c0d`. -Its first three commits contain these stages: - -1. Selector forms and combined claims: `9f69ad9b`. -2. Backend contract, validation, and format changes: `53ee17f7`. -3. Prover and verifier integration with readable routing tests: `58003f57`. - -WHIR has a separate `zkfr/two-witness-opening` branch based on its `main` at `afec1fac`. -Its stages cover parameter derivation, paired execution, the zero-witness repair, and privacy documentation. -The WHIR checkout contains `docs/two-witness-opening.md` with exact protocol details. - -## ProveKit interface - -Select `WitnessOpeningMode::Joint` before creating the transcript. -Call `scheme.validate_joint_backend(&backend)` before committing witnesses. -Use these injected entry points: +Select Joint mode before creating the transcript and call `scheme.validate_joint_backend(&backend)` before committing. +Use these entry points: - `prove_noir_with_joint_backend` - `prove_from_alphas_ctx_with_joint_backend` - `verify_with_joint_backend` -The default entry points use `UnavailableJointOpening` for Joint mode. -Separate mode remains the constructor default. -A Standard single-stage witness retains the existing opening path. -The serialized scheme requires an explicit opening mode. -All affected binary formats now use major version `3`. - -`JointOpeningBackend` receives two ordered existing commitment handles. -The source configuration describes each padded `N`-element witness. -Forms describe the virtual `2N`-element witness `[w1 | w2]`. +The backend receives two ordered `N`-element commitments and forms over `[w1 | w2]`. Claim order is A, B, C, optional public inputs, then optional stage challenges. -ProveKit binds these values before the backend samples shared claim coefficients. -WHIR also binds the values at its public API boundary. -This duplicates at most five field elements and prevents caller-dependent claim binding. - -The backend returns one selector-first point with `log2(N) + 1` coordinates. -It also returns one coefficient per supplied form. -ProveKit validates these dimensions and calls `FinalClaim::verify`. -SPARK receives one combined matrix query with the complete point. -The separate Spartan blinding opening follows the paired witness opening. - -## Tests +The backend binds both commitments and all values before sampling shared coefficients. +It returns one selector-first point and one coefficient per form. +ProveKit checks the result dimensions and completes `FinalClaim::verify`. +SPARK receives one combined matrix query. +The separate Spartan blinding opening follows the witness opening. + +The serialized scheme binds the selected opening mode. +Affected binary formats use major version `3`. +Joint ZeroKnowledge is disabled pending a separate inherited privacy repair. + +The current WHIR pin lacks the pair API, so default entry points reject Joint mode. +The real adapter lives in `tooling/provekit-fixtures/tests/whir_pair.rs` until the dependency update. +ProveKit uses Arkworks 0.5; WHIR main uses 0.6. +The separate dependency migration must precede direct integration with WHIR main. +A compatibility checkout supports local acceptance tests without changing that pin. ```sh cargo test --locked -p provekit-common joint_ --lib cargo test --locked -p provekit-fixtures --test joint_opening ``` -The algebra tests include `20 + 146 = 166` and `3 * 4 = 12`. -The routing tests use a recording backend and do not establish cryptographic correctness. -They check commitment count, claim order, binding checks, complete points, and backend errors. -A transcript-control test checks that Joint ZeroKnowledge rejection occurs before commitment messages. - -`tooling/provekit-fixtures/tests/whir_pair.rs` contains acceptance tests for the real WHIR backend. -Its explicit `whir_pair_backend` configuration requires the WHIR compatibility branch. -The normal dependency pin lacks the new API, so default builds skip this fixture. - -From a clean checkout, substitute the compatibility checkout path in this command: +The tests cover readable arithmetic, ordered claims, public-input binding, and backend failures. +The real-backend fixture also covers BN254, extension-field R1CS, and base-to-extension openings. +Run it with a local compatibility checkout: ```sh -RUSTFLAGS='--cfg whir_pair_backend' cargo \ - --config 'patch."https://github.com/worldfnd/whir.git".whir.path="/path/to/whir-compat"' \ - test -p provekit-fixtures --test whir_pair +cargo --config 'patch."https://github.com/worldfnd/whir.git".whir.path="/path/to/whir-compat"' \ + rustc -p provekit-fixtures --test whir_pair -- --cfg whir_pair_backend ``` -The override changes `Cargo.lock`; keep that local change outside the implementation commits. -The fixture proves full BN254 and extension-leaf Goldilocks R1CS instances. -It also opens base-field witnesses with genuine extension coefficients through the same adapter. -It checks public-input binding, stage challenges, complete SPARK points, and malformed proofs. - -## Remaining work - -WHIR `main` uses Arkworks 0.6, while ProveKit currently uses the 0.5 WHIR revision `78e9460`. -The WHIR compatibility branch carries the same implementation for current ProveKit integration tests. -[ProveKit PR #507](https://github.com/worldfnd/provekit/pull/507) already contains the Arkworks 0.6 upgrade. -That dependency update must precede direct use of the WHIR main-based branch. -The production backend then needs the small adapter currently exercised by the fixture. - -Base-committed Goldilocks cannot currently store an extension challenge in one base-field witness slot. -Full challenge-bearing R1CS tests therefore use `GoldilocksEfField`. -The WHIR paired protocol itself supports `Basefield` with genuine extension coefficients. - -A valid inherited WHIR ZeroKnowledge proof discloses an unclaimed coordinate through mask reconciliation. -The WHIR audit recovers `123456789` from a proof whose public claim equals zero. -Removing only the reconciliation scalar does not repair the later mask disclosures. -An efficient repair needs aggregate mask obligations, a terminal opening, and new security accounting. -A full-vector alternative needs uniform target-field blinders and separate soundness accounting. -Neither privacy repair is implemented in this branch. +Run the emitted test executable and restore `Cargo.lock` after this local override. diff --git a/provekit/common/src/joint_opening.rs b/provekit/common/src/joint_opening.rs index 258c5f364..15aa9e7e2 100644 --- a/provekit/common/src/joint_opening.rs +++ b/provekit/common/src/joint_opening.rs @@ -30,29 +30,13 @@ pub enum WitnessOpeningMode { Joint, } -impl std::str::FromStr for WitnessOpeningMode { - type Err = String; - - fn from_str(value: &str) -> std::result::Result { - match value.to_ascii_lowercase().as_str() { - "separate" => Ok(Self::Separate), - "joint" => Ok(Self::Joint), - _ => Err(format!( - "Invalid witness opening mode: '{value}'. Valid options: separate, joint" - )), - } - } -} - /// Dimensions of the virtual witness `[w1 | w2]`. /// /// Each source commitment has `block_size` entries. /// The first evaluation coordinate selects a block. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct JointOpeningLayout { - block_size: usize, - size: usize, - num_variables: usize, + block_size: usize, } impl JointOpeningLayout { @@ -62,14 +46,10 @@ impl JointOpeningLayout { block_size.is_power_of_two(), "Joint opening block size must be a nonzero power of two" ); - let size = block_size + block_size .checked_mul(2) .ok_or_else(|| anyhow::anyhow!("Joint opening domain size exceeds usize"))?; - Ok(Self { - block_size, - size, - num_variables: size.trailing_zeros() as usize, - }) + Ok(Self { block_size }) } /// Return the size of each source commitment. @@ -79,12 +59,12 @@ impl JointOpeningLayout { /// Return the combined form size. pub const fn size(self) -> usize { - self.size + self.block_size * 2 } /// Return the evaluation point length, including the selector. pub const fn num_variables(self) -> usize { - self.num_variables + self.block_size.trailing_zeros() as usize + 1 } /// Check source dimensions and the existing source configuration @@ -130,10 +110,10 @@ impl JointOpeningLayout { ); for (index, form) in forms.iter().enumerate() { ensure!( - form.size() == self.size, + form.size() == self.size(), "Joint opening form {index} has size {}, expected {}", form.size(), - self.size + self.size() ); } Ok(()) @@ -142,10 +122,10 @@ impl JointOpeningLayout { /// Check the complete evaluation point before evaluating combined forms. pub fn validate_point(self, point: &[F]) -> Result<()> { ensure!( - point.len() == self.num_variables, + point.len() == self.num_variables(), "Joint opening point has {} coordinates, expected {}", point.len(), - self.num_variables + self.num_variables() ); Ok(()) } @@ -183,8 +163,7 @@ impl JointOpeningLayout { pub(crate) fn ensure_joint_standard(source: &ZookConfig) -> Result<()> { ensure!( source.security().mode == Mode::Standard, - "Joint zero-knowledge opening is unavailable: WHIR must fix the code-switch privacy leak \ - before joint ZK proofs are supported" + "Joint zero-knowledge opening is unavailable" ); Ok(()) } @@ -195,8 +174,7 @@ pub(crate) fn ensure_joint_standard(source: &ZookConfig) -> Res /// `w2`. The backend must bind both commitments and all supplied form values. /// It must sample shared claim coefficients after binding every claim value. /// Public data or prior transcript messages must fix the forms before sampling. -/// ZeroKnowledge mode remains unavailable until WHIR fixes code-switch privacy. -/// A future ZeroKnowledge backend must preserve both source commitment masks. +/// Only Standard mode is supported. /// It must return the complete point, with the selector coordinate first. /// It must bind any derived joint configuration into the transcript. /// Source configuration security alone does not establish joint protocol @@ -279,10 +257,7 @@ impl JointOpeningBackend

for UnavailableJointOpening { } fn joint_opening_unavailable() -> anyhow::Error { - anyhow::anyhow!( - "Joint witness opening is unavailable: WHIR must supply the joint opening backend; use \ - separate witness openings" - ) + anyhow::anyhow!("Joint witness opening is unavailable in the pinned WHIR dependency") } #[cfg(test)] @@ -381,7 +356,9 @@ mod tests { .unwrap() .validate_source_config(&source) .unwrap_err(); - assert!(error.to_string().contains("code-switch privacy leak")); + assert!(error + .to_string() + .contains("Joint zero-knowledge opening is unavailable")); } #[test] @@ -449,27 +426,7 @@ mod tests { } #[test] - fn separate_is_the_default_and_joint_requires_an_explicit_choice() { + fn separate_is_the_default() { assert_eq!(WitnessOpeningMode::default(), WitnessOpeningMode::Separate); - assert_eq!("separate".parse(), Ok(WitnessOpeningMode::Separate)); - assert_eq!("joint".parse(), Ok(WitnessOpeningMode::Joint)); - assert_eq!("JOINT".parse(), Ok(WitnessOpeningMode::Joint)); - assert!("jointly".parse::().is_err()); - } - - #[test] - fn both_opening_modes_round_trip_through_json_and_postcard() { - for mode in [WitnessOpeningMode::Separate, WitnessOpeningMode::Joint] { - let json = serde_json::to_string(&mode).unwrap(); - assert_eq!( - serde_json::from_str::(&json).unwrap(), - mode - ); - let postcard = postcard::to_allocvec(&mode).unwrap(); - assert_eq!( - postcard::from_bytes::(&postcard).unwrap(), - mode - ); - } } } diff --git a/tooling/provekit-fixtures/tests/joint_opening.rs b/tooling/provekit-fixtures/tests/joint_opening.rs index beeb7415a..12314e874 100644 --- a/tooling/provekit-fixtures/tests/joint_opening.rs +++ b/tooling/provekit-fixtures/tests/joint_opening.rs @@ -18,10 +18,7 @@ use { ProveFromAlphasCtx, WhirR1CSCommitment, WhirR1CSProver, }, provekit_verifier::verify_with_joint_backend, - std::sync::{ - atomic::{AtomicUsize, Ordering}, - Mutex, - }, + std::cell::{Cell, RefCell}, whir::{ algebra::{embedding::Identity, linear_form::LinearForm}, protocols::zook::{ @@ -145,10 +142,10 @@ struct RecordedOpening { /// This backend returns test data, not a cryptographic opening. #[derive(Default)] struct RecordingBackend { - calls: AtomicUsize, - verifier_calls: AtomicUsize, - recorded: Mutex>, - recorded_verification: Mutex>, + calls: Cell, + verifier_calls: Cell, + recorded: RefCell>, + recorded_verification: RefCell>, result: RecordingResult, verifier_result: VerifierResult, } @@ -167,7 +164,7 @@ impl JointOpeningBackend for RecordingBackend { forms: &[&dyn LinearForm], values: &[FieldElement], ) -> Result> { - self.calls.fetch_add(1, Ordering::SeqCst); + self.calls.set(self.calls.get() + 1); layout.validate_claims(forms, values)?; if matches!(self.result, RecordingResult::Error) { bail!("Recording backend opening error"); @@ -186,7 +183,7 @@ impl JointOpeningBackend for RecordingBackend { if matches!(self.result, RecordingResult::MissingSelector) { point.remove(0); } - *self.recorded.lock().unwrap() = Some(RecordedOpening { + *self.recorded.borrow_mut() = Some(RecordedOpening { source_size: source.tuning().vector_size, layout, forms: coefficients, @@ -213,7 +210,7 @@ impl JointOpeningBackend for RecordingBackend { forms: &[&dyn LinearForm], values: &[FieldElement], ) -> Result> { - self.verifier_calls.fetch_add(1, Ordering::SeqCst); + self.verifier_calls.set(self.verifier_calls.get() + 1); layout.validate_claims(forms, values)?; let coefficients = forms .iter() @@ -225,7 +222,7 @@ impl JointOpeningBackend for RecordingBackend { .collect(); let mut point = vec![field(0); layout.num_variables()]; point[0] = field(2); - *self.recorded_verification.lock().unwrap() = Some(RecordedOpening { + *self.recorded_verification.borrow_mut() = Some(RecordedOpening { source_size: source.tuning().vector_size, layout, forms: coefficients, @@ -338,7 +335,7 @@ fn an_empty_commitment_list_returns_an_error_before_indexing() { ); assert!(result.is_err()); } - assert_eq!(backend.calls.load(Ordering::SeqCst), 0); + assert_eq!(backend.calls.get(), 0); } #[test] @@ -368,18 +365,12 @@ fn a_two_stage_scheme_rejects_one_or_three_commitments() { .expect("A two-stage scheme requires two commitments"); assert!(format!("{error:#}").contains("commitment"), "{error:#}"); } - assert_eq!(backend.calls.load(Ordering::SeqCst), 0); + assert_eq!(backend.calls.get(), 0); } #[test] fn joint_pipeline_calls_one_backend_with_three_matrix_and_two_binding_claims() { - for witness_mode in [WitnessCommitmentMode::Standard] { - assert_joint_pipeline_claims(witness_mode); - } -} - -fn assert_joint_pipeline_claims(witness_mode: WitnessCommitmentMode) { - let scheme = scheme_with_commitment_mode(WitnessOpeningMode::Joint, witness_mode); + let scheme = scheme(WitnessOpeningMode::Joint); let public = PublicInputs::from_vec(vec![field(3)]); let backend = RecordingBackend::default(); let mut merlin = transcript(&scheme, &public); @@ -388,8 +379,8 @@ fn assert_joint_pipeline_claims(witness_mode: WitnessCommitmentMode) { prove_from_alphas_ctx_with_joint_backend(&scheme, merlin, ctx, &public, &backend) .expect("The recording backend checks routing only"); - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); - let recorded = backend.recorded.lock().unwrap().as_ref().unwrap().clone(); + assert_eq!(backend.calls.get(), 1); + let recorded = backend.recorded.borrow().as_ref().unwrap().clone(); let block_size = scheme.domain_size(); assert_eq!(recorded.source_size, block_size); assert_eq!(recorded.layout.size(), 2 * block_size); @@ -468,7 +459,7 @@ fn assert_bad_backend_result(result: RecordingResult, expected_error: &str) { .err() .expect("A malformed backend result must fail"); assert!(format!("{error:#}").contains(expected_error), "{error:#}"); - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); + assert_eq!(backend.calls.get(), 1); } #[test] @@ -502,25 +493,6 @@ fn full_outer_routing_fixture( WhirR1CSProof, PublicInputs, R1CS, -) { - full_outer_routing_fixture_with_mode( - backend, - public_witness, - change_challenge, - WitnessCommitmentMode::Standard, - ) -} - -fn full_outer_routing_fixture_with_mode( - backend: &RecordingBackend, - public_witness: u64, - change_challenge: bool, - witness_mode: WitnessCommitmentMode, -) -> ( - Scheme, - WhirR1CSProof, - PublicInputs, - R1CS, ) { let (r1cs, mut w1, offsets) = challenge_with_public_input::(3); w1[1] = field(public_witness); @@ -532,7 +504,7 @@ fn full_outer_routing_fixture_with_mode( offsets, true, HashConfig::Sha256, - witness_mode, + WitnessCommitmentMode::Standard, ) .unwrap() .with_witness_opening(WitnessOpeningMode::Joint); @@ -579,26 +551,18 @@ fn full_outer_routing_fixture_with_mode( #[test] fn the_outer_transcript_reaches_one_verifier_call_with_the_same_forms_and_values() { - for witness_mode in [WitnessCommitmentMode::Standard] { - assert_full_outer_transcript_agreement(witness_mode); - } -} - -fn assert_full_outer_transcript_agreement(witness_mode: WitnessCommitmentMode) { let backend = RecordingBackend::default(); - let (scheme, routing_payload, public, r1cs) = - full_outer_routing_fixture_with_mode(&backend, 3, false, witness_mode); + let (scheme, routing_payload, public, r1cs) = full_outer_routing_fixture(&backend, 3, false); let error = verify_with_joint_backend(&scheme, &routing_payload, &public, &r1cs, &backend) .expect_err("The recording backend never verifies a proof"); assert!(format!("{error:#}").contains("The recording backend never verifies a proof")); - assert_eq!(backend.calls.load(Ordering::SeqCst), 1); - assert_eq!(backend.verifier_calls.load(Ordering::SeqCst), 1); + assert_eq!(backend.calls.get(), 1); + assert_eq!(backend.verifier_calls.get(), 1); - let prover = backend.recorded.lock().unwrap().as_ref().unwrap().clone(); + let prover = backend.recorded.borrow().as_ref().unwrap().clone(); let verifier = backend .recorded_verification - .lock() - .unwrap() + .borrow() .as_ref() .unwrap() .clone(); @@ -636,7 +600,7 @@ fn assert_bad_verifier_result(verifier_result: VerifierResult, expected_error: & let error = verify_with_joint_backend(&scheme, &routing_payload, &public, &r1cs, &backend) .expect_err("Every test final claim fails a ProveKit check"); assert!(format!("{error:#}").contains(expected_error), "{error:#}"); - assert_eq!(backend.verifier_calls.load(Ordering::SeqCst), 1); + assert_eq!(backend.verifier_calls.get(), 1); } #[test] @@ -649,7 +613,7 @@ fn verifier_rejects_a_wrong_public_witness_before_the_joint_backend() { format!("{error:#}").to_lowercase().contains("public"), "{error:#}" ); - assert_eq!(backend.verifier_calls.load(Ordering::SeqCst), 0); + assert_eq!(backend.verifier_calls.get(), 0); } #[test] @@ -662,7 +626,7 @@ fn verifier_rejects_a_changed_challenge_before_the_joint_backend() { format!("{error:#}").to_lowercase().contains("challenge"), "{error:#}" ); - assert_eq!(backend.verifier_calls.load(Ordering::SeqCst), 0); + assert_eq!(backend.verifier_calls.get(), 0); } #[test] @@ -684,7 +648,9 @@ fn joint_zero_knowledge_rejects_before_commitment_changes_the_transcript() { ) .err() .expect("Joint ZK requires a privacy repair"); - assert!(error.to_string().contains("privacy leak")); + assert!(error + .to_string() + .contains("Joint zero-knowledge opening is unavailable")); let after: FieldElement = attempted.verifier_message(); let untouched: FieldElement = control.verifier_message(); assert_eq!(after, untouched); From bcd69180a29c8238b9100e56997b527223650dcb Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 13 Oct 2026 15:54:23 +0200 Subject: [PATCH 06/12] test(proof): use the simplified WHIR pair API --- tooling/provekit-fixtures/tests/whir_pair.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tooling/provekit-fixtures/tests/whir_pair.rs b/tooling/provekit-fixtures/tests/whir_pair.rs index 32e6fdf95..8679b09a2 100644 --- a/tooling/provekit-fixtures/tests/whir_pair.rs +++ b/tooling/provekit-fixtures/tests/whir_pair.rs @@ -28,10 +28,7 @@ use { }, buffer::Buffer, protocols::{ - params::{ - joint::JointConfig, FoldingFactor, PowBudget, RateSchedule, SecuritySpec, - TuningSpec, - }, + params::{FoldingFactor, PowBudget, RateSchedule, SecuritySpec, TuningSpec}, zook::{Commitment, CommittedWitness, FinalClaim, ProtocolConfig, ProverClaim}, }, transcript::{ @@ -54,7 +51,9 @@ where layout: JointOpeningLayout, ) -> Result<()> { layout.validate_source_config(source)?; - JointConfig::derive(source)?.validate()?; + source + .validate_pair() + .map_err(|_| anyhow::anyhow!("Invalid pair parameters"))?; Ok(()) } @@ -69,7 +68,9 @@ where ) -> Result>> { layout.validate_source_config(source)?; layout.validate_claims(forms, values)?; - Ok(JointConfig::derive(source)?.prove_pair(transcript, witnesses, forms, values)?) + source + .prove_pair(transcript, witnesses, forms, values) + .map_err(|_| anyhow::anyhow!("Pair opening failed")) } fn verify( @@ -83,7 +84,9 @@ where ) -> Result>> { layout.validate_source_config(source)?; layout.validate_claims(forms, values)?; - Ok(JointConfig::derive(source)?.verify_pair(transcript, commitments, forms, values)?) + source + .verify_pair(transcript, commitments, forms, values) + .map_err(|_| anyhow::anyhow!("Invalid pair opening")) } } From 298c1f1d9ab823385af770fba9e19e2bd4955131 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 13 Oct 2026 16:03:07 +0200 Subject: [PATCH 07/12] feat(proof): allow both joint opening commitment modes --- provekit/common/JOINT_OPENING.md | 4 +- provekit/common/src/joint_opening.rs | 47 ++------ provekit/common/src/whir_r1cs.rs | 13 +-- provekit/prover/src/whir_r1cs.rs | 3 +- .../provekit-fixtures/tests/joint_opening.rs | 108 ++++++++---------- tooling/provekit-fixtures/tests/whir_pair.rs | 39 ++++--- 6 files changed, 87 insertions(+), 127 deletions(-) diff --git a/provekit/common/JOINT_OPENING.md b/provekit/common/JOINT_OPENING.md index 1fd4bb3ab..308769155 100644 --- a/provekit/common/JOINT_OPENING.md +++ b/provekit/common/JOINT_OPENING.md @@ -21,7 +21,7 @@ The separate Spartan blinding opening follows the witness opening. The serialized scheme binds the selected opening mode. Affected binary formats use major version `3`. -Joint ZeroKnowledge is disabled pending a separate inherited privacy repair. +Joint opening preserves the source commitment mode, including ZeroKnowledge. The current WHIR pin lacks the pair API, so default entry points reject Joint mode. The real adapter lives in `tooling/provekit-fixtures/tests/whir_pair.rs` until the dependency update. @@ -35,7 +35,7 @@ cargo test --locked -p provekit-fixtures --test joint_opening ``` The tests cover readable arithmetic, ordered claims, public-input binding, and backend failures. -The real-backend fixture also covers BN254, extension-field R1CS, and base-to-extension openings. +The real-backend fixture covers BN254, extension-field R1CS, and base-to-extension openings in both commitment modes. Run it with a local compatibility checkout: ```sh diff --git a/provekit/common/src/joint_opening.rs b/provekit/common/src/joint_opening.rs index 15aa9e7e2..355fe0395 100644 --- a/provekit/common/src/joint_opening.rs +++ b/provekit/common/src/joint_opening.rs @@ -10,11 +10,8 @@ use { serde::{Deserialize, Serialize}, whir::{ algebra::{embedding::Embedding, linear_form::LinearForm}, - protocols::{ - params::Mode, - zook::{ - Commitment, CommittedWitness, FinalClaim, ProtocolConfig as ZookConfig, ProverClaim, - }, + protocols::zook::{ + Commitment, CommittedWitness, FinalClaim, ProtocolConfig as ZookConfig, ProverClaim, }, transcript::{ProverState, VerifierState}, }, @@ -73,7 +70,6 @@ impl JointOpeningLayout { /// This check does not establish security for the joint protocol. /// The backend must also validate its joint configuration. pub fn validate_source_config(self, source: &ZookConfig) -> Result<()> { - ensure_joint_standard(source)?; ensure!( source.tuning().vector_size == self.block_size, "Joint opening source size {} does not match block size {}", @@ -159,22 +155,13 @@ impl JointOpeningLayout { } } -/// Reject joint ZK before any backend call or transcript interaction. -pub(crate) fn ensure_joint_standard(source: &ZookConfig) -> Result<()> { - ensure!( - source.security().mode == Mode::Standard, - "Joint zero-knowledge opening is unavailable" - ); - Ok(()) -} - /// Backend contract for one proof over two existing commitments. /// /// Array order fixes the selector: index 0 selects `w1`, and index 1 selects /// `w2`. The backend must bind both commitments and all supplied form values. /// It must sample shared claim coefficients after binding every claim value. /// Public data or prior transcript messages must fix the forms before sampling. -/// Only Standard mode is supported. +/// It must preserve the source configuration's commitment mode. /// It must return the complete point, with the selector coordinate first. /// It must bind any derived joint configuration into the transcript. /// Source configuration security alone does not establish joint protocol @@ -349,25 +336,15 @@ mod tests { } #[test] - fn joint_zero_knowledge_fails_preflight_even_with_valid_source_parameters() { - let source = extension_source(Mode::ZeroKnowledge, 32); - source.validate().unwrap(); - let error = JointOpeningLayout::new(32) - .unwrap() - .validate_source_config(&source) - .unwrap_err(); - assert!(error - .to_string() - .contains("Joint zero-knowledge opening is unavailable")); - } - - #[test] - fn joint_standard_accepts_extension_fields_at_128_bits() { - let source = extension_source(Mode::Standard, 32); - JointOpeningLayout::new(32) - .unwrap() - .validate_source_config(&source) - .unwrap(); + fn both_modes_accept_extension_fields_at_128_bits() { + for mode in [Mode::Standard, Mode::ZeroKnowledge] { + let source = extension_source(mode, 32); + JointOpeningLayout::new(32) + .unwrap() + .validate_source_config(&source) + .unwrap(); + assert_eq!(source.security().mode, mode); + } } #[test] diff --git a/provekit/common/src/whir_r1cs.rs b/provekit/common/src/whir_r1cs.rs index ef5997e6a..911c48d9c 100644 --- a/provekit/common/src/whir_r1cs.rs +++ b/provekit/common/src/whir_r1cs.rs @@ -160,8 +160,8 @@ pub struct WhirR1CSScheme { pub has_public_inputs: bool, /// Base-field witness commitment, via zook. pub whir_witness: ZookConfig, - /// Opening protocol for two-stage witnesses. Joint ZeroKnowledge mode is - /// unavailable. Standard single-stage witnesses use the existing opening. + /// Opening protocol for two-stage witnesses. + /// Single-stage witnesses use the existing opening. pub witness_opening: WitnessOpeningMode, /// Separate ext-field commitment to the Spartan blinding polynomial `g`; /// masking the ext-valued sumcheck rounds needs ext randomness, so `g` @@ -196,14 +196,6 @@ impl WhirR1CSScheme

{ Ok(()) } - /// Reject unsupported joint ZK before committing a witness. - pub fn validate_witness_opening_mode(&self) -> anyhow::Result<()> { - if self.witness_opening == WitnessOpeningMode::Joint { - crate::joint_opening::ensure_joint_standard(&self.whir_witness)?; - } - Ok(()) - } - /// Create a domain separator for the provekit outer protocol. /// /// The domain separator serializes the entire scheme (including @@ -236,7 +228,6 @@ impl WhirR1CSScheme

{ &self, backend: &B, ) -> anyhow::Result<()> { - self.validate_witness_opening_mode()?; if self.witness_opening == WitnessOpeningMode::Joint && self.num_challenges > 0 { anyhow::ensure!( self.num_challenges == self.challenge_offsets.len(), diff --git a/provekit/prover/src/whir_r1cs.rs b/provekit/prover/src/whir_r1cs.rs index e754df76a..8e6c69f8a 100644 --- a/provekit/prover/src/whir_r1cs.rs +++ b/provekit/prover/src/whir_r1cs.rs @@ -118,7 +118,6 @@ where witness: Vec>, is_w1: bool, ) -> Result> { - self.validate_witness_opening_mode()?; let witness_size = if is_w1 { self.w1_size } else { @@ -230,7 +229,7 @@ where /// Prove R1CS satisfaction with an external backend for joint witness openings. /// -/// Separate and single-stage openings use the existing ZO0K implementation. +/// Separate and single-stage openings use the existing zook implementation. #[instrument(skip_all)] #[allow(clippy::too_many_arguments)] pub fn prove_noir_with_joint_backend>( diff --git a/tooling/provekit-fixtures/tests/joint_opening.rs b/tooling/provekit-fixtures/tests/joint_opening.rs index 12314e874..3ca10fa6a 100644 --- a/tooling/provekit-fixtures/tests/joint_opening.rs +++ b/tooling/provekit-fixtures/tests/joint_opening.rs @@ -21,8 +21,11 @@ use { std::cell::{Cell, RefCell}, whir::{ algebra::{embedding::Identity, linear_form::LinearForm}, - protocols::zook::{ - Commitment, CommittedWitness, FinalClaim, ProtocolConfig as ZookConfig, ProverClaim, + protocols::{ + params::Mode, + zook::{ + Commitment, CommittedWitness, FinalClaim, ProtocolConfig as ZookConfig, ProverClaim, + }, }, transcript::{ProverState, VerifierMessage, VerifierState}, }, @@ -45,13 +48,6 @@ fn multiplication_r1cs() -> R1CS { } fn scheme(opening_mode: WitnessOpeningMode) -> Scheme { - scheme_with_commitment_mode(opening_mode, WitnessCommitmentMode::Standard) -} - -fn scheme_with_commitment_mode( - opening_mode: WitnessOpeningMode, - witness_mode: WitnessCommitmentMode, -) -> Scheme { Scheme::new_for_r1cs( &multiplication_r1cs(), 3, @@ -59,7 +55,7 @@ fn scheme_with_commitment_mode( vec![0], true, HashConfig::Sha256, - witness_mode, + WitnessCommitmentMode::Standard, ) .unwrap() .with_witness_opening(opening_mode) @@ -132,6 +128,7 @@ enum VerifierResult { #[derive(Clone)] struct RecordedOpening { + source_mode: Mode, source_size: usize, layout: JointOpeningLayout, forms: Vec>, @@ -184,6 +181,7 @@ impl JointOpeningBackend for RecordingBackend { point.remove(0); } *self.recorded.borrow_mut() = Some(RecordedOpening { + source_mode: source.security().mode, source_size: source.tuning().vector_size, layout, forms: coefficients, @@ -223,6 +221,7 @@ impl JointOpeningBackend for RecordingBackend { let mut point = vec![field(0); layout.num_variables()]; point[0] = field(2); *self.recorded_verification.borrow_mut() = Some(RecordedOpening { + source_mode: source.security().mode, source_size: source.tuning().vector_size, layout, forms: coefficients, @@ -488,6 +487,7 @@ fn full_outer_routing_fixture( backend: &RecordingBackend, public_witness: u64, change_challenge: bool, + witness_mode: WitnessCommitmentMode, ) -> ( Scheme, WhirR1CSProof, @@ -504,7 +504,7 @@ fn full_outer_routing_fixture( offsets, true, HashConfig::Sha256, - WitnessCommitmentMode::Standard, + witness_mode, ) .unwrap() .with_witness_opening(WitnessOpeningMode::Joint); @@ -550,27 +550,35 @@ fn full_outer_routing_fixture( } #[test] -fn the_outer_transcript_reaches_one_verifier_call_with_the_same_forms_and_values() { - let backend = RecordingBackend::default(); - let (scheme, routing_payload, public, r1cs) = full_outer_routing_fixture(&backend, 3, false); - let error = verify_with_joint_backend(&scheme, &routing_payload, &public, &r1cs, &backend) - .expect_err("The recording backend never verifies a proof"); - assert!(format!("{error:#}").contains("The recording backend never verifies a proof")); - assert_eq!(backend.calls.get(), 1); - assert_eq!(backend.verifier_calls.get(), 1); - - let prover = backend.recorded.borrow().as_ref().unwrap().clone(); - let verifier = backend - .recorded_verification - .borrow() - .as_ref() - .unwrap() - .clone(); - assert_eq!(prover.layout, verifier.layout); - assert_eq!(prover.source_size, verifier.source_size); - assert_eq!(prover.forms, verifier.forms); - assert_eq!(prover.values, verifier.values); - assert_eq!(verifier.values.len(), 5); +fn both_modes_reach_one_verifier_call_with_the_same_forms_and_values() { + for witness_mode in [ + WitnessCommitmentMode::Standard, + WitnessCommitmentMode::ZeroKnowledge, + ] { + let backend = RecordingBackend::default(); + let (scheme, routing_payload, public, r1cs) = + full_outer_routing_fixture(&backend, 3, false, witness_mode); + let error = verify_with_joint_backend(&scheme, &routing_payload, &public, &r1cs, &backend) + .expect_err("The recording backend never verifies a proof"); + assert!(format!("{error:#}").contains("The recording backend never verifies a proof")); + assert_eq!(backend.calls.get(), 1); + assert_eq!(backend.verifier_calls.get(), 1); + + let prover = backend.recorded.borrow().as_ref().unwrap().clone(); + let verifier = backend + .recorded_verification + .borrow() + .as_ref() + .unwrap() + .clone(); + assert_eq!(prover.source_mode, Mode::from(witness_mode)); + assert_eq!(prover.source_mode, verifier.source_mode); + assert_eq!(prover.layout, verifier.layout); + assert_eq!(prover.source_size, verifier.source_size); + assert_eq!(prover.forms, verifier.forms); + assert_eq!(prover.values, verifier.values); + assert_eq!(verifier.values.len(), 5); + } } #[test] @@ -596,7 +604,8 @@ fn assert_bad_verifier_result(verifier_result: VerifierResult, expected_error: & verifier_result, ..RecordingBackend::default() }; - let (scheme, routing_payload, public, r1cs) = full_outer_routing_fixture(&backend, 3, false); + let (scheme, routing_payload, public, r1cs) = + full_outer_routing_fixture(&backend, 3, false, WitnessCommitmentMode::Standard); let error = verify_with_joint_backend(&scheme, &routing_payload, &public, &r1cs, &backend) .expect_err("Every test final claim fails a ProveKit check"); assert!(format!("{error:#}").contains(expected_error), "{error:#}"); @@ -606,7 +615,8 @@ fn assert_bad_verifier_result(verifier_result: VerifierResult, expected_error: & #[test] fn verifier_rejects_a_wrong_public_witness_before_the_joint_backend() { let backend = RecordingBackend::default(); - let (scheme, routing_payload, public, r1cs) = full_outer_routing_fixture(&backend, 4, false); + let (scheme, routing_payload, public, r1cs) = + full_outer_routing_fixture(&backend, 4, false, WitnessCommitmentMode::Standard); let error = verify_with_joint_backend(&scheme, &routing_payload, &public, &r1cs, &backend) .expect_err("The witness contains 4, but the public input is 3"); assert!( @@ -619,7 +629,8 @@ fn verifier_rejects_a_wrong_public_witness_before_the_joint_backend() { #[test] fn verifier_rejects_a_changed_challenge_before_the_joint_backend() { let backend = RecordingBackend::default(); - let (scheme, routing_payload, public, r1cs) = full_outer_routing_fixture(&backend, 3, true); + let (scheme, routing_payload, public, r1cs) = + full_outer_routing_fixture(&backend, 3, true, WitnessCommitmentMode::Standard); let error = verify_with_joint_backend(&scheme, &routing_payload, &public, &r1cs, &backend) .expect_err("The witness challenge differs from the transcript challenge"); assert!( @@ -628,30 +639,3 @@ fn verifier_rejects_a_changed_challenge_before_the_joint_backend() { ); assert_eq!(backend.verifier_calls.get(), 0); } - -#[test] -fn joint_zero_knowledge_rejects_before_commitment_changes_the_transcript() { - let scheme = scheme_with_commitment_mode( - WitnessOpeningMode::Joint, - WitnessCommitmentMode::ZeroKnowledge, - ); - let public = PublicInputs::from_vec(vec![field(3)]); - let mut attempted = transcript(&scheme, &public); - let mut control = transcript(&scheme, &public); - let error = scheme - .commit( - &mut attempted, - 5, - 1, - vec![field(1), field(3), field(12)], - true, - ) - .err() - .expect("Joint ZK requires a privacy repair"); - assert!(error - .to_string() - .contains("Joint zero-knowledge opening is unavailable")); - let after: FieldElement = attempted.verifier_message(); - let untouched: FieldElement = control.verifier_message(); - assert_eq!(after, untouched); -} diff --git a/tooling/provekit-fixtures/tests/whir_pair.rs b/tooling/provekit-fixtures/tests/whir_pair.rs index 8679b09a2..b9c70cc9a 100644 --- a/tooling/provekit-fixtures/tests/whir_pair.rs +++ b/tooling/provekit-fixtures/tests/whir_pair.rs @@ -28,7 +28,7 @@ use { }, buffer::Buffer, protocols::{ - params::{FoldingFactor, PowBudget, RateSchedule, SecuritySpec, TuningSpec}, + params::{FoldingFactor, Mode, PowBudget, RateSchedule, SecuritySpec, TuningSpec}, zook::{Commitment, CommittedWitness, FinalClaim, ProtocolConfig, ProverClaim}, }, transcript::{ @@ -90,7 +90,7 @@ where } } -fn check_full_r1cs

() +fn check_full_r1cs

(mode: WitnessCommitmentMode) where P: FieldHash, P::Embedding: Embedding>, @@ -100,17 +100,9 @@ where // The witness is [1, 3 | c, 1/c]. The constraints are 3*1=3 and c*(1/c)=1. let (r1cs, left, offsets) = challenge_with_public_input::>(3); let public = PublicInputs::from_vec(vec![Base::

::from(3)]); - let scheme = WhirR1CSScheme::

::new_for_r1cs( - &r1cs, - left.len(), - 1, - offsets, - true, - HASH, - WitnessCommitmentMode::Standard, - ) - .unwrap() - .with_witness_opening(WitnessOpeningMode::Joint); + let scheme = WhirR1CSScheme::

::new_for_r1cs(&r1cs, left.len(), 1, offsets, true, HASH, mode) + .unwrap() + .with_witness_opening(WitnessOpeningMode::Joint); scheme.validate_joint_backend(&WhirPairBackend).unwrap(); assert_eq!(scheme.whir_witness.security().target_security_bits, 128); let instance = public.hash_bytes::

(HASH); @@ -159,24 +151,41 @@ where #[test] fn bn254_joint_r1cs_proof_binds_public_inputs_and_stage_challenges() { - check_full_r1cs::(); + for mode in [ + WitnessCommitmentMode::Standard, + WitnessCommitmentMode::ZeroKnowledge, + ] { + check_full_r1cs::(mode); + } } #[test] fn extension_leaf_joint_r1cs_proof_binds_public_inputs_and_stage_challenges() { // Existing R1CS challenge slots store one field element, so this path uses // Identity. The next test exercises Basefield directly. - check_full_r1cs::(); + for mode in [ + WitnessCommitmentMode::Standard, + WitnessCommitmentMode::ZeroKnowledge, + ] { + check_full_r1cs::(mode); + } } #[test] fn base_field_witnesses_open_genuine_extension_claims_through_the_adapter() { + for mode in [Mode::Standard, Mode::ZeroKnowledge] { + check_base_field_pair(mode); + } +} + +fn check_base_field_pair(mode: Mode) { type P = GoldilocksField; type F = Field64_3; P::register(); let n = 256; let source = ProtocolConfig::<

::Embedding>::derive( SecuritySpec::new(128) + .with_mode(mode) .with_pow_budget(PowBudget::per_slot(10)) .with_hash(HASH.engine_id()), TuningSpec { From 2b530d3017dd5a81c817828689cf90a29ad55ad2 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 13 Oct 2026 16:25:40 +0200 Subject: [PATCH 08/12] refactor(proof): call WHIR joint opening directly --- Cargo.lock | 1 + provekit/common/JOINT_OPENING.md | 52 +- provekit/common/src/joint_opening.rs | 409 -------- provekit/common/src/lib.rs | 6 +- provekit/common/src/whir_r1cs.rs | 31 +- provekit/prover/src/lib.rs | 5 +- provekit/prover/src/whir_r1cs.rs | 69 +- provekit/verifier/src/lib.rs | 2 +- provekit/verifier/src/whir_r1cs.rs | 578 ++++++----- tooling/provekit-fixtures/Cargo.toml | 1 + .../provekit-fixtures/tests/joint_opening.rs | 905 +++++++----------- tooling/provekit-fixtures/tests/whir_pair.rs | 275 ------ 12 files changed, 665 insertions(+), 1669 deletions(-) delete mode 100644 provekit/common/src/joint_opening.rs delete mode 100644 tooling/provekit-fixtures/tests/whir_pair.rs diff --git a/Cargo.lock b/Cargo.lock index 4d2aeb8b9..afb1ba822 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4657,6 +4657,7 @@ dependencies = [ "provekit-backend-goldilocks", "provekit-common", "provekit-prover", + "provekit-spark", "provekit-verifier", "serde_json", "tracing", diff --git a/provekit/common/JOINT_OPENING.md b/provekit/common/JOINT_OPENING.md index 308769155..80be1fc3f 100644 --- a/provekit/common/JOINT_OPENING.md +++ b/provekit/common/JOINT_OPENING.md @@ -1,46 +1,26 @@ # Joint witness opening -`WitnessOpeningMode::Joint` opens two existing witness commitments through `JointOpeningBackend`. -Separate mode remains the default. -Single-stage witnesses keep their existing opening path. - -Select Joint mode before creating the transcript and call `scheme.validate_joint_backend(&backend)` before committing. -Use these entry points: - -- `prove_noir_with_joint_backend` -- `prove_from_alphas_ctx_with_joint_backend` -- `verify_with_joint_backend` - -The backend receives two ordered `N`-element commitments and forms over `[w1 | w2]`. -Claim order is A, B, C, optional public inputs, then optional stage challenges. -The backend binds both commitments and all values before sampling shared coefficients. -It returns one selector-first point and one coefficient per form. -ProveKit checks the result dimensions and completes `FinalClaim::verify`. -SPARK receives one combined matrix query. +Set `WitnessOpeningMode::Joint` before creating the transcript. +Separate opening remains the default. Single-stage witnesses use their existing opening path. + +The normal prover and verifier call WHIR `prove_pair` and `verify_pair` directly. +Both commitments keep their original encoding and order. +Forms describe the padded witness `[w1 | w2]`. +A, B, and C remain separate claims; each adds its two witness-stage contributions. +Public inputs bind the first block. Stage challenges bind the second block. +WHIR binds the claim values before sampling shared coefficients. +The verifier completes `FinalClaim::verify`. +SPARK receives one query with the witness selector first. The separate Spartan blinding opening follows the witness opening. -The serialized scheme binds the selected opening mode. -Affected binary formats use major version `3`. -Joint opening preserves the source commitment mode, including ZeroKnowledge. - -The current WHIR pin lacks the pair API, so default entry points reject Joint mode. -The real adapter lives in `tooling/provekit-fixtures/tests/whir_pair.rs` until the dependency update. -ProveKit uses Arkworks 0.5; WHIR main uses 0.6. -The separate dependency migration must precede direct integration with WHIR main. -A compatibility checkout supports local acceptance tests without changing that pin. +Standard and ZeroKnowledge commitment modes both support joint opening. +The serialized scheme binds the opening mode. Affected binary formats use major version `3`. +Build against the matching WHIR joint-opening branch. ```sh cargo test --locked -p provekit-common joint_ --lib cargo test --locked -p provekit-fixtures --test joint_opening ``` -The tests cover readable arithmetic, ordered claims, public-input binding, and backend failures. -The real-backend fixture covers BN254, extension-field R1CS, and base-to-extension openings in both commitment modes. -Run it with a local compatibility checkout: - -```sh -cargo --config 'patch."https://github.com/worldfnd/whir.git".whir.path="/path/to/whir-compat"' \ - rustc -p provekit-fixtures --test whir_pair -- --cfg whir_pair_backend -``` - -Run the emitted test executable and restore `Cargo.lock` after this local override. +Tests use the normal APIs without a custom backend or build configuration. +They cover BN254, extension-field witnesses, genuine base-to-extension openings, and claim binding. diff --git a/provekit/common/src/joint_opening.rs b/provekit/common/src/joint_opening.rs deleted file mode 100644 index 355fe0395..000000000 --- a/provekit/common/src/joint_opening.rs +++ /dev/null @@ -1,409 +0,0 @@ -//! Interface for one opening of two ordered witness commitments. -//! -//! ProveKit supplies the two source commitments and forms over their combined -//! domain. WHIR supplies the joint protocol through [`JointOpeningBackend`]. - -use { - crate::{Ext, FieldHash}, - anyhow::{ensure, Result}, - ark_ff::Field, - serde::{Deserialize, Serialize}, - whir::{ - algebra::{embedding::Embedding, linear_form::LinearForm}, - protocols::zook::{ - Commitment, CommittedWitness, FinalClaim, ProtocolConfig as ZookConfig, ProverClaim, - }, - transcript::{ProverState, VerifierState}, - }, -}; - -/// Select separate openings or one joint opening for a two-stage witness. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub enum WitnessOpeningMode { - /// Use the existing opening for each commitment. - #[default] - Separate, - /// Use the joint opening backend for both commitments. - Joint, -} - -/// Dimensions of the virtual witness `[w1 | w2]`. -/// -/// Each source commitment has `block_size` entries. -/// The first evaluation coordinate selects a block. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct JointOpeningLayout { - block_size: usize, -} - -impl JointOpeningLayout { - /// Check the block size and derive the joint dimensions. - pub fn new(block_size: usize) -> Result { - ensure!( - block_size.is_power_of_two(), - "Joint opening block size must be a nonzero power of two" - ); - block_size - .checked_mul(2) - .ok_or_else(|| anyhow::anyhow!("Joint opening domain size exceeds usize"))?; - Ok(Self { block_size }) - } - - /// Return the size of each source commitment. - pub const fn block_size(self) -> usize { - self.block_size - } - - /// Return the combined form size. - pub const fn size(self) -> usize { - self.block_size * 2 - } - - /// Return the evaluation point length, including the selector. - pub const fn num_variables(self) -> usize { - self.block_size.trailing_zeros() as usize + 1 - } - - /// Check source dimensions and the existing source configuration - /// invariants. - /// - /// This check does not establish security for the joint protocol. - /// The backend must also validate its joint configuration. - pub fn validate_source_config(self, source: &ZookConfig) -> Result<()> { - ensure!( - source.tuning().vector_size == self.block_size, - "Joint opening source size {} does not match block size {}", - source.tuning().vector_size, - self.block_size - ); - let first = source - .first_round() - .ok_or_else(|| anyhow::anyhow!("Joint opening requires a first code-switch round"))?; - ensure!( - first.code_switch().source().vector_size() == self.block_size, - "Joint opening first-round source size does not match the witness block" - ); - source - .validate() - .map_err(|error| anyhow::anyhow!("Invalid joint opening source configuration: {error}")) - } - - /// Check that every value has one form over the combined domain. - pub fn validate_claims( - self, - forms: &[&dyn LinearForm], - values: &[F], - ) -> Result<()> { - ensure!( - !forms.is_empty(), - "Joint opening requires at least one claim" - ); - ensure!( - forms.len() == values.len(), - "Joint opening form count {} does not match value count {}", - forms.len(), - values.len() - ); - for (index, form) in forms.iter().enumerate() { - ensure!( - form.size() == self.size(), - "Joint opening form {index} has size {}, expected {}", - form.size(), - self.size() - ); - } - Ok(()) - } - - /// Check the complete evaluation point before evaluating combined forms. - pub fn validate_point(self, point: &[F]) -> Result<()> { - ensure!( - point.len() == self.num_variables(), - "Joint opening point has {} coordinates, expected {}", - point.len(), - self.num_variables() - ); - Ok(()) - } - - /// Check the dimensions of the backend prover result. - pub fn validate_prover_claim( - self, - claim: &ProverClaim, - num_claims: usize, - ) -> Result<()> { - self.validate_point(&claim.evaluation_point)?; - self.validate_coefficients(claim.rlc_coefficients.len(), num_claims) - } - - /// Check the backend verifier result before its final form check. - pub fn validate_final_claim( - self, - claim: &FinalClaim, - num_claims: usize, - ) -> Result<()> { - self.validate_point(&claim.evaluation_point)?; - self.validate_coefficients(claim.rlc_coefficients.len(), num_claims) - } - - fn validate_coefficients(self, actual: usize, expected: usize) -> Result<()> { - ensure!( - actual == expected, - "Joint opening coefficient count {actual} does not match claim count {expected}" - ); - Ok(()) - } -} - -/// Backend contract for one proof over two existing commitments. -/// -/// Array order fixes the selector: index 0 selects `w1`, and index 1 selects -/// `w2`. The backend must bind both commitments and all supplied form values. -/// It must sample shared claim coefficients after binding every claim value. -/// Public data or prior transcript messages must fix the forms before sampling. -/// It must preserve the source configuration's commitment mode. -/// It must return the complete point, with the selector coordinate first. -/// It must bind any derived joint configuration into the transcript. -/// Source configuration security alone does not establish joint protocol -/// security. Callers must validate the result dimensions and finish the -/// verifier's final form check. -pub trait JointOpeningBackend { - /// Check availability, source invariants, and security of the derived joint - /// configuration. - /// - /// Call this method before committing or changing the transcript. - fn validate(&self, source: &ZookConfig, layout: JointOpeningLayout) - -> Result<()>; - - /// Prove the supplied claims against the two ordered source witnesses. - #[allow(clippy::too_many_arguments)] - fn prove( - &self, - transcript: &mut ProverState, - source: &ZookConfig, - layout: JointOpeningLayout, - witnesses: [CommittedWitness; 2], - forms: &[&dyn LinearForm>], - values: &[Ext

], - ) -> Result>>; - - /// Verify the supplied claims against the two ordered source commitments. - #[allow(clippy::too_many_arguments)] - fn verify( - &self, - transcript: &mut VerifierState<'_, P::Sponge>, - source: &ZookConfig, - layout: JointOpeningLayout, - commitments: [Commitment; 2], - forms: &[&dyn LinearForm>], - values: &[Ext

], - ) -> Result>>; -} - -/// Explicit rejection until WHIR supplies a joint opening implementation. -#[derive(Debug, Clone, Copy, Default)] -pub struct UnavailableJointOpening; - -impl JointOpeningBackend

for UnavailableJointOpening { - fn validate( - &self, - source: &ZookConfig, - layout: JointOpeningLayout, - ) -> Result<()> { - layout.validate_source_config(source)?; - Err(joint_opening_unavailable()) - } - - fn prove( - &self, - _transcript: &mut ProverState, - source: &ZookConfig, - layout: JointOpeningLayout, - _witnesses: [CommittedWitness; 2], - forms: &[&dyn LinearForm>], - values: &[Ext

], - ) -> Result>> { - layout.validate_source_config(source)?; - layout.validate_claims(forms, values)?; - Err(joint_opening_unavailable()) - } - - fn verify( - &self, - _transcript: &mut VerifierState<'_, P::Sponge>, - source: &ZookConfig, - layout: JointOpeningLayout, - _commitments: [Commitment; 2], - forms: &[&dyn LinearForm>], - values: &[Ext

], - ) -> Result>> { - layout.validate_source_config(source)?; - layout.validate_claims(forms, values)?; - Err(joint_opening_unavailable()) - } -} - -fn joint_opening_unavailable() -> anyhow::Error { - anyhow::anyhow!("Joint witness opening is unavailable in the pinned WHIR dependency") -} - -#[cfg(test)] -mod tests { - use { - super::*, - crate::PrefixCovector, - whir::{ - algebra::{ - embedding::{Basefield, Identity}, - fields::{Field64, Field64_3}, - }, - protocols::params::{ - DecodingRegime, FoldingFactor, Mode, PowBudget, RateSchedule, SecuritySpec, - TuningSpec, - }, - }, - }; - - fn field(value: u64) -> Field64 { - Field64::from(value) - } - - #[test] - fn two_blocks_of_four_need_eight_entries_and_three_coordinates() { - let layout = JointOpeningLayout::new(4).unwrap(); - assert_eq!(layout.block_size(), 4); - assert_eq!(layout.size(), 8); - assert_eq!(layout.num_variables(), 3); - } - - #[test] - fn a_single_entry_block_still_needs_a_selector() { - let layout = JointOpeningLayout::new(1).unwrap(); - assert_eq!(layout.size(), 2); - assert_eq!(layout.num_variables(), 1); - } - - #[test] - fn zero_and_non_power_of_two_blocks_are_rejected() { - for block_size in [0, 3, 6] { - assert!(JointOpeningLayout::new(block_size).is_err()); - } - } - - #[test] - fn an_overflowing_joint_domain_is_rejected() { - let largest_power_of_two = 1usize << (usize::BITS - 1); - assert!(JointOpeningLayout::new(largest_power_of_two).is_err()); - } - - #[test] - fn the_source_configuration_must_match_each_block() { - let source = ZookConfig::>::derive( - SecuritySpec { - mode: Mode::Standard, - decoding_regime: DecodingRegime::Johnson, - target_security_bits: 40, - pow_budget: PowBudget::per_slot(10), - hash_id: whir::hash::BLAKE3, - }, - TuningSpec { - vector_size: 32, - starting_log_inv_rate: 1, - folding_factor: FoldingFactor::Constant(2), - rate_schedule: RateSchedule::Stepping, - }, - ) - .unwrap(); - - assert!(JointOpeningLayout::new(32) - .unwrap() - .validate_source_config(&source) - .is_ok()); - assert!(JointOpeningLayout::new(4) - .unwrap() - .validate_source_config(&source) - .is_err()); - } - - fn extension_source(mode: Mode, size: usize) -> ZookConfig> { - ZookConfig::derive(SecuritySpec::new(128).with_mode(mode), TuningSpec { - vector_size: size, - starting_log_inv_rate: 2, - folding_factor: FoldingFactor::Constant(2), - rate_schedule: RateSchedule::Stepping, - }) - .unwrap() - } - - #[test] - fn both_modes_accept_extension_fields_at_128_bits() { - for mode in [Mode::Standard, Mode::ZeroKnowledge] { - let source = extension_source(mode, 32); - JointOpeningLayout::new(32) - .unwrap() - .validate_source_config(&source) - .unwrap(); - assert_eq!(source.security().mode, mode); - } - } - - #[test] - fn a_source_without_a_code_switch_fails_preflight() { - let source = extension_source(Mode::Standard, 8); - let error = JointOpeningLayout::new(8) - .unwrap() - .validate_source_config(&source) - .unwrap_err(); - assert!(error.to_string().contains("first code-switch round")); - } - - #[test] - fn each_claim_needs_a_value_and_an_eight_entry_form() { - let layout = JointOpeningLayout::new(4).unwrap(); - let correct = PrefixCovector::new(vec![field(2), field(3)], 8); - let source_only = PrefixCovector::new(vec![field(2), field(3)], 4); - - assert!(layout.validate_claims(&[&correct], &[field(11)]).is_ok()); - assert!(layout.validate_claims(&[&correct], &[]).is_err()); - assert!(layout - .validate_claims(&[&source_only], &[field(11)]) - .is_err()); - assert!(layout.validate_claims::(&[], &[]).is_err()); - } - - #[test] - fn a_point_without_the_selector_is_rejected() { - let layout = JointOpeningLayout::new(4).unwrap(); - assert!(layout.validate_point(&[field(2), field(3)]).is_err()); - assert!(layout - .validate_point(&[field(2), field(3), field(5)]) - .is_ok()); - assert!(layout - .validate_point(&[field(2), field(3), field(5), field(7)]) - .is_err()); - } - - #[test] - fn backend_results_need_one_coefficient_for_each_claim() { - let layout = JointOpeningLayout::new(4).unwrap(); - let prover = ProverClaim { - evaluation_point: vec![field(2), field(3), field(5)], - rlc_coefficients: vec![field(1), field(7)], - }; - let verifier = FinalClaim { - evaluation_point: prover.evaluation_point.clone(), - initial_claim_scale: field(1), - linear_forms_contribution: field(11), - rlc_coefficients: prover.rlc_coefficients.clone(), - }; - assert!(layout.validate_prover_claim(&prover, 2).is_ok()); - assert!(layout.validate_final_claim(&verifier, 2).is_ok()); - assert!(layout.validate_prover_claim(&prover, 3).is_err()); - assert!(layout.validate_final_claim(&verifier, 3).is_err()); - } - - #[test] - fn separate_is_the_default() { - assert_eq!(WitnessOpeningMode::default(), WitnessOpeningMode::Separate); - } -} diff --git a/provekit/common/src/lib.rs b/provekit/common/src/lib.rs index bdc494790..d18a0bd47 100644 --- a/provekit/common/src/lib.rs +++ b/provekit/common/src/lib.rs @@ -8,7 +8,6 @@ pub mod hash_config; mod interner; pub mod joint_claims; pub mod joint_covector; -pub mod joint_opening; mod logging; pub mod prefix_covector; pub mod public_inputs; @@ -28,9 +27,6 @@ pub use { field::{Base, Ext, FieldHash, ProofField}, hash_config::{HashConfig, POSEIDON2, SKYSCRAPER}, joint_covector::JointLinearForm, - joint_opening::{ - JointOpeningBackend, JointOpeningLayout, UnavailableJointOpening, WitnessOpeningMode, - }, logging::log_commit_input, prefix_covector::{OffsetCovector, PrefixCovector, SparseCovector}, public_inputs::{PublicInputs, PublicInputsHash}, @@ -38,6 +34,6 @@ pub use { sparse_matrix::{HydratedSparseMatrix, SparseMatrix}, whir_r1cs::{ whir_protocol_params, ProvekitProof, R1csHash, WhirR1CSProof, WhirR1CSScheme, - WitnessCommitmentMode, MIN_WHIR_NUM_VARIABLES, + WitnessCommitmentMode, WitnessOpeningMode, MIN_WHIR_NUM_VARIABLES, }, }; diff --git a/provekit/common/src/whir_r1cs.rs b/provekit/common/src/whir_r1cs.rs index 911c48d9c..f1dee6124 100644 --- a/provekit/common/src/whir_r1cs.rs +++ b/provekit/common/src/whir_r1cs.rs @@ -14,8 +14,7 @@ use { crate::{ field::{Base, Ext, FieldHash, ProofField}, utils::{next_power_of_two, serde_hex}, - HashConfig, JointOpeningBackend, JointOpeningLayout, PublicInputs, WitnessOpeningMode, - R1CS, + HashConfig, PublicInputs, R1CS, }, serde::{Deserialize, Serialize}, whir::{ @@ -44,6 +43,14 @@ pub enum WitnessCommitmentMode { ZeroKnowledge, } +/// Select separate openings or one joint opening for a two-stage witness. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum WitnessOpeningMode { + #[default] + Separate, + Joint, +} + impl From for Mode { fn from(mode: WitnessCommitmentMode) -> Self { match mode { @@ -220,14 +227,8 @@ impl WhirR1CSScheme

{ self } - /// Validate the joint backend before starting a two-stage proof or - /// verification. - /// - /// The source configuration retains the original block size. - pub fn validate_joint_backend>( - &self, - backend: &B, - ) -> anyhow::Result<()> { + /// Validate joint opening parameters before proving or verifying. + pub fn validate_joint_opening(&self) -> anyhow::Result<()> { if self.witness_opening == WitnessOpeningMode::Joint && self.num_challenges > 0 { anyhow::ensure!( self.num_challenges == self.challenge_offsets.len(), @@ -246,9 +247,13 @@ impl WhirR1CSScheme

{ .all(|offset| *offset < block_size), "Challenge offset exceeds the second commitment block" ); - let layout = JointOpeningLayout::new(block_size)?; - layout.validate_source_config(&self.whir_witness)?; - backend.validate(&self.whir_witness, layout)?; + anyhow::ensure!( + self.whir_witness.tuning().vector_size == block_size, + "Joint opening source size does not match the witness block" + ); + self.whir_witness + .validate_pair() + .map_err(|_| anyhow::anyhow!("Invalid joint opening parameters"))?; } Ok(()) } diff --git a/provekit/prover/src/lib.rs b/provekit/prover/src/lib.rs index 0981cd7a7..c6e4df7b7 100644 --- a/provekit/prover/src/lib.rs +++ b/provekit/prover/src/lib.rs @@ -1,7 +1,6 @@ mod whir_r1cs; pub use whir_r1cs::{ - prove_from_alphas, prove_from_alphas_ctx, prove_from_alphas_ctx_with_joint_backend, - prove_noir_with_joint_backend, run_zk_sumcheck_prover, ProveFromAlphasCtx, SparkColQueryData, - SparkQueryData, WhirR1CSCommitment, WhirR1CSProver, + prove_from_alphas, prove_from_alphas_ctx, run_zk_sumcheck_prover, ProveFromAlphasCtx, + SparkColQueryData, SparkQueryData, WhirR1CSCommitment, WhirR1CSProver, }; diff --git a/provekit/prover/src/whir_r1cs.rs b/provekit/prover/src/whir_r1cs.rs index 8e6c69f8a..4afe4821a 100644 --- a/provekit/prover/src/whir_r1cs.rs +++ b/provekit/prover/src/whir_r1cs.rs @@ -21,9 +21,8 @@ use { multiply_transposed_by_eq_alpha, sumcheck_fold_map_reduce, transpose_r1cs_matrices, }, }, - Base, Ext, FieldHash, JointOpeningBackend, JointOpeningLayout, PrefixCovector, ProofField, - PublicInputs, UnavailableJointOpening, WhirR1CSProof, WhirR1CSScheme, WitnessOpeningMode, - R1CS, + Base, Ext, FieldHash, PrefixCovector, ProofField, PublicInputs, WhirR1CSProof, + WhirR1CSScheme, WitnessOpeningMode, R1CS, }, whir::{ algebra::{ @@ -189,7 +188,7 @@ where full_witness: Vec>, public_inputs: &PublicInputs>, ) -> Result { - let (proof, _) = prove_noir_with_joint_backend( + let (proof, _) = prove_noir_inner( self, merlin, r1cs, @@ -197,7 +196,6 @@ where full_witness, public_inputs, false, - &UnavailableJointOpening, )?; Ok(proof) } @@ -211,7 +209,7 @@ where full_witness: Vec>, public_inputs: &PublicInputs>, ) -> Result<(WhirR1CSProof, SparkQueryData

)> { - let (proof, queries) = prove_noir_with_joint_backend( + let (proof, queries) = prove_noir_inner( self, merlin, r1cs, @@ -219,7 +217,6 @@ where full_witness, public_inputs, true, - &UnavailableJointOpening, )?; let queries = queries .ok_or_else(|| anyhow::anyhow!("SPARK query data must be produced when requested"))?; @@ -227,12 +224,8 @@ where } } -/// Prove R1CS satisfaction with an external backend for joint witness openings. -/// -/// Separate and single-stage openings use the existing zook implementation. #[instrument(skip_all)] -#[allow(clippy::too_many_arguments)] -pub fn prove_noir_with_joint_backend>( +fn prove_noir_inner( scheme: &WhirR1CSScheme

, mut merlin: ProverState, r1cs: R1CS>, @@ -240,13 +233,12 @@ pub fn prove_noir_with_joint_backend>( full_witness: Vec>, public_inputs: &PublicInputs>, produce_spark_query: bool, - backend: &B, ) -> Result<(WhirR1CSProof, Option>)> where Standard: Distribution> + Distribution>, { scheme.validate_witness_commitment_count(commitments.len())?; - scheme.validate_joint_backend(backend)?; + scheme.validate_joint_opening()?; let (a, b, c) = calculate_witness_bounds(&r1cs, &full_witness); drop(full_witness); @@ -276,7 +268,7 @@ where let alphas = multiply_transposed_by_eq_alpha(&embedding, &at, &bt, &ct, &alpha, &r1cs); let blinding_weights = expand_powers::<4, _>(&alpha); - prove_from_alphas_ctx_with_joint_backend( + prove_from_alphas_ctx( scheme, merlin, ProveFromAlphasCtx { @@ -287,7 +279,6 @@ where spark_row: produce_spark_query.then_some(alpha), }, public_inputs, - backend, ) } @@ -332,40 +323,16 @@ where #[instrument(skip_all)] pub fn prove_from_alphas_ctx( - scheme: &WhirR1CSScheme

, - merlin: ProverState, - ctx: ProveFromAlphasCtx

, - public_inputs: &PublicInputs>, -) -> Result<(WhirR1CSProof, Option>)> -where - Standard: Distribution> + Distribution>, -{ - prove_from_alphas_ctx_with_joint_backend( - scheme, - merlin, - ctx, - public_inputs, - &UnavailableJointOpening, - ) -} - -/// Open post-sumcheck claims with an external joint backend. -/// -/// Noir and Mavros share this boundary. The scheme selects the opening -/// protocol. -#[instrument(skip_all)] -pub fn prove_from_alphas_ctx_with_joint_backend>( scheme: &WhirR1CSScheme

, mut merlin: ProverState, ctx: ProveFromAlphasCtx

, public_inputs: &PublicInputs>, - backend: &B, ) -> Result<(WhirR1CSProof, Option>)> where Standard: Distribution> + Distribution>, { scheme.validate_witness_commitment_count(ctx.commitments.len())?; - scheme.validate_joint_backend(backend)?; + scheme.validate_joint_opening()?; let ProveFromAlphasCtx { alphas, blinding_eval, @@ -454,7 +421,6 @@ where } }) } else if scheme.witness_opening == WitnessOpeningMode::Joint { - let layout = JointOpeningLayout::new(scheme.domain_size())?; let [c1, c2]: [WhirR1CSCommitment

; 2] = commitments .try_into() .map_err(|_| anyhow::anyhow!("Joint opening requires two commitments"))?; @@ -488,19 +454,18 @@ where )); } let form_refs = linear_form_refs(&forms); - layout.validate_claims(&form_refs, &evaluations)?; for evaluation in &evaluations { merlin.prover_message(evaluation); } - let final_claim = backend.prove( - &mut merlin, - &scheme.whir_witness, - layout, - [c1.witness, c2.witness], - &form_refs, - &evaluations, - )?; - layout.validate_prover_claim(&final_claim, forms.len())?; + let final_claim = scheme + .whir_witness + .prove_pair( + &mut merlin, + [c1.witness, c2.witness], + &form_refs, + &evaluations, + ) + .map_err(|_| anyhow::anyhow!("Joint WHIR opening failed"))?; spark_row.map(|row| { let col = final_claim.evaluation_point; SparkQueryData { diff --git a/provekit/verifier/src/lib.rs b/provekit/verifier/src/lib.rs index 8269f3340..24f977cf2 100644 --- a/provekit/verifier/src/lib.rs +++ b/provekit/verifier/src/lib.rs @@ -1,3 +1,3 @@ mod whir_r1cs; -pub use whir_r1cs::{verify_with_joint_backend, WhirR1CSVerifier}; +pub use whir_r1cs::WhirR1CSVerifier; diff --git a/provekit/verifier/src/whir_r1cs.rs b/provekit/verifier/src/whir_r1cs.rs index eafaf3ac6..b8f40d727 100644 --- a/provekit/verifier/src/whir_r1cs.rs +++ b/provekit/verifier/src/whir_r1cs.rs @@ -14,8 +14,8 @@ use { utils::sumcheck::{ calculate_eq, eval_cubic_poly, multiply_transposed_by_eq_alpha, transpose_r1cs_matrices, }, - Base, Ext, FieldHash, JointOpeningBackend, JointOpeningLayout, ProofField, PublicInputs, - UnavailableJointOpening, WhirR1CSProof, WhirR1CSScheme, WitnessOpeningMode, R1CS, + Base, Ext, FieldHash, ProofField, PublicInputs, WhirR1CSProof, WhirR1CSScheme, + WitnessOpeningMode, R1CS, }, tracing::instrument, whir::{ @@ -52,314 +52,294 @@ where public_inputs: &PublicInputs>, r1cs: &R1CS>, ) -> Result<()> { - verify_with_joint_backend(self, proof, public_inputs, r1cs, &UnavailableJointOpening) - } -} - -/// Verify R1CS satisfaction with an external backend for joint witness -/// openings. -/// -/// The scheme selects the protocol. The verifier also checks the backend's -/// final linear claim. -#[instrument(skip_all)] -pub fn verify_with_joint_backend>( - scheme: &WhirR1CSScheme

, - proof: &WhirR1CSProof, - public_inputs: &PublicInputs>, - r1cs: &R1CS>, - backend: &B, -) -> Result<()> -where - Standard: Distribution>, -{ - scheme.validate_joint_backend(backend)?; - let actual_r1cs_hash = r1cs.hash(); - anyhow::ensure!( - scheme.r1cs_hash == actual_r1cs_hash, - "R1CS hash mismatch: scheme expects {:?}, got {:?}", - scheme.r1cs_hash, - actual_r1cs_hash - ); + self.validate_joint_opening()?; + let actual_r1cs_hash = r1cs.hash(); + anyhow::ensure!( + self.r1cs_hash == actual_r1cs_hash, + "R1CS hash mismatch: scheme expects {:?}, got {:?}", + self.r1cs_hash, + actual_r1cs_hash + ); - let public_inputs_hash = P::hash_public_inputs(scheme.hash_config, &public_inputs.0); - let instance = P::ext_to_bytes_le(&public_inputs_hash); - let ds = scheme.create_domain_separator().instance(&instance); - let whir_proof = Proof { - narg_string: proof.narg_string.clone(), - hints: proof.hints.clone(), - #[cfg(debug_assertions)] - pattern: proof.pattern.clone(), - }; - let mut arthur = VerifierState::new(&ds, &whir_proof, P::transcript_sponge(scheme.hash_config)); - - let commitment_1 = scheme - .whir_witness - .receive_commitment(&mut arthur) - .map_err(|_| anyhow::anyhow!("Failed to parse commitment 1"))?; - - // Mirror the prover: the ext blinding commitment is absorbed immediately - // after the (w1) witness commitment, before any challenge sampling. - let blinding_commitment = scheme - .whir_blinding - .receive_commitment(&mut arthur) - .map_err(|_| anyhow::anyhow!("Failed to parse blinding commitment"))?; - - let (commitment_2, logup_challenges) = if scheme.num_challenges > 0 { - let challenges: Vec> = arthur.verifier_message_vec(scheme.num_challenges); - let commitment = scheme + let public_inputs_hash = P::hash_public_inputs(self.hash_config, &public_inputs.0); + let instance = P::ext_to_bytes_le(&public_inputs_hash); + let ds = self.create_domain_separator().instance(&instance); + let whir_proof = Proof { + narg_string: proof.narg_string.clone(), + hints: proof.hints.clone(), + #[cfg(debug_assertions)] + pattern: proof.pattern.clone(), + }; + let mut arthur = + VerifierState::new(&ds, &whir_proof, P::transcript_sponge(self.hash_config)); + + let commitment_1 = self .whir_witness .receive_commitment(&mut arthur) - .map_err(|_| anyhow::anyhow!("Failed to parse commitment 2"))?; - (Some(commitment), Some(challenges)) - } else { - (None, None) - }; - let (transposed, sumcheck_result) = rayon::join( - || transpose_r1cs_matrices(r1cs), - || run_sumcheck_verifier::, _>(&mut arthur, scheme.m_0), - ); - let data_from_sumcheck_verifier = sumcheck_result.context("while verifying sumcheck")?; - let (at, bt, ct) = transposed; + .map_err(|_| anyhow::anyhow!("Failed to parse commitment 1"))?; - let public_inputs_hash_buf: Ext

= arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read public inputs hash"))?; - ensure!( - public_inputs_hash_buf == public_inputs_hash, - "Public inputs hash mismatch: expected {:?}, got {:?}", - public_inputs_hash, - public_inputs_hash_buf - ); - let x: Ext

= arthur.verifier_message(); - - let embedding = ::default(); - let alphas = multiply_transposed_by_eq_alpha( - &embedding, - &at, - &bt, - &ct, - &data_from_sumcheck_verifier.alpha, - r1cs, - ); + // Mirror the prover: the ext blinding commitment is absorbed immediately + // after the (w1) witness commitment, before any challenge sampling. + let blinding_commitment = self + .whir_blinding + .receive_commitment(&mut arthur) + .map_err(|_| anyhow::anyhow!("Failed to parse blinding commitment"))?; - let blinding_eval = data_from_sumcheck_verifier.blinding_eval; - let blinding_weights = expand_powers::<4, _>(&data_from_sumcheck_verifier.alpha); - - let (az_at_alpha, bz_at_alpha, cz_at_alpha) = match (scheme.witness_opening, commitment_2) { - (WitnessOpeningMode::Joint, Some(commitment_2)) => { - let layout = JointOpeningLayout::new(scheme.domain_size())?; - let forms = build_joint_forms( - scheme.m, - scheme.w1_size, - alphas, - x, - public_inputs.len(), - &scheme.challenge_offsets, - )?; - let matrix_evals: [Ext

; 3] = [ - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read joint A evaluation"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read joint B evaluation"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read joint C evaluation"))?, - ]; - let mut evaluations = matrix_evals.to_vec(); - if !public_inputs.is_empty() { - let public_eval = arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read joint public evaluation"))?; - verify_public_input_binding(&embedding, public_eval, x, public_inputs)?; - evaluations.push(public_eval); + let (commitment_2, logup_challenges) = if self.num_challenges > 0 { + let challenges: Vec> = arthur.verifier_message_vec(self.num_challenges); + let commitment = self + .whir_witness + .receive_commitment(&mut arthur) + .map_err(|_| anyhow::anyhow!("Failed to parse commitment 2"))?; + (Some(commitment), Some(challenges)) + } else { + (None, None) + }; + let (transposed, sumcheck_result) = rayon::join( + || transpose_r1cs_matrices(r1cs), + || run_sumcheck_verifier::, _>(&mut arthur, self.m_0), + ); + let data_from_sumcheck_verifier = sumcheck_result.context("while verifying sumcheck")?; + let (at, bt, ct) = transposed; + + let public_inputs_hash_buf: Ext

= arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read public inputs hash"))?; + ensure!( + public_inputs_hash_buf == public_inputs_hash, + "Public inputs hash mismatch: expected {:?}, got {:?}", + public_inputs_hash, + public_inputs_hash_buf + ); + let x: Ext

= arthur.verifier_message(); + + let embedding = ::default(); + let alphas = multiply_transposed_by_eq_alpha( + &embedding, + &at, + &bt, + &ct, + &data_from_sumcheck_verifier.alpha, + r1cs, + ); + + let blinding_eval = data_from_sumcheck_verifier.blinding_eval; + let blinding_weights = expand_powers::<4, _>(&data_from_sumcheck_verifier.alpha); + + let (az_at_alpha, bz_at_alpha, cz_at_alpha) = match (self.witness_opening, commitment_2) { + (WitnessOpeningMode::Joint, Some(commitment_2)) => { + let forms = build_joint_forms( + self.m, + self.w1_size, + alphas, + x, + public_inputs.len(), + &self.challenge_offsets, + )?; + let matrix_evals: [Ext

; 3] = [ + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read joint A evaluation"))?, + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read joint B evaluation"))?, + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read joint C evaluation"))?, + ]; + let mut evaluations = matrix_evals.to_vec(); + if !public_inputs.is_empty() { + let public_eval = arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read joint public evaluation"))?; + verify_public_input_binding(&embedding, public_eval, x, public_inputs)?; + evaluations.push(public_eval); + } + if let Some(ref challenges) = logup_challenges { + let challenge_eval = arthur.prover_message().map_err(|_| { + anyhow::anyhow!("Failed to read joint challenge evaluation") + })?; + verify_challenge_binding(challenge_eval, x, challenges)?; + evaluations.push(challenge_eval); + } + let form_refs = linear_form_refs(&forms); + let final_claim = self + .whir_witness + .verify_pair( + &mut arthur, + [commitment_1, commitment_2], + &form_refs, + &evaluations, + ) + .map_err(|_| anyhow::anyhow!("Joint WHIR verification failed"))?; + final_claim + .verify(&form_refs) + .map_err(|_| anyhow::anyhow!("Joint WHIR final-claim check failed"))?; + (matrix_evals[0], matrix_evals[1], matrix_evals[2]) } - if let Some(ref challenges) = logup_challenges { - let challenge_eval = arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read joint challenge evaluation"))?; - verify_challenge_binding(challenge_eval, x, challenges)?; - evaluations.push(challenge_eval); + (_, Some(commitment_2)) => { + let (alphas_1, alphas_2): (Vec<_>, Vec<_>) = alphas + .into_iter() + .map(|mut v| { + let v2 = v.split_off(self.w1_size); + (v, v2) + }) + .unzip(); + let alphas_1: [Vec>; 3] = alphas_1 + .try_into() + .map_err(|_| anyhow::anyhow!("Expected 3 alpha vectors for commitment 1"))?; + let alphas_2: [Vec>; 3] = alphas_2 + .try_into() + .map_err(|_| anyhow::anyhow!("Expected 3 alpha vectors for commitment 2"))?; + + let evals_1: [Ext

; 3] = [ + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read evals_1[0]"))?, + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read evals_1[1]"))?, + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read evals_1[2]"))?, + ]; + let evals_2: [Ext

; 3] = [ + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read evals_2[0]"))?, + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read evals_2[1]"))?, + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read evals_2[2]"))?, + ]; + + let mut weights_1 = build_prefix_covectors(self.m, alphas_1); + let weights_2 = build_prefix_covectors(self.m, alphas_2); + + let evaluations_1 = if !public_inputs.is_empty() { + let public_1: Ext

= arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read public_1"))?; + verify_public_input_binding(&embedding, public_1, x, public_inputs)?; + weights_1.insert(0, make_public_weight(x, public_inputs.len(), self.m)); + vec![public_1, evals_1[0], evals_1[1], evals_1[2]] + } else { + evals_1.to_vec() + }; + let mut evaluations_2 = evals_2.to_vec(); + + // Challenge binding: verify that w2 contains the correct + // Fiat-Shamir challenge values at the expected positions. + let challenge_covector = if let Some(ref challenges) = logup_challenges { + let challenge_eval: Ext

= arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read challenge_eval"))?; + verify_challenge_binding(challenge_eval, x, challenges)?; + let cw = make_challenge_weight(x, &self.challenge_offsets, self.m); + evaluations_2.push(challenge_eval); + Some(cw) + } else { + None + }; + + let weight_refs_1 = linear_form_refs(&weights_1); + + let fc_1 = self + .whir_witness + .verify(&mut arthur, commitment_1, &weight_refs_1, &evaluations_1) + .map_err(|_| anyhow::anyhow!("WHIR verification failed for c1"))?; + fc_1.verify(&weight_refs_1) + .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed for c1"))?; + + let mut weight_refs_2 = linear_form_refs(&weights_2); + if let Some(ref cw) = challenge_covector { + weight_refs_2.push(cw as &dyn LinearForm>); + } + let fc_2 = self + .whir_witness + .verify(&mut arthur, commitment_2, &weight_refs_2, &evaluations_2) + .map_err(|_| anyhow::anyhow!("WHIR verification failed for c2"))?; + fc_2.verify(&weight_refs_2) + .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed for c2"))?; + + ( + evals_1[0] + evals_2[0], + evals_1[1] + evals_2[1], + evals_1[2] + evals_2[2], + ) } - let form_refs = linear_form_refs(&forms); - layout.validate_claims(&form_refs, &evaluations)?; - let final_claim = backend.verify( - &mut arthur, - &scheme.whir_witness, - layout, - [commitment_1, commitment_2], - &form_refs, - &evaluations, - )?; - layout.validate_final_claim(&final_claim, forms.len())?; - final_claim - .verify(&form_refs) - .map_err(|_| anyhow::anyhow!("Joint WHIR final-claim check failed"))?; - (matrix_evals[0], matrix_evals[1], matrix_evals[2]) - } - (_, Some(commitment_2)) => { - let (alphas_1, alphas_2): (Vec<_>, Vec<_>) = alphas - .into_iter() - .map(|mut v| { - let v2 = v.split_off(scheme.w1_size); - (v, v2) - }) - .unzip(); - let alphas_1: [Vec>; 3] = alphas_1 - .try_into() - .map_err(|_| anyhow::anyhow!("Expected 3 alpha vectors for commitment 1"))?; - let alphas_2: [Vec>; 3] = alphas_2 - .try_into() - .map_err(|_| anyhow::anyhow!("Expected 3 alpha vectors for commitment 2"))?; - - let evals_1: [Ext

; 3] = [ - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_1[0]"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_1[1]"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_1[2]"))?, - ]; - let evals_2: [Ext

; 3] = [ - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_2[0]"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_2[1]"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_2[2]"))?, - ]; - - let mut weights_1 = build_prefix_covectors(scheme.m, alphas_1); - let weights_2 = build_prefix_covectors(scheme.m, alphas_2); - - let evaluations_1 = if !public_inputs.is_empty() { - let public_1: Ext

= arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read public_1"))?; - verify_public_input_binding(&embedding, public_1, x, public_inputs)?; - weights_1.insert(0, make_public_weight(x, public_inputs.len(), scheme.m)); - vec![public_1, evals_1[0], evals_1[1], evals_1[2]] - } else { - evals_1.to_vec() - }; - let mut evaluations_2 = evals_2.to_vec(); - - // Challenge binding: verify that w2 contains the correct - // Fiat-Shamir challenge values at the expected positions. - let challenge_covector = if let Some(ref challenges) = logup_challenges { - let challenge_eval: Ext

= arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read challenge_eval"))?; - verify_challenge_binding(challenge_eval, x, challenges)?; - let cw = make_challenge_weight(x, &scheme.challenge_offsets, scheme.m); - evaluations_2.push(challenge_eval); - Some(cw) - } else { - None - }; - - let weight_refs_1 = linear_form_refs(&weights_1); - - let fc_1 = scheme - .whir_witness - .verify(&mut arthur, commitment_1, &weight_refs_1, &evaluations_1) - .map_err(|_| anyhow::anyhow!("WHIR verification failed for c1"))?; - fc_1.verify(&weight_refs_1) - .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed for c1"))?; - - let mut weight_refs_2 = linear_form_refs(&weights_2); - if let Some(ref cw) = challenge_covector { - weight_refs_2.push(cw as &dyn LinearForm>); + (_, None) => { + let evals: [Ext

; 3] = [ + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read evals[0]"))?, + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read evals[1]"))?, + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read evals[2]"))?, + ]; + + let mut weights = build_prefix_covectors(self.m, alphas); + + let evaluations = if !public_inputs.is_empty() { + let public_eval: Ext

= arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read public eval"))?; + verify_public_input_binding(&embedding, public_eval, x, public_inputs)?; + weights.insert(0, make_public_weight(x, public_inputs.len(), self.m)); + vec![public_eval, evals[0], evals[1], evals[2]] + } else { + evals.to_vec() + }; + + let weight_refs = linear_form_refs(&weights); + + let fc = self + .whir_witness + .verify(&mut arthur, commitment_1, &weight_refs, &evaluations) + .map_err(|_| anyhow::anyhow!("WHIR verification failed"))?; + fc.verify(&weight_refs) + .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed"))?; + + (evals[0], evals[1], evals[2]) } - let fc_2 = scheme - .whir_witness - .verify(&mut arthur, commitment_2, &weight_refs_2, &evaluations_2) - .map_err(|_| anyhow::anyhow!("WHIR verification failed for c2"))?; - fc_2.verify(&weight_refs_2) - .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed for c2"))?; - - ( - evals_1[0] + evals_2[0], - evals_1[1] + evals_2[1], - evals_1[2] + evals_2[2], - ) - } - (_, None) => { - let evals: [Ext

; 3] = [ - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals[0]"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals[1]"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals[2]"))?, - ]; - - let mut weights = build_prefix_covectors(scheme.m, alphas); - - let evaluations = if !public_inputs.is_empty() { - let public_eval: Ext

= arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read public eval"))?; - verify_public_input_binding(&embedding, public_eval, x, public_inputs)?; - weights.insert(0, make_public_weight(x, public_inputs.len(), scheme.m)); - vec![public_eval, evals[0], evals[1], evals[2]] - } else { - evals.to_vec() - }; - - let weight_refs = linear_form_refs(&weights); - - let fc = scheme - .whir_witness - .verify(&mut arthur, commitment_1, &weight_refs, &evaluations) - .map_err(|_| anyhow::anyhow!("WHIR verification failed"))?; - fc.verify(&weight_refs) - .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed"))?; - - (evals[0], evals[1], evals[2]) + }; + + // Open the ext blinding commitment: verify `blinding_eval` against the + // sumcheck power covector over the committed blinding vector (g at + // offset 0). Mirrors the prover's single blinding open after all base + // opens. + { + let blind_domain = self.blinding_domain_size(); + let blinding_covector = OffsetCovector::new(blinding_weights, 0, blind_domain); + let fc_b = self + .whir_blinding + .verify(&mut arthur, &[&blinding_commitment], &[blinding_eval]) + .map_err(|_| anyhow::anyhow!("WHIR verification failed for blinding"))?; + fc_b.verify(std::iter::once( + &blinding_covector as &dyn LinearForm>, + )) + .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed for blinding"))?; } - }; - - // Open the ext blinding commitment: verify `blinding_eval` against the - // sumcheck power covector over the committed blinding vector (g at - // offset 0). Mirrors the prover's single blinding open after all base - // opens. - { - let blind_domain = scheme.blinding_domain_size(); - let blinding_covector = OffsetCovector::new(blinding_weights, 0, blind_domain); - let fc_b = scheme - .whir_blinding - .verify(&mut arthur, &[&blinding_commitment], &[blinding_eval]) - .map_err(|_| anyhow::anyhow!("WHIR verification failed for blinding"))?; - fc_b.verify(std::iter::once( - &blinding_covector as &dyn LinearForm>, - )) - .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed for blinding"))?; - } - ensure!( - data_from_sumcheck_verifier.f_at_alpha - == (az_at_alpha * bz_at_alpha - cz_at_alpha) - * calculate_eq( - &data_from_sumcheck_verifier.r, - &data_from_sumcheck_verifier.alpha - ), - "last sumcheck value does not match" - ); + ensure!( + data_from_sumcheck_verifier.f_at_alpha + == (az_at_alpha * bz_at_alpha - cz_at_alpha) + * calculate_eq( + &data_from_sumcheck_verifier.r, + &data_from_sumcheck_verifier.alpha + ), + "last sumcheck value does not match" + ); - arthur - .check_eof() - .map_err(|_| anyhow::anyhow!("Proof contains unparsed trailing bytes")) + arthur + .check_eof() + .map_err(|_| anyhow::anyhow!("Proof contains unparsed trailing bytes")) + } } #[instrument(skip_all)] diff --git a/tooling/provekit-fixtures/Cargo.toml b/tooling/provekit-fixtures/Cargo.toml index 1a18644b1..e2952b27b 100644 --- a/tooling/provekit-fixtures/Cargo.toml +++ b/tooling/provekit-fixtures/Cargo.toml @@ -26,6 +26,7 @@ postcard.workspace = true # Concrete proof fields to instantiate the generic harness. provekit-backend-bn254.workspace = true provekit-backend-goldilocks.workspace = true +provekit-spark.workspace = true inferno.workspace = true tracing.workspace = true tracing-flame.workspace = true diff --git a/tooling/provekit-fixtures/tests/joint_opening.rs b/tooling/provekit-fixtures/tests/joint_opening.rs index 3ca10fa6a..cbddc55a5 100644 --- a/tooling/provekit-fixtures/tests/joint_opening.rs +++ b/tooling/provekit-fixtures/tests/joint_opening.rs @@ -1,641 +1,394 @@ -//! Readable tests for ProveKit's joint opening integration. -//! -//! The recording backend tests data routing only. -//! It does not implement a cryptographic opening and never verifies a proof. +//! Joint opening acceptance tests through the production prover and verifier. use { - anyhow::{bail, Result}, - ark_ff::Zero, - provekit_backend_bn254::{Bn254Field, FieldElement, TranscriptSponge}, + ark_ff::{AdditiveGroup, Field}, + ark_std::rand::distributions::{Distribution, Standard}, + provekit_backend_bn254::{spark::SparkQueryBatch, Bn254Field, FieldElement}, + provekit_backend_goldilocks::{GoldilocksEfField, GoldilocksField}, provekit_common::{ - FieldHash, HashConfig, JointOpeningBackend, JointOpeningLayout, PublicInputs, - PublicInputsHash, UnavailableJointOpening, WhirR1CSProof, WhirR1CSScheme, - WitnessCommitmentMode, WitnessOpeningMode, R1CS, + Base, Ext, FieldHash, HashConfig, JointLinearForm, PrefixCovector, PublicInputs, + PublicInputsHash, WhirR1CSScheme, WitnessCommitmentMode, WitnessOpeningMode, }, - provekit_fixtures::builders::{challenge_with_public_input, multi_challenge_inverses_w2}, - provekit_prover::{ - prove_from_alphas_ctx_with_joint_backend, prove_noir_with_joint_backend, - ProveFromAlphasCtx, WhirR1CSCommitment, WhirR1CSProver, + provekit_fixtures::builders::{ + challenge_with_public_input, multi_challenge_inverses_w2, two_public_inputs, }, - provekit_verifier::verify_with_joint_backend, - std::cell::{Cell, RefCell}, + provekit_prover::{SparkQueryData, WhirR1CSProver}, + provekit_spark::{ + preprocess_spark, SparkMatrix, SparkProverContext, SparkProverScheme, SparkVerifierScheme, + }, + provekit_verifier::WhirR1CSVerifier, whir::{ - algebra::{embedding::Identity, linear_form::LinearForm}, + algebra::{ + embedding::Embedding, + fields::{Field64, Field64_3}, + linear_form::LinearForm, + }, + buffer::Buffer, protocols::{ - params::Mode, - zook::{ - Commitment, CommittedWitness, FinalClaim, ProtocolConfig as ZookConfig, ProverClaim, - }, + params::{FoldingFactor, Mode, PowBudget, RateSchedule, SecuritySpec, TuningSpec}, + zook::ProtocolConfig, + }, + transcript::{ + codecs::Empty, DomainSeparator, Proof, ProverState, VerificationResult, + VerifierMessage, VerifierState, }, - transcript::{ProverState, VerifierMessage, VerifierState}, }, }; -type Scheme = WhirR1CSScheme; -type SourceConfig = ZookConfig>; - -fn field(value: u64) -> FieldElement { - FieldElement::from(value) -} +const HASH: HashConfig = HashConfig::Sha256; -/// The matrix equation is `w[1] * w[3] = w[2]`, or `3 * 4 = 12`. -fn multiplication_r1cs() -> R1CS { - let mut r1cs = R1CS::new(); - r1cs.num_public_inputs = 1; - r1cs.add_witnesses(5); - r1cs.add_constraint(&[(field(1), 1)], &[(field(1), 3)], &[(field(1), 2)]); - r1cs +#[derive(Clone, Copy)] +enum Change { + None, + PublicWitness, + StageChallenge, } -fn scheme(opening_mode: WitnessOpeningMode) -> Scheme { - Scheme::new_for_r1cs( - &multiplication_r1cs(), - 3, - 1, - vec![0], - true, - HashConfig::Sha256, - WitnessCommitmentMode::Standard, - ) - .unwrap() - .with_witness_opening(opening_mode) -} - -fn transcript( - scheme: &Scheme, - public_inputs: &PublicInputs, -) -> ProverState { - let instance = public_inputs.hash_bytes::(scheme.hash_config); +fn check_full_r1cs

(mode: WitnessCommitmentMode, change: Change) -> SparkQueryData

+where + P: FieldHash, + P::Embedding: Embedding>, + Standard: Distribution>, +{ + P::register(); + // The witness is [1, 3 | c, 1/c]. The constraints are 3*1=3 and c*(1/c)=1. + let (mut r1cs, mut left, offsets) = challenge_with_public_input::>(3); + // Eight constraints also meet SPARK's minimum matrix dimensions. + let one = Base::

::ONE; + for _ in 1..4 { + r1cs.add_constraint(&[(one, 1)], &[(one, 0)], &[(one, 1)]); + r1cs.add_constraint(&[(one, 2)], &[(one, 3)], &[(one, 0)]); + } + if matches!(change, Change::PublicWitness) { + left[1] += Base::

::ONE; + } + let public = PublicInputs::from_vec(vec![Base::

::from(3)]); + let scheme = WhirR1CSScheme::

::new_for_r1cs(&r1cs, left.len(), 1, offsets, true, HASH, mode) + .unwrap() + .with_witness_opening(WitnessOpeningMode::Joint); + scheme.validate_joint_opening().unwrap(); + assert_eq!(scheme.whir_witness.security().target_security_bits, 128); + let instance = public.hash_bytes::

(HASH); let ds = scheme.create_domain_separator().instance(&instance); - ProverState::new(&ds, Bn254Field::transcript_sponge(scheme.hash_config)) -} - -fn empty_context() -> ProveFromAlphasCtx { - ProveFromAlphasCtx { - alphas: [vec![], vec![], vec![]], - blinding_eval: field(0), - blinding_weights: vec![], - commitments: vec![], - spark_row: None, + let mut prover = ProverState::new(&ds, P::transcript_sponge(HASH)); + let first = scheme + .commit(&mut prover, 4, r1cs.num_constraints(), left.clone(), true) + .unwrap(); + let mut challenges: Vec> = prover.verifier_message_vec(1); + if matches!(change, Change::StageChallenge) { + challenges[0] += Base::

::ONE; } -} - -/// Commit both real source witnesses in their original order. -/// The fixed second witness tests routing and does not claim challenge -/// consistency. -fn routing_context( - scheme: &Scheme, - merlin: &mut ProverState, -) -> ProveFromAlphasCtx { - let w1 = scheme - .commit(merlin, 5, 1, vec![field(1), field(3), field(12)], true) + let right = multi_challenge_inverses_w2(&challenges, &left).unwrap(); + assert_eq!(right[0] * right[1], Base::

::ONE); + let second = scheme + .commit(&mut prover, 4, r1cs.num_constraints(), right.clone(), false) .unwrap(); - let _stage_challenge: FieldElement = merlin.verifier_message(); - let w2 = scheme - .commit(merlin, 5, 1, vec![field(4), field(9)], false) + let mut full_witness = left; + full_witness.extend(right); + let (proof, queries) = scheme + .prove_noir_with_spark( + prover, + r1cs.clone(), + vec![first, second], + full_witness, + &public, + ) .unwrap(); - ProveFromAlphasCtx { - alphas: [ - vec![field(0), field(1), field(0), field(0), field(0)], - vec![field(0), field(0), field(0), field(1), field(0)], - vec![field(0), field(0), field(1), field(0), field(0)], - ], - // A zero form honestly opens the separate blinding commitment to zero. - blinding_eval: field(0), - blinding_weights: vec![field(0); 4 * scheme.m_0], - commitments: vec![w1, w2], - spark_row: Some(vec![field(7)]), - } -} - -#[derive(Clone, Copy, Default)] -enum RecordingResult { - #[default] - CompletePoint, - MissingSelector, - MissingCoefficient, - Error, -} - -#[derive(Clone, Copy, Default)] -enum VerifierResult { - #[default] - SentinelError, - MissingSelector, - MissingCoefficient, - WrongContribution, -} - -#[derive(Clone)] -struct RecordedOpening { - source_mode: Mode, - source_size: usize, - layout: JointOpeningLayout, - forms: Vec>, - values: Vec, - point: Vec, -} - -/// This backend returns test data, not a cryptographic opening. -#[derive(Default)] -struct RecordingBackend { - calls: Cell, - verifier_calls: Cell, - recorded: RefCell>, - recorded_verification: RefCell>, - result: RecordingResult, - verifier_result: VerifierResult, -} - -impl JointOpeningBackend for RecordingBackend { - fn validate(&self, source: &SourceConfig, layout: JointOpeningLayout) -> Result<()> { - layout.validate_source_config(source) - } - fn prove( - &self, - _transcript: &mut ProverState, - source: &SourceConfig, - layout: JointOpeningLayout, - _witnesses: [CommittedWitness>; 2], - forms: &[&dyn LinearForm], - values: &[FieldElement], - ) -> Result> { - self.calls.set(self.calls.get() + 1); - layout.validate_claims(forms, values)?; - if matches!(self.result, RecordingResult::Error) { - bail!("Recording backend opening error"); - } - - let coefficients = forms - .iter() - .map(|form| { - let mut vector = vec![field(0); layout.size()]; - form.accumulate(&mut vector, field(1)); - vector - }) - .collect(); - let mut point = vec![field(0); layout.num_variables()]; - point[0] = field(2); - if matches!(self.result, RecordingResult::MissingSelector) { - point.remove(0); - } - *self.recorded.borrow_mut() = Some(RecordedOpening { - source_mode: source.security().mode, - source_size: source.tuning().vector_size, - layout, - forms: coefficients, - values: values.to_vec(), - point: point.clone(), - }); - - let mut rlc_coefficients = vec![field(1); forms.len()]; - if matches!(self.result, RecordingResult::MissingCoefficient) { - rlc_coefficients.pop(); - } - Ok(ProverClaim { - evaluation_point: point, - rlc_coefficients, - }) - } - - fn verify( - &self, - _transcript: &mut VerifierState<'_, TranscriptSponge>, - source: &SourceConfig, - layout: JointOpeningLayout, - _commitments: [Commitment; 2], - forms: &[&dyn LinearForm], - values: &[FieldElement], - ) -> Result> { - self.verifier_calls.set(self.verifier_calls.get() + 1); - layout.validate_claims(forms, values)?; - let coefficients = forms - .iter() - .map(|form| { - let mut vector = vec![field(0); layout.size()]; - form.accumulate(&mut vector, field(1)); - vector - }) - .collect(); - let mut point = vec![field(0); layout.num_variables()]; - point[0] = field(2); - *self.recorded_verification.borrow_mut() = Some(RecordedOpening { - source_mode: source.security().mode, - source_size: source.tuning().vector_size, - layout, - forms: coefficients, - values: values.to_vec(), - point: point.clone(), - }); - - if matches!(self.verifier_result, VerifierResult::SentinelError) { - bail!("The recording backend never verifies a proof"); - } - let form_sum: FieldElement = forms.iter().map(|form| form.mle_evaluate(&point)).sum(); - let mut rlc_coefficients = vec![field(1); forms.len()]; - match self.verifier_result { - VerifierResult::MissingSelector => { - point.remove(0); - } - VerifierResult::MissingCoefficient => { - rlc_coefficients.pop(); - } - VerifierResult::WrongContribution => {} - VerifierResult::SentinelError => unreachable!(), + // One matrix query keeps the selector and every source coordinate. + assert_eq!(queries.queries.len(), 1); + assert_eq!(queries.queries[0].col.len(), scheme.m + 1); + let result = scheme.verify(&proof, &public, &r1cs); + match change { + Change::None => result.unwrap(), + Change::PublicWitness | Change::StageChallenge => { + let expected = match change { + Change::PublicWitness => "public", + _ => "challenge", + }; + let error = result.unwrap_err(); + assert!( + format!("{error:#}").to_lowercase().contains(expected), + "{error:#}" + ); + return queries; } - // Every returned final claim deliberately fails a ProveKit check. - Ok(FinalClaim { - evaluation_point: point, - initial_claim_scale: field(1), - linear_forms_contribution: form_sum + field(1), - rlc_coefficients, - }) - } -} - -#[test] -fn opening_mode_survives_scheme_json_and_postcard_round_trips() { - for opening_mode in [WitnessOpeningMode::Separate, WitnessOpeningMode::Joint] { - let original = scheme(opening_mode); - let json = serde_json::to_string(&original).unwrap(); - let from_json: Scheme = serde_json::from_str(&json).unwrap(); - assert_eq!(from_json, original); - - let postcard = postcard::to_allocvec(&original).unwrap(); - let from_postcard: Scheme = postcard::from_bytes(&postcard).unwrap(); - assert_eq!(from_postcard, original); } -} - -#[test] -fn old_json_without_an_opening_mode_is_rejected_explicitly() { - let mut json = serde_json::to_value(scheme(WitnessOpeningMode::Separate)).unwrap(); - json.as_object_mut().unwrap().remove("witness_opening"); - let error = serde_json::from_value::(json).unwrap_err(); - assert!(error.to_string().contains("witness_opening")); -} -#[test] -fn joint_and_separate_modes_produce_different_transcript_challenges() { - let separate = scheme(WitnessOpeningMode::Separate); - let joint = separate + let wrong_public = PublicInputs::from_vec(vec![Base::

::from(4)]); + assert!(scheme.verify(&proof, &wrong_public, &r1cs).is_err()); + let separate = scheme .clone() - .with_witness_opening(WitnessOpeningMode::Joint); - let public = PublicInputs::from_vec(vec![field(3)]); - - // Fiat-Shamir challenges depend on the transcript, not the prover's private - // RNG. - let separate_challenge: FieldElement = transcript(&separate, &public).verifier_message(); - let repeated_challenge: FieldElement = transcript(&separate, &public).verifier_message(); - let joint_challenge: FieldElement = transcript(&joint, &public).verifier_message(); - assert_eq!(separate_challenge, repeated_challenge); - assert_ne!(separate_challenge, joint_challenge); + .with_witness_opening(WitnessOpeningMode::Separate); + assert!(separate.verify(&proof, &public, &r1cs).is_err()); + let mut truncated = proof; + truncated.narg_string.pop(); + assert!(scheme.verify(&truncated, &public, &r1cs).is_err()); + queries } #[test] -fn unavailable_backend_rejects_joint_and_keeps_existing_openings_available() { - let unavailable = UnavailableJointOpening; - let separate = scheme(WitnessOpeningMode::Separate); - assert!(separate.validate_joint_backend(&unavailable).is_ok()); - - let joint = separate.with_witness_opening(WitnessOpeningMode::Joint); - let error = joint.validate_joint_backend(&unavailable).unwrap_err(); - assert!(error - .to_string() - .contains("Joint witness opening is unavailable")); - - let single = Scheme::new_for_r1cs( - &multiplication_r1cs(), - 5, - 0, - vec![], - true, - HashConfig::Sha256, +fn bn254_joint_r1cs_proof_binds_public_inputs_and_stage_challenges() { + for mode in [ WitnessCommitmentMode::Standard, - ) - .unwrap() - .with_witness_opening(WitnessOpeningMode::Joint); - assert!(single.validate_joint_backend(&unavailable).is_ok()); -} - -#[test] -fn an_empty_commitment_list_returns_an_error_before_indexing() { - let backend = RecordingBackend::default(); - let public = PublicInputs::new(); - for mode in [WitnessOpeningMode::Separate, WitnessOpeningMode::Joint] { - let scheme = scheme(mode); - let result = prove_from_alphas_ctx_with_joint_backend( - &scheme, - transcript(&scheme, &public), - empty_context(), - &public, - &backend, - ); - assert!(result.is_err()); + WitnessCommitmentMode::ZeroKnowledge, + ] { + check_spark(check_full_r1cs::(mode, Change::None)); } - assert_eq!(backend.calls.get(), 0); } -#[test] -fn a_two_stage_scheme_rejects_one_or_three_commitments() { - let scheme = scheme(WitnessOpeningMode::Joint); - let public = PublicInputs::from_vec(vec![field(3)]); - let backend = RecordingBackend::default(); - - for count in [1, 3] { - let mut merlin = transcript(&scheme, &public); - let mut ctx = routing_context(&scheme, &mut merlin); - if count == 1 { - ctx.commitments.truncate(1); - } else { - // This duplicate tests count validation before any opening starts. - let second = &ctx.commitments[1]; - ctx.commitments.push(WhirR1CSCommitment { - witness: second.witness.clone(), - polynomial: second.polynomial.clone(), - blinding: None, - }); +fn check_spark(queries: SparkQueryData) { + let n = 1usize << (queries.queries[0].col.len() - 1); + let columns = 2 * n; + let (mut rows, mut cols) = (Vec::new(), Vec::new()); + // SPARK uses the block matrix [A B; 0 C] over the two padded witnesses. + for i in 0..4 { + let a = 2 * i; + let b = a + 1; + for (row, col) in [ + (a, 1), + (b, n), + (a, columns), + (b, columns + n + 1), + (8 + a, columns + 1), + (8 + b, columns), + ] { + rows.push(row); + cols.push(col); } - - let error = - prove_from_alphas_ctx_with_joint_backend(&scheme, merlin, ctx, &public, &backend) - .err() - .expect("A two-stage scheme requires two commitments"); - assert!(format!("{error:#}").contains("commitment"), "{error:#}"); } - assert_eq!(backend.calls.get(), 0); + let mut values = vec![FieldElement::ONE; rows.len()]; + rows.resize(32, 0); + cols.resize(32, 0); + values.resize(32, FieldElement::ZERO); + let matrix = SparkMatrix::new(rows, cols, values, 16, 2 * columns); + let (setup, witnesses) = preprocess_spark(&matrix, HASH); + let context = SparkProverContext { + matrix, + witnesses, + setup, + }; + let scheme = SparkProverScheme { + whir_configs: context.setup.whir_configs.clone(), + matrix_dimensions: context.setup.matrix_dimensions.clone(), + }; + let batch = SparkQueryBatch::from(queries); + let proof = scheme.prove(&context, &batch).unwrap(); + SparkVerifierScheme + .verify(proof, &context.setup, &batch) + .unwrap(); } #[test] -fn joint_pipeline_calls_one_backend_with_three_matrix_and_two_binding_claims() { - let scheme = scheme(WitnessOpeningMode::Joint); - let public = PublicInputs::from_vec(vec![field(3)]); - let backend = RecordingBackend::default(); - let mut merlin = transcript(&scheme, &public); - let ctx = routing_context(&scheme, &mut merlin); - let (_routing_payload, spark) = - prove_from_alphas_ctx_with_joint_backend(&scheme, merlin, ctx, &public, &backend) - .expect("The recording backend checks routing only"); - - assert_eq!(backend.calls.get(), 1); - let recorded = backend.recorded.borrow().as_ref().unwrap().clone(); - let block_size = scheme.domain_size(); - assert_eq!(recorded.source_size, block_size); - assert_eq!(recorded.layout.size(), 2 * block_size); - assert_eq!(recorded.forms.len(), 5); - assert!(recorded - .forms - .iter() - .all(|form| form.len() == 2 * block_size)); - - // A selects 3 from w1. B selects 4 from w2. C selects 12 from w1. - assert_eq!(&recorded.values[..3], &[field(3), field(4), field(12)]); - assert_eq!(recorded.values[0] * recorded.values[1], recorded.values[2]); - assert_eq!(recorded.forms[0][1], field(1)); - assert_eq!(recorded.forms[1][block_size], field(1)); - assert_eq!(recorded.forms[2][2], field(1)); - for (form, selected_index) in recorded.forms[..3].iter().zip([1, block_size, 2]) { - for (index, coefficient) in form.iter().enumerate() { - assert_eq!( - *coefficient, - if index == selected_index { - field(1) - } else { - field(0) - } - ); - } +fn extension_leaf_joint_r1cs_proof_binds_public_inputs_and_stage_challenges() { + // Existing R1CS challenge slots store one field element, so this path uses + // Identity. The next test exercises Basefield directly. + for mode in [ + WitnessCommitmentMode::Standard, + WitnessCommitmentMode::ZeroKnowledge, + ] { + check_full_r1cs::(mode, Change::None); } - - // Public binding gives 1 + 3*x on the first block. - let x = recorded.forms[3][1]; - assert_eq!(recorded.values[3], field(1) + field(3) * x); - assert!(recorded.forms[3][block_size..].iter().all(Zero::is_zero)); - // Challenge binding gives 4 at offset 0 of the second block. - assert_eq!(recorded.values[4], field(4)); - assert!(recorded.forms[4][..block_size].iter().all(Zero::is_zero)); - assert_eq!(recorded.forms[4][block_size], field(1)); - - let spark = spark.expect("SPARK query was requested"); - assert_eq!(spark.row, vec![field(7)]); - assert_eq!(spark.queries.len(), 1); - let query = &spark.queries[0]; - assert_eq!(query.col, recorded.point); - assert_eq!(query.col.len(), scheme.m + 1); - assert_eq!(query.col[0], field(2)); - // At beta=0, A and C are zero. The selector 2 multiplies B's second block. - assert_eq!(query.claimed_a, field(0)); - assert_eq!(query.claimed_b, field(2)); - assert_eq!(query.claimed_c, field(0)); } #[test] -fn joint_pipeline_rejects_backend_points_without_the_selector() { - assert_bad_backend_result(RecordingResult::MissingSelector, "point has"); -} - -#[test] -fn joint_pipeline_rejects_backend_results_with_a_missing_claim_coefficient() { - assert_bad_backend_result(RecordingResult::MissingCoefficient, "coefficient count"); -} - -#[test] -fn joint_pipeline_preserves_backend_opening_errors() { - assert_bad_backend_result(RecordingResult::Error, "Recording backend opening error"); +fn base_field_witnesses_open_genuine_extension_claims_with_provekit_forms() { + for mode in [Mode::Standard, Mode::ZeroKnowledge] { + check_base_field_pair(mode); + } } -fn assert_bad_backend_result(result: RecordingResult, expected_error: &str) { - let scheme = scheme(WitnessOpeningMode::Joint); - let public = PublicInputs::from_vec(vec![field(3)]); - let backend = RecordingBackend { - result, - ..RecordingBackend::default() +fn check_base_field_pair(mode: Mode) { + type P = GoldilocksField; + type F = Field64_3; + P::register(); + let n = 256; + let source = ProtocolConfig::<

::Embedding>::derive( + SecuritySpec::new(128) + .with_mode(mode) + .with_pow_budget(PowBudget::per_slot(10)) + .with_hash(HASH.engine_id()), + TuningSpec { + vector_size: n, + starting_log_inv_rate: 2, + folding_factor: FoldingFactor::Constant(2), + rate_schedule: RateSchedule::Stepping, + }, + ) + .unwrap(); + let e = F::new(Field64::ZERO, Field64::ONE, Field64::ZERO); + let forms = [ + JointLinearForm::new( + PrefixCovector::new([2, 3, 4, 0].map(F::from).to_vec(), n), + PrefixCovector::new([11, 13].map(F::from).to_vec(), n), + ), + JointLinearForm::new( + PrefixCovector::new(vec![F::ZERO, e + F::ONE], n), + PrefixCovector::new(vec![e], n), + ), + ]; + let refs: Vec<&dyn LinearForm> = forms.iter().map(|form| form as _).collect(); + // Left [1,2,3] gives 20. Right [5,7] gives 146. The total is 166. + // The second claim is (e+1)*2 + e*5 = 7e+2, with e outside the base field. + let values = [F::from(166), e * F::from(7) + F::from(2)]; + assert_ne!(values[1].c1, Field64::ZERO); + let mut left = vec![Field64::ZERO; n]; + left[..3].copy_from_slice(&[1, 2, 3].map(Field64::from)); + let mut right = vec![Field64::ZERO; n]; + right[..2].copy_from_slice(&[5, 7].map(Field64::from)); + let ds = DomainSeparator::protocol(&"provekit-joint-forms").instance(&Empty); + let mut prover = ProverState::new(&ds, P::transcript_sponge(HASH)); + let first = source.commit(&mut prover, Buffer::from(left)); + let challenge: F = prover.verifier_message(); + let second = source.commit(&mut prover, Buffer::from(right)); + let result = source + .prove_pair(&mut prover, [first, second], &refs, &values) + .unwrap(); + prover.prover_message(&F::from(987)); + let proof = prover.proof(); + + let verify = |proof: &Proof, values: &[F]| -> VerificationResult<()> { + let mut verifier = VerifierState::new(&ds, proof, P::transcript_sponge(HASH)); + let first = source.receive_commitment(&mut verifier)?; + let received: F = verifier.verifier_message(); + assert_eq!(received, challenge); + let second = source.receive_commitment(&mut verifier)?; + let final_claim = source.verify_pair(&mut verifier, [first, second], &refs, values)?; + assert_eq!(final_claim.evaluation_point, result.evaluation_point); + final_claim.verify(&refs)?; + let trailing: F = verifier.prover_message()?; + assert_eq!(trailing, F::from(987)); + verifier.check_eof() }; - let mut merlin = transcript(&scheme, &public); - let ctx = routing_context(&scheme, &mut merlin); - let error = prove_from_alphas_ctx_with_joint_backend(&scheme, merlin, ctx, &public, &backend) - .err() - .expect("A malformed backend result must fail"); - assert!(format!("{error:#}").contains(expected_error), "{error:#}"); - assert_eq!(backend.calls.get(), 1); + verify(&proof, &values).unwrap(); + let mut false_values = values; + false_values[1] += F::ONE; + assert!(verify(&proof, &false_values).is_err()); } #[test] -fn verifier_rejects_an_unavailable_backend_before_reading_a_proof() { - let scheme = scheme(WitnessOpeningMode::Joint); - let proof = WhirR1CSProof { - narg_string: vec![], - hints: vec![], - #[cfg(debug_assertions)] - pattern: vec![], - }; - let error = verify_with_joint_backend( - &scheme, - &proof, - &PublicInputs::from_vec(vec![field(3)]), - &multiplication_r1cs(), - &UnavailableJointOpening, - ) - .unwrap_err(); - assert!(format!("{error:#}").contains("Joint witness opening is unavailable")); +fn joint_proofs_reject_incorrect_public_witnesses_and_stage_challenges() { + for mode in [ + WitnessCommitmentMode::Standard, + WitnessCommitmentMode::ZeroKnowledge, + ] { + for change in [Change::PublicWitness, Change::StageChallenge] { + check_full_r1cs::(mode, change); + } + } } -/// Produce a complete outer transcript with correct constraint witnesses. -/// The recording backend supplies no cryptographic joint opening payload. -fn full_outer_routing_fixture( - backend: &RecordingBackend, - public_witness: u64, - change_challenge: bool, - witness_mode: WitnessCommitmentMode, -) -> ( - Scheme, - WhirR1CSProof, - PublicInputs, - R1CS, -) { - let (r1cs, mut w1, offsets) = challenge_with_public_input::(3); - w1[1] = field(public_witness); - let public = PublicInputs::from_vec(vec![field(3)]); - let scheme = Scheme::new_for_r1cs( +fn bn254_scheme() -> WhirR1CSScheme { + type P = Bn254Field; + P::register(); + let (r1cs, left, offsets) = challenge_with_public_input::>(3); + WhirR1CSScheme::

::new_for_r1cs( &r1cs, - w1.len(), - offsets.len(), + left.len(), + 1, offsets, true, - HashConfig::Sha256, - witness_mode, + HASH, + WitnessCommitmentMode::Standard, ) .unwrap() - .with_witness_opening(WitnessOpeningMode::Joint); - scheme.validate_joint_backend(backend).unwrap(); - let mut merlin = transcript(&scheme, &public); - let c1 = scheme - .commit( - &mut merlin, - r1cs.num_witnesses(), - r1cs.num_constraints(), - w1.clone(), - true, - ) - .unwrap(); - let mut challenges: Vec = merlin.verifier_message_vec(scheme.num_challenges); - if change_challenge { - challenges[0] += field(1); - } - let w2 = multi_challenge_inverses_w2(&challenges, &w1).unwrap(); - let c2 = scheme - .commit( - &mut merlin, - r1cs.num_witnesses(), - r1cs.num_constraints(), - w2.clone(), - false, - ) - .unwrap(); - let full_witness = w1.into_iter().chain(w2).collect(); - let (routing_payload, spark) = prove_noir_with_joint_backend( - &scheme, - merlin, - r1cs.clone(), - vec![c1, c2], - full_witness, - &public, - false, - backend, - ) - .expect("The recording backend tests the complete outer transcript only"); - assert!(spark.is_none()); - (scheme, routing_payload, public, r1cs) } #[test] -fn both_modes_reach_one_verifier_call_with_the_same_forms_and_values() { - for witness_mode in [ - WitnessCommitmentMode::Standard, - WitnessCommitmentMode::ZeroKnowledge, - ] { - let backend = RecordingBackend::default(); - let (scheme, routing_payload, public, r1cs) = - full_outer_routing_fixture(&backend, 3, false, witness_mode); - let error = verify_with_joint_backend(&scheme, &routing_payload, &public, &r1cs, &backend) - .expect_err("The recording backend never verifies a proof"); - assert!(format!("{error:#}").contains("The recording backend never verifies a proof")); - assert_eq!(backend.calls.get(), 1); - assert_eq!(backend.verifier_calls.get(), 1); - - let prover = backend.recorded.borrow().as_ref().unwrap().clone(); - let verifier = backend - .recorded_verification - .borrow() - .as_ref() - .unwrap() - .clone(); - assert_eq!(prover.source_mode, Mode::from(witness_mode)); - assert_eq!(prover.source_mode, verifier.source_mode); - assert_eq!(prover.layout, verifier.layout); - assert_eq!(prover.source_size, verifier.source_size); - assert_eq!(prover.forms, verifier.forms); - assert_eq!(prover.values, verifier.values); - assert_eq!(verifier.values.len(), 5); +fn opening_mode_is_serialized_and_bound_to_the_transcript() { + type P = Bn254Field; + type Scheme = WhirR1CSScheme

; + let separate = bn254_scheme(); + assert_eq!(separate.witness_opening, WitnessOpeningMode::Separate); + let joint = separate + .clone() + .with_witness_opening(WitnessOpeningMode::Joint); + for original in [&separate, &joint] { + let json = serde_json::to_string(original).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), *original); + let postcard = postcard::to_allocvec(original).unwrap(); + assert_eq!( + postcard::from_bytes::(&postcard).unwrap(), + *original + ); } -} - -#[test] -fn verifier_rejects_a_missing_selector_in_the_backend_final_point() { - assert_bad_verifier_result(VerifierResult::MissingSelector, "point has"); -} - -#[test] -fn verifier_rejects_a_missing_backend_final_claim_coefficient() { - assert_bad_verifier_result(VerifierResult::MissingCoefficient, "coefficient count"); -} - -#[test] -fn verifier_checks_the_combined_forms_at_the_backend_final_point() { - assert_bad_verifier_result( - VerifierResult::WrongContribution, - "Joint WHIR final-claim check failed", - ); -} + let mut old_json = serde_json::to_value(&separate).unwrap(); + old_json.as_object_mut().unwrap().remove("witness_opening"); + assert!(serde_json::from_value::(old_json) + .unwrap_err() + .to_string() + .contains("witness_opening")); -fn assert_bad_verifier_result(verifier_result: VerifierResult, expected_error: &str) { - let backend = RecordingBackend { - verifier_result, - ..RecordingBackend::default() + let public = PublicInputs::from_vec(vec![Base::

::from(3)]); + let instance = public.hash_bytes::

(HASH); + let challenge = |scheme: &Scheme| -> Ext

{ + let ds = scheme.create_domain_separator().instance(&instance); + ProverState::new(&ds, P::transcript_sponge(HASH)).verifier_message() }; - let (scheme, routing_payload, public, r1cs) = - full_outer_routing_fixture(&backend, 3, false, WitnessCommitmentMode::Standard); - let error = verify_with_joint_backend(&scheme, &routing_payload, &public, &r1cs, &backend) - .expect_err("Every test final claim fails a ProveKit check"); - assert!(format!("{error:#}").contains(expected_error), "{error:#}"); - assert_eq!(backend.verifier_calls.get(), 1); + assert_eq!(challenge(&separate), challenge(&separate)); + assert_ne!(challenge(&separate), challenge(&joint)); } #[test] -fn verifier_rejects_a_wrong_public_witness_before_the_joint_backend() { - let backend = RecordingBackend::default(); - let (scheme, routing_payload, public, r1cs) = - full_outer_routing_fixture(&backend, 4, false, WitnessCommitmentMode::Standard); - let error = verify_with_joint_backend(&scheme, &routing_payload, &public, &r1cs, &backend) - .expect_err("The witness contains 4, but the public input is 3"); - assert!( - format!("{error:#}").to_lowercase().contains("public"), - "{error:#}" - ); - assert_eq!(backend.verifier_calls.get(), 0); +fn joint_preflight_checks_source_size_and_challenge_and_commitment_counts() { + let scheme = bn254_scheme().with_witness_opening(WitnessOpeningMode::Joint); + scheme.validate_joint_opening().unwrap(); + scheme.validate_witness_commitment_count(2).unwrap(); + for count in [0, 1, 3] { + assert!(scheme.validate_witness_commitment_count(count).is_err()); + } + let mut wrong_size = scheme.clone(); + wrong_size.m += 1; + assert!(wrong_size + .validate_joint_opening() + .unwrap_err() + .to_string() + .contains("source size")); + let mut wrong_count = scheme; + wrong_count.num_challenges += 1; + assert!(wrong_count + .validate_joint_opening() + .unwrap_err() + .to_string() + .contains("Challenge count")); } #[test] -fn verifier_rejects_a_changed_challenge_before_the_joint_backend() { - let backend = RecordingBackend::default(); - let (scheme, routing_payload, public, r1cs) = - full_outer_routing_fixture(&backend, 3, true, WitnessCommitmentMode::Standard); - let error = verify_with_joint_backend(&scheme, &routing_payload, &public, &r1cs, &backend) - .expect_err("The witness challenge differs from the transcript challenge"); - assert!( - format!("{error:#}").to_lowercase().contains("challenge"), - "{error:#}" - ); - assert_eq!(backend.verifier_calls.get(), 0); +fn joint_mode_keeps_single_stage_proofs_available() { + type P = Bn254Field; + P::register(); + let (r1cs, witness) = two_public_inputs::>(6, 7); + let public = PublicInputs::from_vec(vec![witness[1], witness[2]]); + for mode in [ + WitnessCommitmentMode::Standard, + WitnessCommitmentMode::ZeroKnowledge, + ] { + let scheme = + WhirR1CSScheme::

::new_for_r1cs(&r1cs, witness.len(), 0, vec![], true, HASH, mode) + .unwrap() + .with_witness_opening(WitnessOpeningMode::Joint); + scheme.validate_joint_opening().unwrap(); + let instance = public.hash_bytes::

(HASH); + let ds = scheme.create_domain_separator().instance(&instance); + let mut prover = ProverState::new(&ds, P::transcript_sponge(HASH)); + let committed = scheme + .commit( + &mut prover, + r1cs.num_witnesses(), + r1cs.num_constraints(), + witness.clone(), + true, + ) + .unwrap(); + let proof = scheme + .prove_noir( + prover, + r1cs.clone(), + vec![committed], + witness.clone(), + &public, + ) + .unwrap(); + scheme.verify(&proof, &public, &r1cs).unwrap(); + } } diff --git a/tooling/provekit-fixtures/tests/whir_pair.rs b/tooling/provekit-fixtures/tests/whir_pair.rs deleted file mode 100644 index b9c70cc9a..000000000 --- a/tooling/provekit-fixtures/tests/whir_pair.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! Acceptance tests for the real two-witness WHIR backend. -//! -//! The normal WHIR pin lacks this API. Enable `whir_pair_backend` only with -//! the compatible WHIR branch patched into the workspace. This adapter is an -//! integration fixture, not the default production backend. -#![allow(unexpected_cfgs)] -#![cfg(whir_pair_backend)] - -use { - anyhow::Result, - ark_ff::{AdditiveGroup, Field}, - ark_std::rand::distributions::{Distribution, Standard}, - provekit_backend_bn254::Bn254Field, - provekit_backend_goldilocks::{GoldilocksEfField, GoldilocksField}, - provekit_common::{ - Base, Ext, FieldHash, HashConfig, JointLinearForm, JointOpeningBackend, JointOpeningLayout, - PrefixCovector, PublicInputs, PublicInputsHash, WhirR1CSScheme, WitnessCommitmentMode, - WitnessOpeningMode, - }, - provekit_fixtures::builders::{challenge_with_public_input, multi_challenge_inverses_w2}, - provekit_prover::{prove_noir_with_joint_backend, WhirR1CSProver}, - provekit_verifier::verify_with_joint_backend, - whir::{ - algebra::{ - embedding::Embedding, - fields::{Field64, Field64_3}, - linear_form::LinearForm, - }, - buffer::Buffer, - protocols::{ - params::{FoldingFactor, Mode, PowBudget, RateSchedule, SecuritySpec, TuningSpec}, - zook::{Commitment, CommittedWitness, FinalClaim, ProtocolConfig, ProverClaim}, - }, - transcript::{ - codecs::Empty, DomainSeparator, Proof, ProverState, VerifierMessage, VerifierState, - }, - }, -}; - -const HASH: HashConfig = HashConfig::Sha256; - -struct WhirPairBackend; - -impl JointOpeningBackend

for WhirPairBackend -where - Standard: Distribution>, -{ - fn validate( - &self, - source: &ProtocolConfig, - layout: JointOpeningLayout, - ) -> Result<()> { - layout.validate_source_config(source)?; - source - .validate_pair() - .map_err(|_| anyhow::anyhow!("Invalid pair parameters"))?; - Ok(()) - } - - fn prove( - &self, - transcript: &mut ProverState, - source: &ProtocolConfig, - layout: JointOpeningLayout, - witnesses: [CommittedWitness; 2], - forms: &[&dyn LinearForm>], - values: &[Ext

], - ) -> Result>> { - layout.validate_source_config(source)?; - layout.validate_claims(forms, values)?; - source - .prove_pair(transcript, witnesses, forms, values) - .map_err(|_| anyhow::anyhow!("Pair opening failed")) - } - - fn verify( - &self, - transcript: &mut VerifierState<'_, P::Sponge>, - source: &ProtocolConfig, - layout: JointOpeningLayout, - commitments: [Commitment; 2], - forms: &[&dyn LinearForm>], - values: &[Ext

], - ) -> Result>> { - layout.validate_source_config(source)?; - layout.validate_claims(forms, values)?; - source - .verify_pair(transcript, commitments, forms, values) - .map_err(|_| anyhow::anyhow!("Invalid pair opening")) - } -} - -fn check_full_r1cs

(mode: WitnessCommitmentMode) -where - P: FieldHash, - P::Embedding: Embedding>, - Standard: Distribution>, -{ - P::register(); - // The witness is [1, 3 | c, 1/c]. The constraints are 3*1=3 and c*(1/c)=1. - let (r1cs, left, offsets) = challenge_with_public_input::>(3); - let public = PublicInputs::from_vec(vec![Base::

::from(3)]); - let scheme = WhirR1CSScheme::

::new_for_r1cs(&r1cs, left.len(), 1, offsets, true, HASH, mode) - .unwrap() - .with_witness_opening(WitnessOpeningMode::Joint); - scheme.validate_joint_backend(&WhirPairBackend).unwrap(); - assert_eq!(scheme.whir_witness.security().target_security_bits, 128); - let instance = public.hash_bytes::

(HASH); - let ds = scheme.create_domain_separator().instance(&instance); - let mut prover = ProverState::new(&ds, P::transcript_sponge(HASH)); - let first = scheme - .commit(&mut prover, 4, 2, left.clone(), true) - .unwrap(); - let challenges: Vec> = prover.verifier_message_vec(1); - let right = multi_challenge_inverses_w2(&challenges, &left).unwrap(); - assert_eq!(right[0] * right[1], Base::

::ONE); - let second = scheme - .commit(&mut prover, 4, 2, right.clone(), false) - .unwrap(); - let mut full_witness = left; - full_witness.extend(right); - let (proof, queries) = prove_noir_with_joint_backend( - &scheme, - prover, - r1cs.clone(), - vec![first, second], - full_witness, - &public, - true, - &WhirPairBackend, - ) - .unwrap(); - verify_with_joint_backend(&scheme, &proof, &public, &r1cs, &WhirPairBackend).unwrap(); - - // One combined matrix query keeps the selector and every source coordinate. - let queries = queries.unwrap(); - assert_eq!(queries.queries.len(), 1); - assert_eq!(queries.queries[0].col.len(), scheme.m + 1); - - let wrong_public = PublicInputs::from_vec(vec![Base::

::from(4)]); - assert!( - verify_with_joint_backend(&scheme, &proof, &wrong_public, &r1cs, &WhirPairBackend,) - .is_err() - ); - let mut truncated = proof.clone(); - truncated.narg_string.pop(); - assert!( - verify_with_joint_backend(&scheme, &truncated, &public, &r1cs, &WhirPairBackend,).is_err() - ); -} - -#[test] -fn bn254_joint_r1cs_proof_binds_public_inputs_and_stage_challenges() { - for mode in [ - WitnessCommitmentMode::Standard, - WitnessCommitmentMode::ZeroKnowledge, - ] { - check_full_r1cs::(mode); - } -} - -#[test] -fn extension_leaf_joint_r1cs_proof_binds_public_inputs_and_stage_challenges() { - // Existing R1CS challenge slots store one field element, so this path uses - // Identity. The next test exercises Basefield directly. - for mode in [ - WitnessCommitmentMode::Standard, - WitnessCommitmentMode::ZeroKnowledge, - ] { - check_full_r1cs::(mode); - } -} - -#[test] -fn base_field_witnesses_open_genuine_extension_claims_through_the_adapter() { - for mode in [Mode::Standard, Mode::ZeroKnowledge] { - check_base_field_pair(mode); - } -} - -fn check_base_field_pair(mode: Mode) { - type P = GoldilocksField; - type F = Field64_3; - P::register(); - let n = 256; - let source = ProtocolConfig::<

::Embedding>::derive( - SecuritySpec::new(128) - .with_mode(mode) - .with_pow_budget(PowBudget::per_slot(10)) - .with_hash(HASH.engine_id()), - TuningSpec { - vector_size: n, - starting_log_inv_rate: 2, - folding_factor: FoldingFactor::Constant(2), - rate_schedule: RateSchedule::Stepping, - }, - ) - .unwrap(); - let layout = JointOpeningLayout::new(n).unwrap(); - let e = F::new(Field64::ZERO, Field64::ONE, Field64::ZERO); - let forms = [ - JointLinearForm::new( - PrefixCovector::new([2, 3, 4, 0].map(F::from).to_vec(), n), - PrefixCovector::new([11, 13].map(F::from).to_vec(), n), - ), - JointLinearForm::new( - PrefixCovector::new(vec![F::ZERO, e + F::ONE], n), - PrefixCovector::new(vec![e], n), - ), - ]; - let refs: Vec<&dyn LinearForm> = forms.iter().map(|form| form as _).collect(); - // Left [1,2,3] gives 20. Right [5,7] gives 146. The total is 166. - // The second claim is (e+1)*2 + e*5 = 7e+2, with e outside the base field. - let values = [F::from(166), e * F::from(7) + F::from(2)]; - assert_ne!(values[1].c1, Field64::ZERO); - let mut left = vec![Field64::ZERO; n]; - left[..3].copy_from_slice(&[1, 2, 3].map(Field64::from)); - let mut right = vec![Field64::ZERO; n]; - right[..2].copy_from_slice(&[5, 7].map(Field64::from)); - let ds = DomainSeparator::protocol(&"provekit-real-pair-adapter").instance(&Empty); - let mut prover = ProverState::new(&ds, P::transcript_sponge(HASH)); - let first = source.commit(&mut prover, Buffer::from(left)); - let challenge: F = prover.verifier_message(); - let second = source.commit(&mut prover, Buffer::from(right)); - let result = >::prove( - &WhirPairBackend, - &mut prover, - &source, - layout, - [first, second], - &refs, - &values, - ) - .unwrap(); - prover.prover_message(&F::from(987)); - let proof = prover.proof(); - - let verify = |proof: &Proof, values: &[F]| -> Result<()> { - let mut verifier = VerifierState::new(&ds, proof, P::transcript_sponge(HASH)); - let first = source - .receive_commitment(&mut verifier) - .map_err(|_| anyhow::anyhow!("first commitment"))?; - let received: F = verifier.verifier_message(); - assert_eq!(received, challenge); - let second = source - .receive_commitment(&mut verifier) - .map_err(|_| anyhow::anyhow!("second commitment"))?; - let final_claim = >::verify( - &WhirPairBackend, - &mut verifier, - &source, - layout, - [first, second], - &refs, - values, - )?; - layout.validate_final_claim(&final_claim, forms.len())?; - assert_eq!(final_claim.evaluation_point, result.evaluation_point); - final_claim - .verify(&refs) - .map_err(|_| anyhow::anyhow!("final form check"))?; - let trailing: F = verifier - .prover_message() - .map_err(|_| anyhow::anyhow!("trailing value"))?; - assert_eq!(trailing, F::from(987)); - verifier - .check_eof() - .map_err(|_| anyhow::anyhow!("trailing bytes"))?; - Ok(()) - }; - verify(&proof, &values).unwrap(); - let mut false_values = values; - false_values[1] += F::ONE; - assert!(verify(&proof, &false_values).is_err()); -} From 81e0d5132df50086f8496a2b2d1151305a8d0233 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 13 Oct 2026 16:25:45 +0200 Subject: [PATCH 09/12] feat(cli): select joint witness opening during prepare --- provekit/common/JOINT_OPENING.md | 2 +- tooling/cli/src/cmd/prepare.rs | 24 +++++++++++++++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/provekit/common/JOINT_OPENING.md b/provekit/common/JOINT_OPENING.md index 80be1fc3f..2fdf6e266 100644 --- a/provekit/common/JOINT_OPENING.md +++ b/provekit/common/JOINT_OPENING.md @@ -1,6 +1,6 @@ # Joint witness opening -Set `WitnessOpeningMode::Joint` before creating the transcript. +Select `prepare --joint-opening`, or set `WitnessOpeningMode::Joint` before creating the transcript. Separate opening remains the default. Single-stage witnesses use their existing opening path. The normal prover and verifier call WHIR `prove_pair` and `verify_pair` directly. diff --git a/tooling/cli/src/cmd/prepare.rs b/tooling/cli/src/cmd/prepare.rs index 639e8018f..af7433c91 100644 --- a/tooling/cli/src/cmd/prepare.rs +++ b/tooling/cli/src/cmd/prepare.rs @@ -13,7 +13,8 @@ use { noirc_driver::{CompilationResult, CompileOptions, CrateName, NOIR_ARTIFACT_VERSION_STRING}, provekit_backend_bn254::{FieldElement, NoirProofScheme, Prover, SparkSetup, Verifier}, provekit_common::{ - file::write, utils::next_power_of_two, HashConfig, WitnessCommitmentMode, R1CS, + file::write, utils::next_power_of_two, HashConfig, WitnessCommitmentMode, + WitnessOpeningMode, R1CS, }, provekit_r1cs_compiler::{MavrosCompiler, NoirCompiler}, provekit_spark::SparkMatrix, @@ -116,6 +117,10 @@ pub struct Args { #[argh(option, long = "witness-mode", default = "String::from(\"zk\")")] witness_mode: String, + /// use one joint opening for two-stage witnesses + #[argh(switch, long = "joint-opening")] + joint_opening: bool, + /// also run SPARK preprocessing; the setup is folded into the PKV and the /// prover context is written to `--spctx` #[argh(switch, long = "spark")] @@ -212,8 +217,9 @@ impl Args { )?; for (package, artifact) in binary_packages.iter().zip(artifacts) { - let scheme = NoirCompiler::from_program(artifact, hash_config, witness_mode) + let mut scheme = NoirCompiler::from_program(artifact, hash_config, witness_mode) .context("while building Noir proof scheme")?; + self.set_witness_opening(&mut scheme)?; let spark_setup = self.maybe_build_spark(&scheme, hash_config)?; let pkp_path = self .pkp_path @@ -241,9 +247,10 @@ impl Args { .r1cs_path .as_ref() .context("--r1cs is required when using the mavros compiler")?; - let scheme = + let mut scheme = MavrosCompiler::compile(&self.program_path, r1cs_path, hash_config, witness_mode) .context("while compiling with Mavros")?; + self.set_witness_opening(&mut scheme)?; let spark_setup = self.maybe_build_spark(&scheme, hash_config)?; let pkp_path = resolve_key_path(self.pkp_path.as_deref(), "pkp")?; let pkv_path = resolve_key_path(self.pkv_path.as_deref(), "pkv")?; @@ -255,6 +262,17 @@ impl Args { Ok(()) } + fn set_witness_opening(&self, scheme: &mut NoirProofScheme) -> Result<()> { + let whir = match scheme { + NoirProofScheme::Noir(s) => &mut s.whir_for_witness, + NoirProofScheme::Mavros(s) => &mut s.whir_for_witness, + }; + if self.joint_opening { + whir.witness_opening = WitnessOpeningMode::Joint; + } + whir.validate_joint_opening() + } + /// Run SPARK preprocessing if `--spark` is set: write the prover context /// (`.spctx`) and return the [`SparkSetup`] for the caller to fold into the /// PKV. The verifier consumes the setup from the trusted PKV; there is no From 38d94aeba5ab7142282e0a591f2b783394b772b2 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 13 Oct 2026 16:27:01 +0200 Subject: [PATCH 10/12] chore: remove joint opening documentation --- provekit/common/JOINT_OPENING.md | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 provekit/common/JOINT_OPENING.md diff --git a/provekit/common/JOINT_OPENING.md b/provekit/common/JOINT_OPENING.md deleted file mode 100644 index 2fdf6e266..000000000 --- a/provekit/common/JOINT_OPENING.md +++ /dev/null @@ -1,26 +0,0 @@ -# Joint witness opening - -Select `prepare --joint-opening`, or set `WitnessOpeningMode::Joint` before creating the transcript. -Separate opening remains the default. Single-stage witnesses use their existing opening path. - -The normal prover and verifier call WHIR `prove_pair` and `verify_pair` directly. -Both commitments keep their original encoding and order. -Forms describe the padded witness `[w1 | w2]`. -A, B, and C remain separate claims; each adds its two witness-stage contributions. -Public inputs bind the first block. Stage challenges bind the second block. -WHIR binds the claim values before sampling shared coefficients. -The verifier completes `FinalClaim::verify`. -SPARK receives one query with the witness selector first. -The separate Spartan blinding opening follows the witness opening. - -Standard and ZeroKnowledge commitment modes both support joint opening. -The serialized scheme binds the opening mode. Affected binary formats use major version `3`. -Build against the matching WHIR joint-opening branch. - -```sh -cargo test --locked -p provekit-common joint_ --lib -cargo test --locked -p provekit-fixtures --test joint_opening -``` - -Tests use the normal APIs without a custom backend or build configuration. -They cover BN254, extension-field witnesses, genuine base-to-extension openings, and claim binding. From 2d238b606a5419a3bb67a3687d8099f35e8c0bbb Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Tue, 13 Oct 2026 16:41:38 +0200 Subject: [PATCH 11/12] refactor(proof): always open two-stage witnesses jointly --- Cargo.lock | 2 - provekit/common/src/lib.rs | 2 +- provekit/common/src/prefix_covector.rs | 18 +- provekit/common/src/whir_r1cs.rs | 31 +- provekit/prover/src/whir_r1cs.rs | 155 +--------- provekit/verifier/src/whir_r1cs.rs | 267 ++++++------------ tooling/cli/src/cmd/prepare.rs | 24 +- tooling/provekit-fixtures/Cargo.toml | 2 - .../provekit-fixtures/tests/joint_opening.rs | 53 +--- 9 files changed, 106 insertions(+), 448 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index afb1ba822..3eea48827 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4652,14 +4652,12 @@ dependencies = [ "ark-ff", "ark-std", "inferno", - "postcard", "provekit-backend-bn254", "provekit-backend-goldilocks", "provekit-common", "provekit-prover", "provekit-spark", "provekit-verifier", - "serde_json", "tracing", "tracing-flame", "tracing-subscriber", diff --git a/provekit/common/src/lib.rs b/provekit/common/src/lib.rs index d18a0bd47..56eed920e 100644 --- a/provekit/common/src/lib.rs +++ b/provekit/common/src/lib.rs @@ -34,6 +34,6 @@ pub use { sparse_matrix::{HydratedSparseMatrix, SparseMatrix}, whir_r1cs::{ whir_protocol_params, ProvekitProof, R1csHash, WhirR1CSProof, WhirR1CSScheme, - WitnessCommitmentMode, WitnessOpeningMode, MIN_WHIR_NUM_VARIABLES, + WitnessCommitmentMode, MIN_WHIR_NUM_VARIABLES, }, }; diff --git a/provekit/common/src/prefix_covector.rs b/provekit/common/src/prefix_covector.rs index cfdb79cd3..313955f02 100644 --- a/provekit/common/src/prefix_covector.rs +++ b/provekit/common/src/prefix_covector.rs @@ -1,7 +1,7 @@ use { ark_ff::{Field, One, Zero}, whir::{ - algebra::{embedding::Embedding, linear_form::LinearForm, mixed_dot, multilinear_extend}, + algebra::{embedding::Embedding, linear_form::LinearForm, multilinear_extend}, buffer::{Buffer, BufferOps}, }, }; @@ -212,22 +212,6 @@ pub fn build_prefix_covectors( .collect() } -/// Compute dot products of alpha vectors against a polynomial without -/// allocating [`PrefixCovector`] weights. Used to write transcript hints -/// before deferring weight construction (saves memory in dual-commit). -#[must_use] -pub fn compute_alpha_evals( - embedding: &M, - polynomial: &Buffer, - alphas: &[Vec; N], -) -> Vec { - let polynomial = polynomial.to_slice(); - alphas - .iter() - .map(|w| mixed_dot(embedding, w, &polynomial[..w.len()])) - .collect() -} - /// Compute the public weight evaluation `⟨[1, x, x², …, x^N], poly[0..=N]⟩` /// without allocating a [`PrefixCovector`]. Covers the R1CS constant at /// position 0 and `num_public_inputs` public input positions. diff --git a/provekit/common/src/whir_r1cs.rs b/provekit/common/src/whir_r1cs.rs index f1dee6124..60975a9e1 100644 --- a/provekit/common/src/whir_r1cs.rs +++ b/provekit/common/src/whir_r1cs.rs @@ -43,14 +43,6 @@ pub enum WitnessCommitmentMode { ZeroKnowledge, } -/// Select separate openings or one joint opening for a two-stage witness. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -pub enum WitnessOpeningMode { - #[default] - Separate, - Joint, -} - impl From for Mode { fn from(mode: WitnessCommitmentMode) -> Self { match mode { @@ -165,11 +157,9 @@ pub struct WhirR1CSScheme { pub num_challenges: usize, pub challenge_offsets: Vec, pub has_public_inputs: bool, - /// Base-field witness commitment, via zook. + /// Base-field witness commitment, via zook. Two-stage witnesses open + /// jointly. pub whir_witness: ZookConfig, - /// Opening protocol for two-stage witnesses. - /// Single-stage witnesses use the existing opening. - pub witness_opening: WitnessOpeningMode, /// Separate ext-field commitment to the Spartan blinding polynomial `g`; /// masking the ext-valued sumcheck rounds needs ext randomness, so `g` /// cannot ride on the base witness commitment. @@ -219,17 +209,9 @@ impl WhirR1CSScheme

{ } impl WhirR1CSScheme

{ - /// Select the opening protocol before constructing a transcript or - /// committing witnesses. - #[must_use] - pub fn with_witness_opening(mut self, mode: WitnessOpeningMode) -> Self { - self.witness_opening = mode; - self - } - /// Validate joint opening parameters before proving or verifying. pub fn validate_joint_opening(&self) -> anyhow::Result<()> { - if self.witness_opening == WitnessOpeningMode::Joint && self.num_challenges > 0 { + if self.num_challenges > 0 { anyhow::ensure!( self.num_challenges == self.challenge_offsets.len(), "Challenge count does not match challenge offsets" @@ -317,7 +299,7 @@ impl WhirR1CSScheme

{ let m = m1_raw.max(m2_raw).max(MIN_WHIR_NUM_VARIABLES); let m_0 = m0_raw.max(MIN_SUMCHECK_NUM_VARIABLES); - Ok(Self { + let scheme = Self { m, m_0, a_num_terms: next_power_of_two(a_num_entries), @@ -326,7 +308,6 @@ impl WhirR1CSScheme

{ hash_config.engine_id(), witness_mode, )?, - witness_opening: WitnessOpeningMode::default(), whir_blinding: Self::new_blinding_config_for_size(m_0, hash_config.engine_id()), w1_size, num_challenges, @@ -334,7 +315,9 @@ impl WhirR1CSScheme

{ has_public_inputs, r1cs_hash: R1csHash::UNSET, hash_config, - }) + }; + scheme.validate_joint_opening()?; + Ok(scheme) } /// Build the zook witness config: commits in `P`'s base field, opens at diff --git a/provekit/prover/src/whir_r1cs.rs b/provekit/prover/src/whir_r1cs.rs index 4afe4821a..3be15554f 100644 --- a/provekit/prover/src/whir_r1cs.rs +++ b/provekit/prover/src/whir_r1cs.rs @@ -9,8 +9,7 @@ use { provekit_common::{ joint_claims::{build_joint_forms, compute_joint_matrix_evals}, prefix_covector::{ - build_prefix_covectors, compute_alpha_evals, compute_challenge_eval, - compute_public_eval, expand_powers, linear_form_refs, make_challenge_weight, + compute_challenge_eval, compute_public_eval, expand_powers, linear_form_refs, make_public_weight, OffsetCovector, }, utils::{ @@ -22,7 +21,7 @@ use { }, }, Base, Ext, FieldHash, PrefixCovector, ProofField, PublicInputs, WhirR1CSProof, - WhirR1CSScheme, WitnessOpeningMode, R1CS, + WhirR1CSScheme, R1CS, }, whir::{ algebra::{ @@ -420,7 +419,7 @@ where }], } }) - } else if scheme.witness_opening == WitnessOpeningMode::Joint { + } else { let [c1, c2]: [WhirR1CSCommitment

; 2] = commitments .try_into() .map_err(|_| anyhow::anyhow!("Joint opening requires two commitments"))?; @@ -478,154 +477,6 @@ where }], } }) - } else { - // Dual commitment path - let mut commitments = commitments.into_iter(); - let c1 = commitments - .next() - .expect("dual-commitment path requires first commitment"); - let c2 = commitments - .next() - .expect("dual-commitment path requires second commitment"); - - let (alphas_1, alphas_2): (Vec<_>, Vec<_>) = alphas - .into_iter() - .map(|mut v| { - let v2 = v.split_off(scheme.w1_size); - (v, v2) - }) - .unzip(); - - let alphas_1: [Vec>; 3] = alphas_1 - .try_into() - .expect("alphas_1 must have exactly 3 elements"); - let alphas_2: [Vec>; 3] = alphas_2 - .try_into() - .expect("alphas_2 must have exactly 3 elements"); - - let evals_1 = compute_alpha_evals(&embedding, &c1.polynomial, &alphas_1); - let evals_2 = compute_alpha_evals(&embedding, &c2.polynomial, &alphas_2); - for eval in &evals_1 { - merlin.prover_message(eval); - } - for eval in &evals_2 { - merlin.prover_message(eval); - } - - let public_1 = if public_inputs_len > 0 { - let p1 = compute_public_eval(&embedding, x, public_inputs_len, &c1.polynomial); - merlin.prover_message(&p1); - Some(p1) - } else { - None - }; - - // Challenge binding: prove that w2 contains the correct Fiat-Shamir - // challenge values at the expected positions. - let challenge_eval = if !scheme.challenge_offsets.is_empty() { - let ce = - compute_challenge_eval(&embedding, x, &scheme.challenge_offsets, &c2.polynomial); - merlin.prover_message(&ce); - Some(ce) - } else { - None - }; - - let WhirR1CSCommitment { witness: w1, .. } = c1; - let WhirR1CSCommitment { witness: w2, .. } = c2; - - let (final_claim_1, claimed_1) = { - let mut weights = build_prefix_covectors(scheme.m, alphas_1); - - // Snapshot the three alpha covectors (A, B, C) before the public - // weight is inserted; they are re-evaluated at the final WHIR - // point to form the SPARK query. - let spark_weights: Option>, usize)>> = spark_row.as_ref().map(|_| { - weights - .iter() - .map(|w| (w.vector().to_vec(), w.size())) - .collect() - }); - - let mut evaluations: Vec> = Vec::new(); - if let Some(pe) = public_1 { - weights.insert(0, make_public_weight(x, public_inputs_len, scheme.m)); - evaluations.push(pe); - } - evaluations.extend_from_slice(&evals_1); - - let form_refs = linear_form_refs(&weights); - - let final_claim = scheme - .whir_witness - .prove(&mut merlin, w1, &form_refs, &evaluations); - - let claimed = - spark_weights.map(|sw| evaluate_spark_weights(sw, &final_claim.evaluation_point)); - (final_claim, claimed) - }; - - let (final_claim_2, claimed_2) = { - let weights = build_prefix_covectors(scheme.m, alphas_2); - - // The first three weights are the alpha covectors (A, B, C); the - // challenge weight, if any, is appended after them. - let spark_weights: Option>, usize)>> = spark_row.as_ref().map(|_| { - weights - .iter() - .map(|w| (w.vector().to_vec(), w.size())) - .collect() - }); - - let mut evaluations: Vec> = evals_2; - - let challenge_covector = challenge_eval.map(|ce| { - evaluations.push(ce); - make_challenge_weight(x, &scheme.challenge_offsets, scheme.m) - }); - - let mut form_refs = linear_form_refs(&weights); - if let Some(ref cw) = challenge_covector { - form_refs.push(cw as &dyn LinearForm>); - } - - let final_claim = scheme - .whir_witness - .prove(&mut merlin, w2, &form_refs, &evaluations); - - let claimed = - spark_weights.map(|sw| evaluate_spark_weights(sw, &final_claim.evaluation_point)); - (final_claim, claimed) - }; - - // The SPARK column axis spans w1 | w2: prefix the per-commitment WHIR - // points with a selector coordinate (0 selects w1, 1 selects w2). - spark_row - .zip(claimed_1.zip(claimed_2)) - .map(|(row, ([a1, b1, c1], [a2, b2, c2]))| { - let mut col_1 = final_claim_1.evaluation_point; - col_1.insert(0, >::zero()); - let query_1 = SparkColQueryData { - col: col_1, - claimed_a: a1, - claimed_b: b1, - claimed_c: c1, - }; - - let mut col_2 = final_claim_2.evaluation_point; - col_2.insert(0, >::one()); - let query_2 = SparkColQueryData { - col: col_2, - claimed_a: a2, - claimed_b: b2, - claimed_c: c2, - }; - - SparkQueryData { - row, - queries: vec![query_1, query_2], - } - }) }; // Open the ext blinding commitment: prove `blinding_eval` is the evaluation diff --git a/provekit/verifier/src/whir_r1cs.rs b/provekit/verifier/src/whir_r1cs.rs index b8f40d727..6040b7e47 100644 --- a/provekit/verifier/src/whir_r1cs.rs +++ b/provekit/verifier/src/whir_r1cs.rs @@ -8,14 +8,13 @@ use { provekit_common::{ joint_claims::build_joint_forms, prefix_covector::{ - build_prefix_covectors, expand_powers, linear_form_refs, make_challenge_weight, - make_public_weight, OffsetCovector, + build_prefix_covectors, expand_powers, linear_form_refs, make_public_weight, + OffsetCovector, }, utils::sumcheck::{ calculate_eq, eval_cubic_poly, multiply_transposed_by_eq_alpha, transpose_r1cs_matrices, }, - Base, Ext, FieldHash, ProofField, PublicInputs, WhirR1CSProof, WhirR1CSScheme, - WitnessOpeningMode, R1CS, + Base, Ext, FieldHash, ProofField, PublicInputs, WhirR1CSProof, WhirR1CSScheme, R1CS, }, tracing::instrument, whir::{ @@ -126,187 +125,91 @@ where let blinding_eval = data_from_sumcheck_verifier.blinding_eval; let blinding_weights = expand_powers::<4, _>(&data_from_sumcheck_verifier.alpha); - let (az_at_alpha, bz_at_alpha, cz_at_alpha) = match (self.witness_opening, commitment_2) { - (WitnessOpeningMode::Joint, Some(commitment_2)) => { - let forms = build_joint_forms( - self.m, - self.w1_size, - alphas, - x, - public_inputs.len(), - &self.challenge_offsets, - )?; - let matrix_evals: [Ext

; 3] = [ - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read joint A evaluation"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read joint B evaluation"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read joint C evaluation"))?, - ]; - let mut evaluations = matrix_evals.to_vec(); - if !public_inputs.is_empty() { - let public_eval = arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read joint public evaluation"))?; - verify_public_input_binding(&embedding, public_eval, x, public_inputs)?; - evaluations.push(public_eval); - } - if let Some(ref challenges) = logup_challenges { - let challenge_eval = arthur.prover_message().map_err(|_| { - anyhow::anyhow!("Failed to read joint challenge evaluation") - })?; - verify_challenge_binding(challenge_eval, x, challenges)?; - evaluations.push(challenge_eval); - } - let form_refs = linear_form_refs(&forms); - let final_claim = self - .whir_witness - .verify_pair( - &mut arthur, - [commitment_1, commitment_2], - &form_refs, - &evaluations, - ) - .map_err(|_| anyhow::anyhow!("Joint WHIR verification failed"))?; - final_claim - .verify(&form_refs) - .map_err(|_| anyhow::anyhow!("Joint WHIR final-claim check failed"))?; - (matrix_evals[0], matrix_evals[1], matrix_evals[2]) + let (az_at_alpha, bz_at_alpha, cz_at_alpha) = if let Some(commitment_2) = commitment_2 { + let forms = build_joint_forms( + self.m, + self.w1_size, + alphas, + x, + public_inputs.len(), + &self.challenge_offsets, + )?; + let matrix_evals: [Ext

; 3] = [ + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read joint A evaluation"))?, + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read joint B evaluation"))?, + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read joint C evaluation"))?, + ]; + let mut evaluations = matrix_evals.to_vec(); + if !public_inputs.is_empty() { + let public_eval = arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read joint public evaluation"))?; + verify_public_input_binding(&embedding, public_eval, x, public_inputs)?; + evaluations.push(public_eval); } - (_, Some(commitment_2)) => { - let (alphas_1, alphas_2): (Vec<_>, Vec<_>) = alphas - .into_iter() - .map(|mut v| { - let v2 = v.split_off(self.w1_size); - (v, v2) - }) - .unzip(); - let alphas_1: [Vec>; 3] = alphas_1 - .try_into() - .map_err(|_| anyhow::anyhow!("Expected 3 alpha vectors for commitment 1"))?; - let alphas_2: [Vec>; 3] = alphas_2 - .try_into() - .map_err(|_| anyhow::anyhow!("Expected 3 alpha vectors for commitment 2"))?; - - let evals_1: [Ext

; 3] = [ - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_1[0]"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_1[1]"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_1[2]"))?, - ]; - let evals_2: [Ext

; 3] = [ - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_2[0]"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_2[1]"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals_2[2]"))?, - ]; - - let mut weights_1 = build_prefix_covectors(self.m, alphas_1); - let weights_2 = build_prefix_covectors(self.m, alphas_2); - - let evaluations_1 = if !public_inputs.is_empty() { - let public_1: Ext

= arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read public_1"))?; - verify_public_input_binding(&embedding, public_1, x, public_inputs)?; - weights_1.insert(0, make_public_weight(x, public_inputs.len(), self.m)); - vec![public_1, evals_1[0], evals_1[1], evals_1[2]] - } else { - evals_1.to_vec() - }; - let mut evaluations_2 = evals_2.to_vec(); - - // Challenge binding: verify that w2 contains the correct - // Fiat-Shamir challenge values at the expected positions. - let challenge_covector = if let Some(ref challenges) = logup_challenges { - let challenge_eval: Ext

= arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read challenge_eval"))?; - verify_challenge_binding(challenge_eval, x, challenges)?; - let cw = make_challenge_weight(x, &self.challenge_offsets, self.m); - evaluations_2.push(challenge_eval); - Some(cw) - } else { - None - }; - - let weight_refs_1 = linear_form_refs(&weights_1); - - let fc_1 = self - .whir_witness - .verify(&mut arthur, commitment_1, &weight_refs_1, &evaluations_1) - .map_err(|_| anyhow::anyhow!("WHIR verification failed for c1"))?; - fc_1.verify(&weight_refs_1) - .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed for c1"))?; - - let mut weight_refs_2 = linear_form_refs(&weights_2); - if let Some(ref cw) = challenge_covector { - weight_refs_2.push(cw as &dyn LinearForm>); - } - let fc_2 = self - .whir_witness - .verify(&mut arthur, commitment_2, &weight_refs_2, &evaluations_2) - .map_err(|_| anyhow::anyhow!("WHIR verification failed for c2"))?; - fc_2.verify(&weight_refs_2) - .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed for c2"))?; - - ( - evals_1[0] + evals_2[0], - evals_1[1] + evals_2[1], - evals_1[2] + evals_2[2], - ) + if let Some(ref challenges) = logup_challenges { + let challenge_eval = arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read joint challenge evaluation"))?; + verify_challenge_binding(challenge_eval, x, challenges)?; + evaluations.push(challenge_eval); } - (_, None) => { - let evals: [Ext

; 3] = [ - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals[0]"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals[1]"))?, - arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read evals[2]"))?, - ]; - - let mut weights = build_prefix_covectors(self.m, alphas); - - let evaluations = if !public_inputs.is_empty() { - let public_eval: Ext

= arthur - .prover_message() - .map_err(|_| anyhow::anyhow!("Failed to read public eval"))?; - verify_public_input_binding(&embedding, public_eval, x, public_inputs)?; - weights.insert(0, make_public_weight(x, public_inputs.len(), self.m)); - vec![public_eval, evals[0], evals[1], evals[2]] - } else { - evals.to_vec() - }; - - let weight_refs = linear_form_refs(&weights); - - let fc = self - .whir_witness - .verify(&mut arthur, commitment_1, &weight_refs, &evaluations) - .map_err(|_| anyhow::anyhow!("WHIR verification failed"))?; - fc.verify(&weight_refs) - .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed"))?; + let form_refs = linear_form_refs(&forms); + let final_claim = self + .whir_witness + .verify_pair( + &mut arthur, + [commitment_1, commitment_2], + &form_refs, + &evaluations, + ) + .map_err(|_| anyhow::anyhow!("Joint WHIR verification failed"))?; + final_claim + .verify(&form_refs) + .map_err(|_| anyhow::anyhow!("Joint WHIR final-claim check failed"))?; + (matrix_evals[0], matrix_evals[1], matrix_evals[2]) + } else { + let evals: [Ext

; 3] = [ + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read evals[0]"))?, + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read evals[1]"))?, + arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read evals[2]"))?, + ]; + + let mut weights = build_prefix_covectors(self.m, alphas); + + let evaluations = if !public_inputs.is_empty() { + let public_eval: Ext

= arthur + .prover_message() + .map_err(|_| anyhow::anyhow!("Failed to read public eval"))?; + verify_public_input_binding(&embedding, public_eval, x, public_inputs)?; + weights.insert(0, make_public_weight(x, public_inputs.len(), self.m)); + vec![public_eval, evals[0], evals[1], evals[2]] + } else { + evals.to_vec() + }; + + let weight_refs = linear_form_refs(&weights); + + let fc = self + .whir_witness + .verify(&mut arthur, commitment_1, &weight_refs, &evaluations) + .map_err(|_| anyhow::anyhow!("WHIR verification failed"))?; + fc.verify(&weight_refs) + .map_err(|_| anyhow::anyhow!("WHIR final-claim check failed"))?; - (evals[0], evals[1], evals[2]) - } + (evals[0], evals[1], evals[2]) }; // Open the ext blinding commitment: verify `blinding_eval` against the diff --git a/tooling/cli/src/cmd/prepare.rs b/tooling/cli/src/cmd/prepare.rs index af7433c91..639e8018f 100644 --- a/tooling/cli/src/cmd/prepare.rs +++ b/tooling/cli/src/cmd/prepare.rs @@ -13,8 +13,7 @@ use { noirc_driver::{CompilationResult, CompileOptions, CrateName, NOIR_ARTIFACT_VERSION_STRING}, provekit_backend_bn254::{FieldElement, NoirProofScheme, Prover, SparkSetup, Verifier}, provekit_common::{ - file::write, utils::next_power_of_two, HashConfig, WitnessCommitmentMode, - WitnessOpeningMode, R1CS, + file::write, utils::next_power_of_two, HashConfig, WitnessCommitmentMode, R1CS, }, provekit_r1cs_compiler::{MavrosCompiler, NoirCompiler}, provekit_spark::SparkMatrix, @@ -117,10 +116,6 @@ pub struct Args { #[argh(option, long = "witness-mode", default = "String::from(\"zk\")")] witness_mode: String, - /// use one joint opening for two-stage witnesses - #[argh(switch, long = "joint-opening")] - joint_opening: bool, - /// also run SPARK preprocessing; the setup is folded into the PKV and the /// prover context is written to `--spctx` #[argh(switch, long = "spark")] @@ -217,9 +212,8 @@ impl Args { )?; for (package, artifact) in binary_packages.iter().zip(artifacts) { - let mut scheme = NoirCompiler::from_program(artifact, hash_config, witness_mode) + let scheme = NoirCompiler::from_program(artifact, hash_config, witness_mode) .context("while building Noir proof scheme")?; - self.set_witness_opening(&mut scheme)?; let spark_setup = self.maybe_build_spark(&scheme, hash_config)?; let pkp_path = self .pkp_path @@ -247,10 +241,9 @@ impl Args { .r1cs_path .as_ref() .context("--r1cs is required when using the mavros compiler")?; - let mut scheme = + let scheme = MavrosCompiler::compile(&self.program_path, r1cs_path, hash_config, witness_mode) .context("while compiling with Mavros")?; - self.set_witness_opening(&mut scheme)?; let spark_setup = self.maybe_build_spark(&scheme, hash_config)?; let pkp_path = resolve_key_path(self.pkp_path.as_deref(), "pkp")?; let pkv_path = resolve_key_path(self.pkv_path.as_deref(), "pkv")?; @@ -262,17 +255,6 @@ impl Args { Ok(()) } - fn set_witness_opening(&self, scheme: &mut NoirProofScheme) -> Result<()> { - let whir = match scheme { - NoirProofScheme::Noir(s) => &mut s.whir_for_witness, - NoirProofScheme::Mavros(s) => &mut s.whir_for_witness, - }; - if self.joint_opening { - whir.witness_opening = WitnessOpeningMode::Joint; - } - whir.validate_joint_opening() - } - /// Run SPARK preprocessing if `--spark` is set: write the prover context /// (`.spctx`) and return the [`SparkSetup`] for the caller to fold into the /// PKV. The verifier consumes the setup from the trusted PKV; there is no diff --git a/tooling/provekit-fixtures/Cargo.toml b/tooling/provekit-fixtures/Cargo.toml index e2952b27b..6d3002366 100644 --- a/tooling/provekit-fixtures/Cargo.toml +++ b/tooling/provekit-fixtures/Cargo.toml @@ -21,8 +21,6 @@ ark-std.workspace = true whir.workspace = true [dev-dependencies] -serde_json.workspace = true -postcard.workspace = true # Concrete proof fields to instantiate the generic harness. provekit-backend-bn254.workspace = true provekit-backend-goldilocks.workspace = true diff --git a/tooling/provekit-fixtures/tests/joint_opening.rs b/tooling/provekit-fixtures/tests/joint_opening.rs index cbddc55a5..969202a42 100644 --- a/tooling/provekit-fixtures/tests/joint_opening.rs +++ b/tooling/provekit-fixtures/tests/joint_opening.rs @@ -7,7 +7,7 @@ use { provekit_backend_goldilocks::{GoldilocksEfField, GoldilocksField}, provekit_common::{ Base, Ext, FieldHash, HashConfig, JointLinearForm, PrefixCovector, PublicInputs, - PublicInputsHash, WhirR1CSScheme, WitnessCommitmentMode, WitnessOpeningMode, + PublicInputsHash, WhirR1CSScheme, WitnessCommitmentMode, }, provekit_fixtures::builders::{ challenge_with_public_input, multi_challenge_inverses_w2, two_public_inputs, @@ -63,9 +63,8 @@ where left[1] += Base::

::ONE; } let public = PublicInputs::from_vec(vec![Base::

::from(3)]); - let scheme = WhirR1CSScheme::

::new_for_r1cs(&r1cs, left.len(), 1, offsets, true, HASH, mode) - .unwrap() - .with_witness_opening(WitnessOpeningMode::Joint); + let scheme = + WhirR1CSScheme::

::new_for_r1cs(&r1cs, left.len(), 1, offsets, true, HASH, mode).unwrap(); scheme.validate_joint_opening().unwrap(); assert_eq!(scheme.whir_witness.security().target_security_bits, 128); let instance = public.hash_bytes::

(HASH); @@ -117,10 +116,6 @@ where let wrong_public = PublicInputs::from_vec(vec![Base::

::from(4)]); assert!(scheme.verify(&proof, &wrong_public, &r1cs).is_err()); - let separate = scheme - .clone() - .with_witness_opening(WitnessOpeningMode::Separate); - assert!(separate.verify(&proof, &public, &r1cs).is_err()); let mut truncated = proof; truncated.narg_string.pop(); assert!(scheme.verify(&truncated, &public, &r1cs).is_err()); @@ -294,44 +289,9 @@ fn bn254_scheme() -> WhirR1CSScheme { .unwrap() } -#[test] -fn opening_mode_is_serialized_and_bound_to_the_transcript() { - type P = Bn254Field; - type Scheme = WhirR1CSScheme

; - let separate = bn254_scheme(); - assert_eq!(separate.witness_opening, WitnessOpeningMode::Separate); - let joint = separate - .clone() - .with_witness_opening(WitnessOpeningMode::Joint); - for original in [&separate, &joint] { - let json = serde_json::to_string(original).unwrap(); - assert_eq!(serde_json::from_str::(&json).unwrap(), *original); - let postcard = postcard::to_allocvec(original).unwrap(); - assert_eq!( - postcard::from_bytes::(&postcard).unwrap(), - *original - ); - } - let mut old_json = serde_json::to_value(&separate).unwrap(); - old_json.as_object_mut().unwrap().remove("witness_opening"); - assert!(serde_json::from_value::(old_json) - .unwrap_err() - .to_string() - .contains("witness_opening")); - - let public = PublicInputs::from_vec(vec![Base::

::from(3)]); - let instance = public.hash_bytes::

(HASH); - let challenge = |scheme: &Scheme| -> Ext

{ - let ds = scheme.create_domain_separator().instance(&instance); - ProverState::new(&ds, P::transcript_sponge(HASH)).verifier_message() - }; - assert_eq!(challenge(&separate), challenge(&separate)); - assert_ne!(challenge(&separate), challenge(&joint)); -} - #[test] fn joint_preflight_checks_source_size_and_challenge_and_commitment_counts() { - let scheme = bn254_scheme().with_witness_opening(WitnessOpeningMode::Joint); + let scheme = bn254_scheme(); scheme.validate_joint_opening().unwrap(); scheme.validate_witness_commitment_count(2).unwrap(); for count in [0, 1, 3] { @@ -354,7 +314,7 @@ fn joint_preflight_checks_source_size_and_challenge_and_commitment_counts() { } #[test] -fn joint_mode_keeps_single_stage_proofs_available() { +fn single_stage_proofs_open_one_witness() { type P = Bn254Field; P::register(); let (r1cs, witness) = two_public_inputs::>(6, 7); @@ -365,8 +325,7 @@ fn joint_mode_keeps_single_stage_proofs_available() { ] { let scheme = WhirR1CSScheme::

::new_for_r1cs(&r1cs, witness.len(), 0, vec![], true, HASH, mode) - .unwrap() - .with_witness_opening(WitnessOpeningMode::Joint); + .unwrap(); scheme.validate_joint_opening().unwrap(); let instance = public.hash_bytes::

(HASH); let ds = scheme.create_domain_separator().instance(&instance); From 512f5520b6ed5e1143663e3fe3da80b4063a62a4 Mon Sep 17 00:00:00 2001 From: zkfriendly Date: Mon, 19 Oct 2026 09:45:36 +0200 Subject: [PATCH 12/12] chore: pin joint-opening WHIR on Arkworks 0.6 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3eea48827..3bedaeef4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7139,7 +7139,7 @@ dependencies = [ [[package]] name = "whir" version = "0.1.0" -source = "git+https://github.com/worldfnd/whir.git?rev=afec1fac27c1c8cc7331aa5668bc9800f104d92c#afec1fac27c1c8cc7331aa5668bc9800f104d92c" +source = "git+https://github.com/worldfnd/whir.git?rev=d2b16529d0a4fad4f53d088281e9062898d35f5b#d2b16529d0a4fad4f53d088281e9062898d35f5b" dependencies = [ "ark-ff", "ark-serialize", diff --git a/Cargo.toml b/Cargo.toml index 01ff19afe..b420fb154 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -214,4 +214,4 @@ spongefish = { package = "provekit-spongefish", version = "=0.2.0", features = [ "sha2", ] } spongefish-pow = { package = "provekit-spongefish-pow", version = "=0.2.0" } -whir = { git = "https://github.com/worldfnd/whir.git", rev = "afec1fac27c1c8cc7331aa5668bc9800f104d92c", features = ["tracing", "rs_in_order"] } +whir = { git = "https://github.com/worldfnd/whir.git", rev = "d2b16529d0a4fad4f53d088281e9062898d35f5b", features = ["tracing", "rs_in_order"] }