From dc02b5515610fc6170822ff63047d6271631fa98 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Wed, 23 Sep 2026 11:07:35 +0530 Subject: [PATCH 1/3] refactor: share helpers for OOD commitments --- crates/pcs/src/challenger.rs | 39 +----------------------------- crates/pcs/src/lib.rs | 1 + crates/pcs/src/pow.rs | 47 ++++++++++++++++++++++++++++++++++++ crates/poly/src/mle.rs | 8 +++--- 4 files changed, 54 insertions(+), 41 deletions(-) create mode 100644 crates/pcs/src/pow.rs diff --git a/crates/pcs/src/challenger.rs b/crates/pcs/src/challenger.rs index 24ff2eac..0a341c49 100644 --- a/crates/pcs/src/challenger.rs +++ b/crates/pcs/src/challenger.rs @@ -1,6 +1,7 @@ //! Flock challenger adapters over the project transcript. use crate::bridge::{as_flock_f128, from_flock_f128}; +use crate::pow::{find as find_pow, valid as pow_valid}; use field::F128 as LocalF128; use flock_core::challenger::Challenger; use flock_core::field::F128 as FlockF128; @@ -232,44 +233,6 @@ impl Challenger for VerifierChallenger<'_, '_> { } } -/// todo: parallel pow? use potentially spongefish? -fn find_pow(seed: &[u8; 16], bits: u32) -> u64 { - if bits == 0 { - return 0; - } - let mut nonce = 0u64; - loop { - if pow_valid(seed, nonce, bits) { - return nonce; - } - nonce = nonce.checked_add(1).expect("proof-of-work nonce exhausted"); - } -} - -fn pow_valid(seed: &[u8; 16], nonce: u64, bits: u32) -> bool { - if bits == 0 { - return nonce == 0; - } - let mut hasher = blake3::Hasher::new(); - hasher.update(b"bitz-pcs-pow-v1"); - hasher.update(seed); - hasher.update(&nonce.to_le_bytes()); - let digest = hasher.finalize(); - leading_zero_bits(digest.as_bytes()) >= bits -} - -fn leading_zero_bits(bytes: &[u8]) -> u32 { - let mut total = 0; - for byte in bytes { - let zeros = byte.leading_zeros(); - total += zeros; - if zeros != 8 { - break; - } - } - total -} - #[cfg(test)] mod tests { use proptest::prelude::*; diff --git a/crates/pcs/src/lib.rs b/crates/pcs/src/lib.rs index 2c636d7c..7f28b8fb 100644 --- a/crates/pcs/src/lib.rs +++ b/crates/pcs/src/lib.rs @@ -94,6 +94,7 @@ mod commitment; mod ligerito; mod mle; mod opening; +mod pow; mod profiles; mod sumcheck; mod transpose; diff --git a/crates/pcs/src/pow.rs b/crates/pcs/src/pow.rs new file mode 100644 index 00000000..c0f2f890 --- /dev/null +++ b/crates/pcs/src/pow.rs @@ -0,0 +1,47 @@ +//! Nonce search and validation shared by PCS grinding rounds. +//! +//! A positive difficulty requires that many leading zero bits in +//! `BLAKE3(POW_HASH_TAG || seed || nonce.to_le_bytes())`. At zero difficulty, +//! only nonce zero is accepted. Transcript framing belongs to each calling round. + +const POW_HASH_TAG: &[u8] = b"bitz-pcs-pow-v1"; + +/// Returns the first valid nonce in ascending order. +// todo: parallel pow? use potentially spongefish? +pub(crate) fn find(seed: &[u8; 16], bits: u32) -> u64 { + if bits == 0 { + return 0; + } + let mut nonce = 0u64; + loop { + if valid(seed, nonce, bits) { + return nonce; + } + nonce = nonce.checked_add(1).expect("proof-of-work nonce exhausted"); + } +} + +/// Checks the hash difficulty, or the canonical zero nonce at zero difficulty. +pub(crate) fn valid(seed: &[u8; 16], nonce: u64, bits: u32) -> bool { + if bits == 0 { + return nonce == 0; + } + let mut hasher = blake3::Hasher::new(); + hasher.update(POW_HASH_TAG); + hasher.update(seed); + hasher.update(&nonce.to_le_bytes()); + let digest = hasher.finalize(); + leading_zero_bits(digest.as_bytes()) >= bits +} + +fn leading_zero_bits(bytes: &[u8]) -> u32 { + let mut total = 0; + for byte in bytes { + let zeros = byte.leading_zeros(); + total += zeros; + if zeros != 8 { + break; + } + } + total +} diff --git a/crates/poly/src/mle.rs b/crates/poly/src/mle.rs index 43d1e817..ba1d331e 100644 --- a/crates/poly/src/mle.rs +++ b/crates/poly/src/mle.rs @@ -166,11 +166,13 @@ impl DenseMultilinearExtension { Ok(Self::evaluate_exact(&self.evaluations, r)) } - #[inline] + /// Evaluates an MLE table whose length is exactly `2^r.len()`. + /// /// Unrolled base cases adapted from WHIR's `eval_exact` (Apache-2.0): /// - fn evaluate_exact(evaluations: &[F], r: &[F]) -> F { - debug_assert_eq!(evaluations.len(), 1 << r.len()); + #[inline] + pub fn evaluate_exact(evaluations: &[F], r: &[F]) -> F { + assert_eq!(evaluations.len(), 1 << r.len(), "MLE table length"); let interpolate = |zero: F, one: F, challenge: F| zero + challenge * (one - zero); From 6d102c7f4acf66bc3a9199b15fad803a49f37b71 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Wed, 23 Sep 2026 12:11:49 +0530 Subject: [PATCH 2/3] feat: authenticate initial OOD claims in PCS openings --- Cargo.lock | 1 + crates/pcs/Cargo.toml | 1 + crates/pcs/src/commitment.rs | 68 +++++++++- crates/pcs/src/lib.rs | 76 ++++++++++- crates/pcs/src/ood.rs | 217 ++++++++++++++++++++++++++++++++ crates/pcs/src/opening.rs | 72 +++++++++-- crates/pcs/src/opening/tests.rs | 2 + crates/pcs/src/profiles.rs | 37 ++++++ crates/pcs/tests/round_trip.rs | 64 ++++++++++ 9 files changed, 523 insertions(+), 15 deletions(-) create mode 100644 crates/pcs/src/ood.rs diff --git a/Cargo.lock b/Cargo.lock index 082feb8d..2842e2c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1119,6 +1119,7 @@ dependencies = [ "field", "flock-core", "num-traits", + "poly", "proptest", "tracing", "transcript", diff --git a/crates/pcs/Cargo.toml b/crates/pcs/Cargo.toml index ad2b2f8f..18218780 100644 --- a/crates/pcs/Cargo.toml +++ b/crates/pcs/Cargo.toml @@ -14,6 +14,7 @@ flock-core = { workspace = true } transcript = { workspace = true } tracing = { workspace = true } num-traits = { workspace = true } +poly = { workspace = true } [dev-dependencies] divan = { workspace = true } diff --git a/crates/pcs/src/commitment.rs b/crates/pcs/src/commitment.rs index 0b17b5ce..2a2ca593 100644 --- a/crates/pcs/src/commitment.rs +++ b/crates/pcs/src/commitment.rs @@ -8,15 +8,18 @@ use core::mem::size_of; +use crate::VerifyError; use crate::bridge::as_flock_f128s; use crate::ligerito::CheckedLigerito; +use crate::ood::{OodClaim, prove, verify}; +use crate::profiles::{ood_grinding_bits, security_config}; use common::{Root, Shape}; use field::F128; pub use flock_core::hash::HashKind; use flock_core::pcs::Commitment as FlockCommitment; use flock_core::pcs::ligerito::LigeritoProfile; use flock_core::pcs::{PcsParams, ProverData as FlockProverData}; -use transcript::Encoding; +use transcript::{Encoding, ProverState, VerifierState}; /// Errors from PCS configuration. #[derive(Clone, Debug, PartialEq, Eq)] @@ -38,14 +41,32 @@ pub enum CommitError { pub struct Pcs { params: PcsParams, checked_ligerito: CheckedLigerito, + ood_grinding_bits: Option, bit_len: usize, packed_len: usize, } /// Flock state retained between commitment and openings. +/// OOD-aware commitment creation also retains the initial evaluation for each opening. pub struct ProverData { commitment: FlockCommitment, flock_prover_data: FlockProverData, + pub(crate) ood: Option, +} + +/// Commitment and out-of-domain claim read from the verifier transcript. +/// The claim is authenticated only after [`CommitScheme::verify_lin_with_ood`](crate::CommitScheme::verify_lin_with_ood) succeeds. +#[derive(Debug)] +pub struct VerifierData { + pub(crate) root: Root, + pub(crate) ood: Option, +} + +impl VerifierData { + /// Returns the public commitment root. + pub fn root(&self) -> Root { + self.root + } } impl Pcs { @@ -61,7 +82,7 @@ impl Pcs { // The ladder fixes the L0 interleaving: the commit must use the same // `log_batch_size` as the opening's `initial_k`, or the L0 tree is not // reusable as Ligerito's first oracle. - let security = crate::profiles::security_config(m, security_profile, merkle_hash)?; + let security = security_config(m, security_profile, merkle_hash)?; let params = PcsParams { m, log_inv_rate: security_profile.log_inv_rate(), @@ -70,6 +91,7 @@ impl Pcs { merkle_hash, }; let checked_ligerito = CheckedLigerito::new(¶ms, &security)?; + let ood_grinding_bits = ood_grinding_bits(&security, checked_ligerito.log_n_u32() as usize); let packed_len = 1usize .checked_shl(checked_ligerito.log_n_u32()) .ok_or(ConfigError::Invalid("packed length overflow"))?; @@ -77,12 +99,14 @@ impl Pcs { Ok(Self { params, checked_ligerito, + ood_grinding_bits, bit_len, packed_len, }) } - /// Commits to the exact configured number of packed field elements. + /// Commits to the packed codeword without sampling an OOD claim. + /// Use [`Self::commit_with_ood`] for protocols requiring initial OOD sampling. #[tracing::instrument(name = "Commit witness", skip_all)] pub fn commit(&self, packed_witness: &[F128]) -> Result<(Root, ProverData), CommitError> { // 1. Input Validation @@ -103,10 +127,44 @@ impl Pcs { ProverData { commitment: flock_commitment, flock_prover_data, + ood: None, }, )) } + /// Commits and retains the initial OOD claim for subsequent batched openings. + /// + /// Call before witness-dependent challenges and continue with the same transcript. + /// [`CommitScheme::prove_lin`](crate::CommitScheme::prove_lin) batches the retained claim into each opening. + /// Profiles using unique decoding omit the OOD round. + /// + /// Returns [`CommitError::PackedWitnessLengthMismatch`] before transcript mutation + /// if `packed_witness` does not have the configured length. + #[tracing::instrument(name = "Commit witness with OOD", skip_all)] + pub fn commit_with_ood( + &self, + packed_witness: &[F128], + transcript: &mut ProverState, + ) -> Result<(Root, ProverData), CommitError> { + let (root, mut data) = self.commit(packed_witness)?; + data.ood = prove(self, &root.0, packed_witness, transcript); + Ok((root, data)) + } + + /// Receives the OOD claim for the public root before subsequent protocol challenges. + /// + /// Mirrors [`Self::commit_with_ood`]. Invalid grinding or a truncated evaluation + /// returns [`VerifyError::MalformedProof`]; authentication of the evaluation is + /// deferred to [`CommitScheme::verify_lin_with_ood`](crate::CommitScheme::verify_lin_with_ood). + pub fn receive_commitment( + &self, + root: Root, + transcript: &mut VerifierState<'_>, + ) -> Result { + let ood = verify(self, &root.0, transcript)?; + Ok(VerifierData { root, ood }) + } + pub fn bit_len(&self) -> usize { self.bit_len } @@ -120,6 +178,10 @@ impl Pcs { &self.params } + pub(crate) fn ood_grinding_bits(&self) -> Option { + self.ood_grinding_bits + } + pub(crate) fn prover_config(&self) -> &flock_core::pcs::ligerito::ProverConfig { self.checked_ligerito.prover_config() } diff --git a/crates/pcs/src/lib.rs b/crates/pcs/src/lib.rs index 7f28b8fb..61deb3b0 100644 --- a/crates/pcs/src/lib.rs +++ b/crates/pcs/src/lib.rs @@ -29,6 +29,7 @@ //! - [`Pcs`] stores trusted Flock parameters and the expected bit length. //! - [`Root`] is the public Merkle root. //! - [`ProverData`] retains the codeword and Merkle tree after commitment. +//! - [`VerifierData`] retains the root and OOD claim received before opening. //! - [`OpeningQuery`] contains an MLE point and target, or a `common::LinearClaim`. //! - [`CommitScheme`] connects commitment, proving, and verification to project transcripts. //! - [`ConfigError`] reports configuration failures. @@ -39,6 +40,9 @@ //! It consumes the packed witness and borrows [`ProverData`]. //! The caller must use matching transcript session and instance labels. //! The caller must also call `VerifierState::check_eof` after successful verification. +//! Use [`Pcs::commit_with_ood`] and [`Pcs::receive_commitment`] before any +//! witness-dependent challenges to include the initial OOD claim. Proving batches +//! that retained claim automatically; verification uses [`Pcs::verify_lin_with_ood`]. //! //! # Example //! @@ -65,8 +69,8 @@ //! target: F128::from(0u64), //! }; //! -//! let (commitment, prover_data) = pcs.commit(&packed_witness).unwrap(); //! let mut prover = build_prover(b"pcs-example", b"zero-polynomial"); +//! let (commitment, prover_data) = pcs.commit_with_ood(&packed_witness, &mut prover).unwrap(); //! pcs.prove_lin( //! &prover_data, //! packed_witness, @@ -78,7 +82,8 @@ //! let proof = prover.finish(); //! //! let mut verifier = build_verifier(b"pcs-example", b"zero-polynomial", &proof); -//! pcs.verify_lin( +//! let commitment = pcs.receive_commitment(commitment, &mut verifier).unwrap(); +//! pcs.verify_lin_with_ood( //! &commitment, //! &query, //! StatementBinding::Bind, @@ -93,6 +98,7 @@ mod challenger; mod commitment; mod ligerito; mod mle; +mod ood; mod opening; mod pow; mod profiles; @@ -106,7 +112,7 @@ mod transpose_tests; use field::F128; use transcript::{ProverState, VerifierState}; -pub use commitment::{CommitError, ConfigError, HashKind, Pcs, ProverData}; +pub use commitment::{CommitError, ConfigError, HashKind, Pcs, ProverData, VerifierData}; pub use common::{OpeningQuery, Root}; pub use flock_core::pcs::ligerito::LigeritoProfile; pub use opening::{ProveError, VerifyError}; @@ -137,6 +143,8 @@ pub trait CommitScheme { type Commitment; /// Private data retained by the prover after commitment. type ProverData; + /// Commitment and OOD claim retained by the verifier before opening. + type VerifierData; /// Commits the caller-owned packed witness to `Enc_C(q_pkd)`, where /// `q_pkd(y) = Σ_{v ∈ {0,1}^7} q(y, v) · basis[v]`. @@ -146,9 +154,30 @@ pub trait CommitScheme { packed_witness: &[F128], ) -> Result<(Self::Commitment, Self::ProverData), CommitError>; + /// Commits and retains the initial OOD claim for subsequent openings. + /// + /// Call before witness-dependent challenges and continue with the same transcript. + /// Profiles without initial OOD sampling omit that round. + fn commit_with_ood( + &self, + packed_witness: &[F128], + transcript: &mut ProverState, + ) -> Result<(Self::Commitment, Self::ProverData), CommitError>; + + /// Receives the OOD claim for the public commitment before protocol challenges. + /// + /// Mirrors [`Self::commit_with_ood`]. The returned claim must be authenticated + /// by [`Self::verify_lin_with_ood`] on the same transcript. + fn receive_commitment( + &self, + commitment: Self::Commitment, + transcript: &mut VerifierState<'_>, + ) -> Result; + /// Consumes the exact packed witness and proves either opening query. /// /// Inner-product claims first pass through quadratic sumcheck and then the MLE opening protocol. + /// An OOD claim retained by [`Self::commit_with_ood`] is batched into the opening. fn prove_lin( &self, data: &Self::ProverData, @@ -168,11 +197,24 @@ pub trait CommitScheme { statement_binding: StatementBinding, transcript: &mut VerifierState<'_>, ) -> Result<(), VerifyError>; + + /// Verifies the linear query batched with the retained OOD claim. + /// + /// Continue the transcript used by [`Self::receive_commitment`]. Borrowing + /// the retained state permits multiple openings against the same commitment. + fn verify_lin_with_ood( + &self, + commitment: &Self::VerifierData, + query: &OpeningQuery, + statement_binding: StatementBinding, + transcript: &mut VerifierState<'_>, + ) -> Result<(), VerifyError>; } impl CommitScheme for Pcs { type Commitment = Root; type ProverData = ProverData; + type VerifierData = VerifierData; fn commit( &self, @@ -181,6 +223,22 @@ impl CommitScheme for Pcs { Pcs::commit(self, packed_witness) } + fn commit_with_ood( + &self, + packed_witness: &[F128], + transcript: &mut ProverState, + ) -> Result<(Self::Commitment, Self::ProverData), CommitError> { + Pcs::commit_with_ood(self, packed_witness, transcript) + } + + fn receive_commitment( + &self, + commitment: Self::Commitment, + transcript: &mut VerifierState<'_>, + ) -> Result { + Pcs::receive_commitment(self, commitment, transcript) + } + fn prove_lin( &self, data: &Self::ProverData, @@ -206,6 +264,16 @@ impl CommitScheme for Pcs { statement_binding: StatementBinding, transcript: &mut VerifierState<'_>, ) -> Result<(), VerifyError> { - opening::verify(self, commitment, query, statement_binding, transcript) + opening::verify(self, commitment, query, statement_binding, None, transcript) + } + + fn verify_lin_with_ood( + &self, + commitment: &Self::VerifierData, + query: &OpeningQuery, + statement_binding: StatementBinding, + transcript: &mut VerifierState<'_>, + ) -> Result<(), VerifyError> { + opening::verify_lin_with_ood(self, commitment, query, statement_binding, transcript) } } diff --git a/crates/pcs/src/ood.rs b/crates/pcs/src/ood.rs new file mode 100644 index 00000000..bb5d718a --- /dev/null +++ b/crates/pcs/src/ood.rs @@ -0,0 +1,217 @@ +//! Initial out-of-domain claim on the packed commitment polynomial. +//! +//! After binding the root and PCS parameters, the prover performs any configured +//! grinding, samples `zeta`, and sends `value = p(point)`, where `p` is the packed +//! witness MLE and `point[i] = zeta^(2^i)` in low-bit-first order. +//! The verifier derives the same point and reads the claimed value. +//! +//! After ring switching, a fresh `coefficient` batches this claim into Ligerito: +//! `basis += coefficient * eq(point, ·)` and `target += coefficient * value`. +//! The claim remains borrowed from commitment state so it can be used by multiple +//! openings. Unique-decoding profiles omit this initial round. + +use field::F128; +use flock_core::field::F128 as FlockF128; +use num_traits::ConstOne; +use poly::{DenseMultilinearExtension, eq_table}; +use transcript::{ProverState, PublicTranscript, VerifierState}; + +use crate::bridge::{as_flock_f128, from_flock_f128}; +use crate::pow::{find, valid}; +use crate::{Pcs, VerifyError}; + +const OOD_ROUND_TAG: &[u8] = b"bitz/pcs/ood/v1"; +const OOD_BATCHING_TAG: &[u8] = b"bitz/pcs/ood-batching/v1"; +const OOD_POW_TAG: &[u8] = b"bitz/pcs/ood-pow/v1"; +const BLOCK_LOG: usize = 12; + +/// An evaluation of the packed witness MLE, authenticated by the batched opening. +#[derive(Debug)] +pub(crate) struct OodClaim { + /// Successive squares of the sampled challenge, in low-bit-first order. + pub(crate) point: Vec, + /// Claimed MLE evaluation at `point`. + pub(crate) value: F128, +} + +/// Sends the initial evaluation after binding the commitment and configuration. +/// Returns `None` without transcript events when the profile omits OOD sampling. +pub(crate) fn prove( + pcs: &Pcs, + root: &[u8; 32], + packed: &[F128], + transcript: &mut ProverState, +) -> Option { + let grinding_bits = pcs.ood_grinding_bits()?; + absorb_header(pcs, root, grinding_bits, transcript); + if grinding_bits != 0 { + prove_pow(transcript, grinding_bits); + } + let point = ood_point(transcript.verifier_message_f128(), pcs.packed_len()); + let value = DenseMultilinearExtension::evaluate_exact(packed, &point); + transcript.prover_message(&value); + Some(OodClaim { point, value }) +} + +/// Reads the initial evaluation, checking grinding before sampling its point. +/// Reading the value does not authenticate it; the caller must verify its opening. +pub(crate) fn verify( + pcs: &Pcs, + root: &[u8; 32], + transcript: &mut VerifierState<'_>, +) -> Result, VerifyError> { + let Some(grinding_bits) = pcs.ood_grinding_bits() else { + return Ok(None); + }; + absorb_header(pcs, root, grinding_bits, transcript); + if grinding_bits != 0 { + verify_pow(transcript, grinding_bits).map_err(|_| VerifyError::MalformedProof)?; + } + let point = ood_point(transcript.verifier_message_f128(), pcs.packed_len()); + let value = transcript + .prover_message::() + .map_err(|_| VerifyError::MalformedProof)?; + Ok(Some(OodClaim { point, value })) +} + +/// Samples the OOD batching coefficient after the ring-switch claims are bound. +pub(crate) fn batching_challenge(transcript: &mut impl PublicTranscript) -> F128 { + transcript.public_message(OOD_BATCHING_TAG); + transcript.verifier_message_f128() +} + +/// Adds `coefficient * eq(claim.point, ·)` to the prover's Boolean evaluation table. +pub(crate) fn add_dense_basis(basis: &mut [FlockF128], claim: &OodClaim, coefficient: F128) { + let low = claim.point.len().min(BLOCK_LOG); + let block = 1usize << low; + let tail = eq_table(&claim.point[..low]); + let head = eq_table(&claim.point[low..]); + for (chunk, &scale) in basis.chunks_exact_mut(block).zip(&head) { + for (basis, &weight) in chunk.iter_mut().zip(&tail) { + *basis += as_flock_f128(coefficient * scale * weight); + } + } +} + +/// Adds the same equality polynomial after its low coordinates are fixed to `ris`. +pub(crate) fn add_succinct_basis( + basis: &mut [FlockF128], + claim: &OodClaim, + coefficient: F128, + ris: &[FlockF128], +) { + let suffix_vars = basis.len().ilog2() as usize; + if claim.point.len() != ris.len() + suffix_vars { + basis.fill(FlockF128::ZERO); + return; + } + let prefix = claim.point[..ris.len()] + .iter() + .zip(ris) + .fold(F128::ONE, |weight, (&point, &query)| { + weight * (F128::ONE + point + from_flock_f128(query)) + }); + let scale = coefficient * prefix; + for (basis, weight) in basis.iter_mut().zip(eq_table(&claim.point[ris.len()..])) { + *basis += as_flock_f128(scale * weight); + } +} + +fn absorb_header( + pcs: &Pcs, + root: &[u8; 32], + grinding_bits: u32, + transcript: &mut impl PublicTranscript, +) { + transcript.public_message(OOD_ROUND_TAG); + transcript.public_message(root); + transcript.public_message(pcs); + transcript.public_message(&(pcs.packed_len() as u64)); + transcript.public_message(&grinding_bits); +} + +fn ood_point(zeta: F128, packed_len: usize) -> Vec { + let mut point = Vec::with_capacity(packed_len.ilog2() as usize); + let mut coordinate = zeta; + for _ in 0..packed_len.ilog2() { + point.push(coordinate); + coordinate *= coordinate; + } + point +} + +fn prove_pow(transcript: &mut ProverState, bits: u32) { + transcript.public_message(OOD_POW_TAG); + transcript.public_message(&bits); + let seed = transcript.verifier_message::().to_bytes(); + let nonce = find(&seed, bits); + transcript.prover_message(&nonce.to_le_bytes()); +} + +fn verify_pow(transcript: &mut VerifierState<'_>, bits: u32) -> Result<(), ()> { + transcript.public_message(OOD_POW_TAG); + transcript.public_message(&bits); + let seed = transcript.verifier_message::().to_bytes(); + let nonce = transcript + .prover_message::<[u8; 8]>() + .map(u64::from_le_bytes) + .map_err(|_| ())?; + valid(&seed, nonce, bits).then_some(()).ok_or(()) +} + +#[cfg(test)] +mod tests { + use num_traits::ConstZero; + use transcript::{build_prover, build_verifier}; + + use super::*; + + #[test] + fn dense_and_succinct_ood_bases_agree_after_folding() { + let point = ood_point(F128::new(7, 11), 1 << 14); + let coefficient = F128::new(13, 17); + let claim = OodClaim { + point, + value: F128::ZERO, + }; + let mut dense = vec![FlockF128::ZERO; 1 << 14]; + add_dense_basis(&mut dense, &claim, coefficient); + let queries: Vec<_> = (0..9) + .map(|i| as_flock_f128(F128::new(i + 2, i + 19))) + .collect(); + for &query in &queries { + for i in 0..dense.len() / 2 { + dense[i] = dense[2 * i] + query * (dense[2 * i] + dense[2 * i + 1]); + } + dense.truncate(dense.len() / 2); + } + let mut succinct = vec![FlockF128::ZERO; dense.len()]; + add_succinct_basis(&mut succinct, &claim, coefficient, &queries); + assert_eq!(dense, succinct); + } + + #[test] + fn grinding_binds_the_following_challenge_and_rejects_invalid_nonces() { + const BITS: u32 = 8; + let mut prover = build_prover(b"ood-test", b"grinding"); + prove_pow(&mut prover, BITS); + let challenge = prover.verifier_message::(); + let mut proof = prover.finish(); + let mut verifier = build_verifier(b"ood-test", b"grinding", &proof); + verify_pow(&mut verifier, BITS).unwrap(); + assert_eq!(verifier.verifier_message::(), challenge); + verifier.check_eof().unwrap(); + + let mut seed_transcript = build_prover(b"ood-test", b"grinding"); + seed_transcript.public_message(OOD_POW_TAG); + seed_transcript.public_message(&BITS); + let seed = seed_transcript.verifier_message::().to_bytes(); + let invalid = (0..).find(|&nonce| !valid(&seed, nonce, BITS)).unwrap(); + proof.narg_string.copy_from_slice(&invalid.to_le_bytes()); + let mut verifier = build_verifier(b"ood-test", b"grinding", &proof); + assert!(verify_pow(&mut verifier, BITS).is_err()); + proof.narg_string.truncate(7); + let mut verifier = build_verifier(b"ood-test", b"grinding", &proof); + assert!(verify_pow(&mut verifier, BITS).is_err()); + } +} diff --git a/crates/pcs/src/opening.rs b/crates/pcs/src/opening.rs index f2683320..87f0236b 100644 --- a/crates/pcs/src/opening.rs +++ b/crates/pcs/src/opening.rs @@ -8,7 +8,8 @@ use transcript::{ProverState, PublicTranscript, VerifierState}; use crate::bridge::{as_flock_f128, as_flock_f128s, from_flock_f128}; use crate::ligerito::{self, ReducedProver}; -use crate::{OpeningQuery, Pcs, ProverData, Root, StatementBinding, mle, sumcheck}; +use crate::ood::{OodClaim, add_dense_basis, add_succinct_basis, batching_challenge}; +use crate::{OpeningQuery, Pcs, ProverData, Root, StatementBinding, VerifierData, mle, sumcheck}; const MLE_STATEMENT_LABEL: &[u8] = b"bitz/pcs/mle-opening/v1"; const INNER_PRODUCT_STATEMENT_LABEL: &[u8] = b"bitz/pcs/bit-inner-product/v2"; @@ -82,6 +83,28 @@ impl From for VerifyError { } } +/// Verifies an opening batched with the OOD claim retained at commitment ingestion. +/// +/// Use the state returned by [`Pcs::receive_commitment`] and continue its transcript. +/// The state is borrowed so multiple openings can authenticate the same OOD claim. +/// Profiles without OOD sampling verify the ordinary linear claim. +pub(crate) fn verify_lin_with_ood( + pcs: &Pcs, + commitment: &VerifierData, + query: &OpeningQuery, + statement_binding: StatementBinding, + transcript: &mut VerifierState<'_>, +) -> Result<(), VerifyError> { + verify( + pcs, + &commitment.root, + query, + statement_binding, + commitment.ood.as_ref(), + transcript, + ) +} + #[tracing::instrument(name = "Prove PCS opening", skip_all)] pub(crate) fn prove( pcs: &Pcs, @@ -98,7 +121,7 @@ pub(crate) fn prove( if statement_binding == StatementBinding::Bind { bind_mle_statement(pcs, &data.commitment().root, point, *target, transcript); } - prove_mle(prover, ring_switch, *target, transcript) + prove_mle(prover, ring_switch, *target, data.ood.as_ref(), transcript) } OpeningQuery::InnerProduct { claim } => { validate_inner_product_claim(pcs, claim)?; @@ -117,7 +140,13 @@ pub(crate) fn prove( reduced.target, transcript, ); - prove_mle(prover, ring_switch, reduced.target, transcript) + prove_mle( + prover, + ring_switch, + reduced.target, + data.ood.as_ref(), + transcript, + ) } } } @@ -128,6 +157,7 @@ pub(crate) fn verify( commitment: &Root, query: &OpeningQuery, statement_binding: StatementBinding, + ood_claim: Option<&OodClaim>, transcript: &mut VerifierState<'_>, ) -> Result<(), VerifyError> { match query { @@ -136,7 +166,7 @@ pub(crate) fn verify( if statement_binding == StatementBinding::Bind { bind_mle_statement(pcs, &commitment.0, point, *target, transcript); } - verify_mle(pcs, commitment, ring_switch, *target, transcript) + verify_mle(pcs, commitment, ring_switch, *target, ood_claim, transcript) } OpeningQuery::InnerProduct { claim } => { validate_inner_product_claim(pcs, claim)?; @@ -153,7 +183,14 @@ pub(crate) fn verify( reduced.target, transcript, ); - verify_mle(pcs, commitment, ring_switch, reduced.target, transcript) + verify_mle( + pcs, + commitment, + ring_switch, + reduced.target, + ood_claim, + transcript, + ) } } } @@ -176,6 +213,7 @@ fn prove_mle( prover: ReducedProver<'_>, ring_switch: mle::RingSwitch<'_>, target: F128, + ood_claim: Option<&OodClaim>, transcript: &mut ProverState, ) -> Result<(), ProveError> { let dense_reduction = { @@ -184,7 +222,13 @@ fn prove_mle( ring_switch.prepare_claims(as_flock_f128s(prover.witness()), target)?; write_claims(transcript, &prepared_claims.claims); let batching_point = sample_challenges(transcript); - prepared_claims.reduce_dense(&batching_point) + let mut reduced = prepared_claims.reduce_dense(&batching_point); + if let Some(claim) = ood_claim { + let coefficient = batching_challenge(transcript); + add_dense_basis(&mut reduced.packed_basis, claim, coefficient); + reduced.packed_target += as_flock_f128(coefficient * claim.value); + } + reduced }; prover.prove(dense_reduction, transcript) } @@ -195,6 +239,7 @@ fn verify_mle( commitment: &Root, ring_switch: mle::RingSwitch<'_>, target: F128, + ood_claim: Option<&OodClaim>, transcript: &mut VerifierState<'_>, ) -> Result<(), VerifyError> { let proof = ligerito::read_proof(pcs, commitment, transcript)?; @@ -207,13 +252,24 @@ fn verify_mle( let batching_point = sample_challenges(transcript); ring_switch.reduce_succinct(&claims, &batching_point) }; + let ood = ood_claim.map(|claim| (claim, batching_challenge(transcript))); + let mut packed_target = reduction.packed_target; + if let Some((claim, coefficient)) = ood { + packed_target += as_flock_f128(coefficient * claim.value); + } ligerito::verify_succinct( pcs, commitment, &proof, ring_switch.suffix_dimension(), - reduction.packed_target, - |ris, yr_log_n| reduction.evaluate_basis(ris, yr_log_n), + packed_target, + |ris, yr_log_n| { + let mut basis = reduction.evaluate_basis(ris, yr_log_n); + if let Some((claim, coefficient)) = ood { + add_succinct_basis(&mut basis, claim, coefficient, ris); + } + basis + }, transcript, ) } diff --git a/crates/pcs/src/opening/tests.rs b/crates/pcs/src/opening/tests.rs index a704f6f8..1e0d30c9 100644 --- a/crates/pcs/src/opening/tests.rs +++ b/crates/pcs/src/opening/tests.rs @@ -78,6 +78,7 @@ fn inner_product_proof_composes_sumcheck_with_a_bound_mle_opening() { target: reduced.target, }, StatementBinding::Bind, + None, &mut verifier, ) .unwrap(); @@ -155,6 +156,7 @@ fn opening_leaves_matching_transcripts_for_following_protocols() { &fixture.root, &query, StatementBinding::Bind, + None, &mut verifier, ) .unwrap(); diff --git a/crates/pcs/src/profiles.rs b/crates/pcs/src/profiles.rs index 572d5b12..1050dbf3 100644 --- a/crates/pcs/src/profiles.rs +++ b/crates/pcs/src/profiles.rs @@ -42,6 +42,32 @@ pub(crate) fn security_config( Ok(security) } +/// Derives initial OOD grinding from the level-zero collision bound. +/// +/// With `rho = 2^-log_inv_rate`, let `list_size = 1 / (2 * eta * sqrt(rho))`, +/// `pairs = max(list_size * (list_size - 1) / 2, 1)`, and +/// `degree = max(2^packed_vars - 1, 1)`. The unground bound is +/// `128 - log2(pairs) - log2(degree)` bits; grinding covers its rounded-up +/// deficit against the target. Unique-decoding profiles return `None`. +pub(crate) fn ood_grinding_bits( + security: &LigeritoSecurityConfig, + packed_vars: usize, +) -> Option { + let level = security.levels.first()?; + let eta = match level.regime { + SoundnessRegime::JohnsonOod => level.eta?, + SoundnessRegime::Udr => return None, + }; + let rho = (-(level.log_inv_rate as f64)).exp2(); + let list_size = 1.0 / (2.0 * eta * rho.sqrt()); + let pairs = (list_size * (list_size - 1.0) / 2.0).max(1.0); + let degree = ((packed_vars as f64).exp2() - 1.0).max(1.0); + let collision_bits = 128.0 - pairs.log2() - degree.log2(); + let deficit = security.target_security_bits as f64 - collision_bits; + let grinding_bits = deficit.ceil().max(0.0) as u32; + Some(grinding_bits) +} + /// Reproduces the reference prover's k = 4 profile with 16-bit query grinding. /// Flock's `derive_profile` fixes k = 6 and zero query grinding for Fast. fn fast_security_config(m: usize) -> Result { @@ -181,4 +207,15 @@ mod tests { assert!(security_config(m, LigeritoProfile::Fast, HashKind::Blake3).is_err()); } } + + #[test] + fn ood_round_parameters_match_the_level_zero_bound() { + let security = security_config(22, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + assert_eq!(ood_grinding_bits(&security, 15), Some(0)); + + let mut unique = security; + unique.levels[0].regime = SoundnessRegime::Udr; + unique.levels[0].eta = None; + assert_eq!(ood_grinding_bits(&unique, 15), None); + } } diff --git a/crates/pcs/tests/round_trip.rs b/crates/pcs/tests/round_trip.rs index d738a41e..561964eb 100644 --- a/crates/pcs/tests/round_trip.rs +++ b/crates/pcs/tests/round_trip.rs @@ -263,6 +263,70 @@ fn real_pcs_opening_round_trip_succeeds() { verifier.check_eof().unwrap(); } +#[test] +fn real_pcs_ood_round_batches_into_opening() { + let pcs = Pcs::new(&shape(), LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let mut packed_witness = vec![F128::ZERO; pcs.packed_len()]; + packed_witness[SINGLETON / 128] = F128::new(0, 1 << (SINGLETON % 128 - 64)); + let point = vec![F128::from(2u64); M]; + let query = OpeningQuery::Mle { + target: singleton_target(&point, SINGLETON), + point, + }; + ood_round_trip(&pcs, packed_witness, query); +} + +fn ood_round_trip(pcs: &impl CommitScheme, packed_witness: Vec, query: OpeningQuery) { + let mut prover = build_prover(SESSION, b"ood-round-trip"); + let (commitment, data) = pcs.commit_with_ood(&packed_witness, &mut prover).unwrap(); + pcs.prove_lin( + &data, + packed_witness, + &query, + StatementBinding::Bind, + &mut prover, + ) + .unwrap(); + let next_challenge = prover.verifier_message::(); + let proof = prover.finish(); + + let mut verifier = build_verifier(SESSION, b"ood-round-trip", &proof); + let received = pcs.receive_commitment(commitment, &mut verifier).unwrap(); + pcs.verify_lin_with_ood(&received, &query, StatementBinding::Bind, &mut verifier) + .unwrap(); + assert_eq!(verifier.verifier_message::(), next_challenge); + verifier.check_eof().unwrap(); +} + +#[test] +fn real_pcs_ood_round_rejects_a_changed_evaluation() { + let pcs = Pcs::new(&shape(), LigeritoProfile::Fast, HashKind::Blake3).unwrap(); + let packed_witness = vec![F128::ZERO; pcs.packed_len()]; + let query = OpeningQuery::Mle { + point: vec![F128::from(2u64); M], + target: F128::ZERO, + }; + let mut prover = build_prover(SESSION, b"ood-tampering"); + let (commitment, data) = pcs.commit_with_ood(&packed_witness, &mut prover).unwrap(); + pcs.prove_lin( + &data, + packed_witness, + &query, + StatementBinding::Bind, + &mut prover, + ) + .unwrap(); + let mut proof = prover.finish(); + proof.narg_string[0] ^= 1; + + let mut verifier = build_verifier(SESSION, b"ood-tampering", &proof); + let received = pcs.receive_commitment(commitment, &mut verifier).unwrap(); + assert!( + pcs.verify_lin_with_ood(&received, &query, StatementBinding::Bind, &mut verifier,) + .is_err() + ); +} + #[test] fn factored_inner_product_round_trip_succeeds_for_all_profiles_and_bindings() { for profile in [ From 39f550959acad596e747eb49cfb81ca9574ee437 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Wed, 23 Sep 2026 12:18:01 +0530 Subject: [PATCH 3/3] feat: bind OOD claims before circuit proof challenges --- crates/prover/src/prove.rs | 8 +-- crates/tests/examples/dump_bitz.rs | 6 ++- crates/tests/tests/host.rs | 6 ++- crates/tests/tests/prove.rs | 23 +++++--- crates/tests/tests/virtual_prove.rs | 16 ++++-- crates/verifier/src/verify.rs | 60 ++++++++++++++++----- tooling/cli/benches/circuits.rs | 12 +++-- tooling/cli/src/benchmark.rs | 2 +- tooling/cli/src/end_to_end.rs | 81 +++++++++++++++++++++++------ tooling/cli/tests/circuits.rs | 4 +- tooling/cli/tests/end_to_end.rs | 31 +++++++++-- 11 files changed, 191 insertions(+), 58 deletions(-) diff --git a/crates/prover/src/prove.rs b/crates/prover/src/prove.rs index 87c62705..055ca7a9 100644 --- a/crates/prover/src/prove.rs +++ b/crates/prover/src/prove.rs @@ -42,8 +42,8 @@ pub struct VirtualWitness<'a> { impl BitZProver { /// Proves the caller's linear claim about the committed bits. /// - /// The caller commits first and passes what that produced: the `data` the - /// opening reads and the packed witness itself. The root is read back off + /// Call `Pcs::commit_with_ood` on this transcript, then pass its retained + /// `data` and the packed witness. The root is read back off /// `data` rather than passed alongside it, so the two cannot disagree. /// `pcs` must be the scheme that committed, or the opening will not verify. /// @@ -82,8 +82,8 @@ impl BitZProver { /// Proves a claim on `h = M (1 || f)` against the commitment to `f`. /// - /// Build the setup from `statement.params().claim()`. Commit `witness.committed_bits` - /// with `pcs` under the committed shape and pass its returned `data`. GKR reduces + /// Build the setup from `statement.params().claim()`. Use `Pcs::commit_with_ood` + /// on `witness.committed_bits` and this transcript, then pass its `data`. GKR reduces /// the input claim to an inner product on padded virtual bits. This method /// transposes its coefficients before PCS opens the committed bits. /// diff --git a/crates/tests/examples/dump_bitz.rs b/crates/tests/examples/dump_bitz.rs index 92f98f77..85f6d316 100644 --- a/crates/tests/examples/dump_bitz.rs +++ b/crates/tests/examples/dump_bitz.rs @@ -29,12 +29,16 @@ fn main() -> Result<(), Box> { let started = std::time::Instant::now(); let mut transcript = prover_transcript(); + let (_, data) = instance + .pcs + .commit_with_ood(&instance.packed, &mut transcript) + .unwrap(); instance .prover .prove( &instance.claim, &instance.pcs, - &instance.data, + &data, instance.packed.clone(), &mut transcript, ) diff --git a/crates/tests/tests/host.rs b/crates/tests/tests/host.rs index 60d101be..6dfb080e 100644 --- a/crates/tests/tests/host.rs +++ b/crates/tests/tests/host.rs @@ -11,12 +11,16 @@ use tests::{Instance, narrow_shape, prover_transcript, verifier_transcript, wide /// Runs an honest prover and hands back what a caller would ship. fn shipped(instance: &Instance) -> Vec { let mut transcript = prover_transcript(); + let (_, data) = instance + .pcs + .commit_with_ood(&instance.packed, &mut transcript) + .unwrap(); instance .prover .prove( &instance.claim, &instance.pcs, - &instance.data, + &data, instance.packed.clone(), &mut transcript, ) diff --git a/crates/tests/tests/prove.rs b/crates/tests/tests/prove.rs index d464853c..08311c69 100644 --- a/crates/tests/tests/prove.rs +++ b/crates/tests/tests/prove.rs @@ -13,12 +13,16 @@ use verifier::{ReceiveError, VerifyError}; fn prove(instance: &Instance) -> Proof { let mut transcript = prover_transcript(); + let (_, data) = instance + .pcs + .commit_with_ood(&instance.packed, &mut transcript) + .unwrap(); instance .prover .prove( &instance.claim, &instance.pcs, - &instance.data, + &data, instance.packed.clone(), &mut transcript, ) @@ -108,12 +112,16 @@ fn an_opening_against_another_commitment_is_refused() { let committed = Instance::honest(narrow_shape(), 36); let mut transcript = prover_transcript(); + let (_, data) = committed + .pcs + .commit_with_ood(&committed.packed, &mut transcript) + .unwrap(); proved .prover .prove( &proved.claim, &proved.pcs, - &committed.data, + &data, proved.packed.clone(), &mut transcript, ) @@ -153,9 +161,8 @@ fn a_tampered_opening_proof_is_refused() { #[test] fn a_proof_verified_under_a_different_profile_is_refused() { - // The profile is not in the frame step 1 absorbs, so what rejects this is - // the opening binding its own parameters: a different profile encodes - // differently, the two sponges part, and the ring-switch check fails. + // OOD binds PCS parameters before the first fold challenge, so a different + // profile changes the fold transcript and GKR rejects. let instance = Instance::honest(narrow_shape(), 38); let slim = Pcs::new( instance.params.shape(), @@ -165,15 +172,15 @@ fn a_proof_verified_under_a_different_profile_is_refused() { .unwrap(); let proof = prove(&instance); - assert_eq!( + assert!(matches!( instance.verifier.verify( &instance.claim, &slim, instance.com, verifier_transcript(&proof) ), - Err(VerifyError::Opening(PcsVerifyError::VerificationFailed)) - ); + Err(VerifyError::Reduction(_)) + )); } #[test] diff --git a/crates/tests/tests/virtual_prove.rs b/crates/tests/tests/virtual_prove.rs index 424de53c..7d4a9048 100644 --- a/crates/tests/tests/virtual_prove.rs +++ b/crates/tests/tests/virtual_prove.rs @@ -94,11 +94,15 @@ impl Instance { fn prove(&self) -> Proof { let mut transcript = prover_transcript(); + let (_, data) = self + .pcs + .commit_with_ood(&self.committed_bits, &mut transcript) + .unwrap(); BitZProver::new(self.params, WINDOW) .prove_virtual( &self.statement(), &self.pcs, - &self.data, + &data, VirtualWitness { committed_bits: self.committed_bits.clone(), virtual_bits: &self.virtual_bits, @@ -297,11 +301,15 @@ fn virtual_bits_inconsistent_with_the_map_cannot_be_opened() { // but the virtual witness no longer equals M (1 || f). virtual_bits[0] = F128::from(6u64); let mut transcript = prover_transcript(); + let (_, data) = instance + .pcs + .commit_with_ood(&instance.committed_bits, &mut transcript) + .unwrap(); assert_eq!( BitZProver::new(instance.params, WINDOW).prove_virtual( &instance.statement(), &instance.pcs, - &instance.data, + &data, VirtualWitness { committed_bits: instance.committed_bits.clone(), virtual_bits: &virtual_bits, @@ -363,8 +371,10 @@ fn sha256_virtual_inner_product_opens_the_committed_bits() { let claim = LinearClaim::new(¶ms, rows, columns, target).unwrap(); let statement = VirtualStatement::new(params, committed_shape, &map, &claim).unwrap(); let pcs = Pcs::new(&committed_shape, LigeritoProfile::Fast, HashKind::Blake3).unwrap(); - let (root, data) = pcs.commit(&committed_bits).unwrap(); let mut transcript = prover_transcript(); + let (root, data) = pcs + .commit_with_ood(&committed_bits, &mut transcript) + .unwrap(); BitZProver::new(params, WINDOW) .prove_virtual( &statement, diff --git a/crates/verifier/src/verify.rs b/crates/verifier/src/verify.rs index e90f2770..e90da561 100644 --- a/crates/verifier/src/verify.rs +++ b/crates/verifier/src/verify.rs @@ -2,7 +2,7 @@ use common::{LinearClaim, OpeningQuery, Root, VirtualMap, VirtualMapError, VirtualStatement}; use field::Fq; -use pcs::{CommitScheme, Pcs, StatementBinding, VerifyError as OpeningVerifyError}; +use pcs::{CommitScheme, Pcs, StatementBinding, VerifierData, VerifyError as OpeningVerifyError}; use transcript::VerifierState; use crate::{BitZVerifier, ReceiveError, ReduceError, reduce::gkr_reduce}; @@ -25,6 +25,39 @@ pub enum VerifyError { } impl BitZVerifier { + /// Receives the commitment's OOD claim and verifies the virtual BitZ proof. + pub fn verify_virtual( + &self, + statement: &VirtualStatement<'_, Q, impl VirtualMap>, + pcs: &Pcs, + root: Root, + mut transcript: VerifierState<'_>, + ) -> Result<(), VerifyError> { + if self.params() != statement.params().claim() + || pcs.bit_len() != 1 << statement.params().committed_shape().log_bits() + { + return Err(VerifyError::ParameterMismatch); + } + let commitment = pcs + .receive_commitment(root, &mut transcript) + .map_err(VerifyError::Opening)?; + self.verify_virtual_with_commitment(statement, pcs, &commitment, transcript) + } + + /// Receives the commitment's OOD claim and verifies the BitZ proof. + pub fn verify( + &self, + claim: &LinearClaim>, + pcs: &Pcs, + root: Root, + mut transcript: VerifierState<'_>, + ) -> Result<(), VerifyError> { + let commitment = pcs + .receive_commitment(root, &mut transcript) + .map_err(VerifyError::Opening)?; + self.verify_with_commitment(claim, pcs, &commitment, transcript) + } + /// Verifies a claim on `h = M (1 || f)` against the commitment to `f`. /// /// Build the setup from `statement.params().claim()` and match the commitment's @@ -32,15 +65,16 @@ impl BitZVerifier { /// padded virtual bits. Supply the public circuit's map; its digest must cover /// its shape and entries. /// - /// Start the transcript with the prover's session, instance, and public-input events. + /// Continue the transcript that produced `commitment` through + /// [`Pcs::receive_commitment`], using the prover's public-input events. /// This method binds the inputs in [`VirtualStatement`], transposes the reduced /// claim, verifies the PCS opening, and rejects trailing proof or hint bytes. #[tracing::instrument(name = "Verify virtual BitZ", skip_all)] - pub fn verify_virtual( + pub fn verify_virtual_with_commitment( &self, statement: &VirtualStatement<'_, Q, impl VirtualMap>, pcs: &Pcs, - com: Root, + commitment: &VerifierData, mut transcript: VerifierState<'_>, ) -> Result<(), VerifyError> { let params = statement.params(); @@ -51,7 +85,7 @@ impl BitZVerifier { return Err(VerifyError::ParameterMismatch); } transcript.public_message(b"bitz/virtual-statement/v1"); - transcript.public_message(&com.0); + transcript.public_message(&commitment.root().0); transcript.public_message(params); transcript.public_message(&statement.map().digest()); transcript.public_message(claim); @@ -59,7 +93,7 @@ impl BitZVerifier { let query = statement .transpose_query(query) .map_err(VerifyError::VirtualMap)?; - pcs.verify_lin(&com, &query, StatementBinding::Bind, &mut transcript) + pcs.verify_lin_with_ood(commitment, &query, StatementBinding::Bind, &mut transcript) .map_err(VerifyError::Opening)?; transcript .check_eof() @@ -68,29 +102,29 @@ impl BitZVerifier { /// Replays the proof of the caller's linear claim about the committed bits. /// - /// `pcs` must be the scheme the commitment was made under. The transcript - /// arrives carrying the caller's events; this appends and consumes it. + /// `pcs` must be the scheme the commitment was made under. Continue the + /// transcript used by [`Pcs::receive_commitment`]; this consumes it and checks EOF. #[tracing::instrument(name = "Verify BitZ", skip_all)] - pub fn verify( + pub fn verify_with_commitment( &self, claim: &LinearClaim>, pcs: &Pcs, - com: Root, + commitment: &VerifierData, mut transcript: VerifierState<'_>, ) -> Result<(), VerifyError> { // Step 1: the admissibility and precondition checks have already run -- // the shape gates in Shape::new, the modulus in Fq's own const assertions, // the generator's order in BitZParams::new and the weight counts in // LinearClaim::new. What is left is binding, before any challenge. - transcript.public_message(&com.0); + transcript.public_message(&commitment.root().0); transcript.public_message(self.params()); // Steps 3 and 4: check integer folds and replay GKR to obtain a bit claim. let query = self.fold_and_reduce(claim, &mut transcript)?; // Step 6: verify the inner-product sumcheck, ring switch, and opening. - // Acceptance requires authenticating GKR's terminal claim against com. - pcs.verify_lin(&com, &query, StatementBinding::Bind, &mut transcript) + // Acceptance requires authenticating GKR's terminal claim against the commitment. + pcs.verify_lin_with_ood(commitment, &query, StatementBinding::Bind, &mut transcript) .map_err(VerifyError::Opening)?; // Both streams must be spent. Taking the transcript by value is what diff --git a/tooling/cli/benches/circuits.rs b/tooling/cli/benches/circuits.rs index 987f2952..13fec1c1 100644 --- a/tooling/cli/benches/circuits.rs +++ b/tooling/cli/benches/circuits.rs @@ -52,11 +52,13 @@ fn commit(bencher: Bencher, circuit: BuiltinCircuit) { #[divan::bench(args = BuiltinCircuit::ALL)] fn prove(bencher: Bencher, circuit: BuiltinCircuit) { let (system, inputs) = setup(circuit); - let witness = system.witness(&inputs).unwrap(); - let data = system.commit(&witness).unwrap(); bencher - .with_inputs(|| system.witness(&inputs).unwrap()) - .bench_local_values(|witness| system.prove(witness, &data).unwrap()); + .with_inputs(|| { + let witness = system.witness(&inputs).unwrap(); + let data = system.commit(&witness).unwrap(); + (witness, data) + }) + .bench_local_values(|(witness, data)| system.prove(witness, data).unwrap()); } #[divan::bench(args = BuiltinCircuit::ALL)] @@ -64,6 +66,6 @@ fn verify(bencher: Bencher, circuit: BuiltinCircuit) { let (system, inputs) = setup(circuit); let witness = system.witness(&inputs).unwrap(); let data = system.commit(&witness).unwrap(); - let proof = system.prove(witness, &data).unwrap(); + let proof = system.prove(witness, data).unwrap(); bencher.bench_local(|| system.verify(&proof).unwrap()); } diff --git a/tooling/cli/src/benchmark.rs b/tooling/cli/src/benchmark.rs index e5bbc09f..cbd8f6a9 100644 --- a/tooling/cli/src/benchmark.rs +++ b/tooling/cli/src/benchmark.rs @@ -38,7 +38,7 @@ pub fn run(statement: S, inputs: &[bool]) -> Result, } +/// Commitment data and the transcript that sampled its OOD claim. +pub struct CommittedWitness { + data: ProverData, + transcript: ProverState, +} + #[derive(Clone, Debug)] pub struct Proof { pub root: Root, @@ -201,23 +209,31 @@ impl CircuitProofSystem { }) } + /// Commits and sends the initial OOD evaluation before any PIOP challenge. + /// The returned state retains both PCS data and the transcript for proving. #[tracing::instrument(name = "commit", skip_all)] - pub fn commit(&self, witness: &Witness) -> Result { - self.pcs - .commit(&witness.committed) - .map(|(_, data)| data) - .map_err(Error::Commit) + pub fn commit(&self, witness: &Witness) -> Result { + let mut transcript = build_prover(SESSION, self.statement.domain()); + let (_, data) = self + .pcs + .commit_with_ood(&witness.committed, &mut transcript) + .map_err(Error::Commit)?; + Ok(CommittedWitness { data, transcript }) } + /// Continues the commitment transcript through Spartan and the BitZ opening. #[tracing::instrument(name = "prove", skip_all, fields(opening_path = ?self.opening_path))] - pub fn prove(&self, witness: Witness, data: &ProverData) -> Result { + pub fn prove(&self, witness: Witness, commitment: CommittedWitness) -> Result { + let CommittedWitness { + data, + mut transcript, + } = commitment; let root = data.root(); - let mut transcript = build_prover(SESSION, self.statement.domain()); self.bind(&mut transcript, root); if self.opening_path == OpeningPath::Direct { self.pcs .prove_lin( - data, + &data, witness.committed.clone(), &self.constant_query(), StatementBinding::Bind, @@ -236,7 +252,7 @@ impl CircuitProofSystem { let prover = BitZProver::new(self.params, WINDOW); match self.opening_path { OpeningPath::Direct => { - prover.prove(&claim, &self.pcs, data, witness.committed, &mut transcript) + prover.prove(&claim, &self.pcs, &data, witness.committed, &mut transcript) } OpeningPath::Virtual => { let statement = @@ -245,7 +261,7 @@ impl CircuitProofSystem { prover.prove_virtual( &statement, &self.pcs, - data, + &data, VirtualWitness { committed_bits: witness.committed, virtual_bits: &witness.assignment_bits, @@ -265,11 +281,15 @@ impl CircuitProofSystem { #[tracing::instrument(name = "verify", skip_all)] pub fn verify(&self, proof: &Proof) -> Result<(), Error> { let mut transcript = build_verifier(SESSION, self.statement.domain(), &proof.opening); + let commitment = self + .pcs + .receive_commitment(proof.root, &mut transcript) + .map_err(Error::OodVerify)?; self.bind(&mut transcript, proof.root); if self.opening_path == OpeningPath::Direct { self.pcs - .verify_lin( - &proof.root, + .verify_lin_with_ood( + &commitment, &self.constant_query(), StatementBinding::Bind, &mut transcript, @@ -281,12 +301,19 @@ impl CircuitProofSystem { let claim = opening_claim(&self.params, &terminal)?; let verifier = BitZVerifier::new(self.params, WINDOW); match self.opening_path { - OpeningPath::Direct => verifier.verify(&claim, &self.pcs, proof.root, transcript), + OpeningPath::Direct => { + verifier.verify_with_commitment(&claim, &self.pcs, &commitment, transcript) + } OpeningPath::Virtual => { let statement = VirtualStatement::new(self.params, self.committed_shape, &self.map, &claim) .map_err(|_| Error::Configuration("invalid virtual statement"))?; - verifier.verify_virtual(&statement, &self.pcs, proof.root, transcript) + verifier.verify_virtual_with_commitment( + &statement, + &self.pcs, + &commitment, + transcript, + ) } } .map_err(Error::Verify) @@ -405,12 +432,28 @@ mod tests { } } + #[test] + fn commitment_sends_ood_before_proving() { + let system = CircuitProofSystem::new(IdentityBit).unwrap(); + let witness = system.witness(&[true]).unwrap(); + let committed = system.commit(&witness).unwrap(); + let proof = committed.transcript.finish(); + assert_eq!(proof.narg_string.len(), 16); + assert!(proof.hints.is_empty()); + let mut verifier = build_verifier(SESSION, system.statement.domain(), &proof); + system + .pcs + .receive_commitment(committed.data.root(), &mut verifier) + .unwrap(); + verifier.check_eof().unwrap(); + } + #[test] fn direct_opening_requires_constant_one_on_both_sides() { let mut system = CircuitProofSystem::new(IdentityBit).unwrap(); let witness = system.witness(&[true]).unwrap(); let data = system.commit(&witness).unwrap(); - let mut proof = system.prove(witness, &data).unwrap(); + let mut proof = system.prove(witness, data).unwrap(); system.verify(&proof).unwrap(); system.opening_path = OpeningPath::Virtual; assert!(system.verify(&proof).is_err()); @@ -420,13 +463,17 @@ mod tests { bad_witness.committed.fill(F128::ZERO); let bad_data = system.commit(&bad_witness).unwrap(); assert!(matches!( - system.prove(bad_witness, &bad_data), + system.prove(bad_witness, bad_data), Err(Error::ConstantProve(_)) )); // A valid opening to zero must not substitute for the required one. let packed = vec![F128::ZERO; 1 << system.committed_shape.log_packed_len()]; let mut transcript = build_prover(SESSION, system.statement.domain()); + let (_, bad_data) = system + .pcs + .commit_with_ood(&packed, &mut transcript) + .unwrap(); system.bind(&mut transcript, bad_data.root()); let query = OpeningQuery::Mle { point: vec![F128::ZERO; system.committed_shape.log_bits()], diff --git a/tooling/cli/tests/circuits.rs b/tooling/cli/tests/circuits.rs index e5581ca5..420e238d 100644 --- a/tooling/cli/tests/circuits.rs +++ b/tooling/cli/tests/circuits.rs @@ -53,7 +53,7 @@ fn supported_sha_circuits_prove_and_verify() { let system = CircuitProofSystem::new(statement).unwrap(); let witness = system.witness(&inputs).unwrap(); let data = system.commit(&witness).unwrap(); - let proof = system.prove(witness, &data).unwrap(); + let proof = system.prove(witness, data).unwrap(); system.verify(&proof).unwrap(); } } @@ -76,7 +76,7 @@ fn sha_compression_matches_abc_and_binds_public_values() { let system = CircuitProofSystem::new(statement.clone()).unwrap(); let witness = system.witness(&inputs).unwrap(); let data = system.commit(&witness).unwrap(); - let proof = system.prove(witness, &data).unwrap(); + let proof = system.prove(witness, data).unwrap(); CircuitProofSystem::new(statement.clone()) .unwrap() .verify(&proof) diff --git a/tooling/cli/tests/end_to_end.rs b/tooling/cli/tests/end_to_end.rs index 7484ce80..857f7b80 100644 --- a/tooling/cli/tests/end_to_end.rs +++ b/tooling/cli/tests/end_to_end.rs @@ -1,5 +1,28 @@ -use bitz_cli::end_to_end::{CircuitProofSystem, CircuitStatement, Error, OpeningPath}; +use bitz_cli::end_to_end::{CircuitProofSystem, CircuitStatement, Error, OpeningPath, Proof}; use circuit::Circuit; +use pcs::VerifyError; + +fn rejects_changed_or_missing_ood( + system: &CircuitProofSystem, + proof: &Proof, +) { + // These Fast-profile fixtures have zero initial grinding bits, so the first + // 16 transcript bytes encode the OOD evaluation. + let mut changed = proof.clone(); + changed.opening.narg_string[0] ^= 1; + assert!(system.verify(&changed).is_err()); + + let mut missing = proof.clone(); + missing.opening.narg_string.drain(..16); + assert!(system.verify(&missing).is_err()); + + let mut truncated = proof.clone(); + truncated.opening.narg_string.truncate(15); + assert!(matches!( + system.verify(&truncated), + Err(Error::OodVerify(VerifyError::MalformedProof)) + )); +} struct PublicBit; @@ -28,11 +51,12 @@ fn generic_driver_accepts_a_non_sha_circuit() { assert_eq!(prepared.stats().committed_bits, 2); let witness = prepared.witness(&[true]).unwrap(); let data = prepared.commit(&witness).unwrap(); - let proof = prepared.prove(witness, &data).unwrap(); + let proof = prepared.prove(witness, data).unwrap(); CircuitProofSystem::new(PublicBit) .unwrap() .verify(&proof) .unwrap(); + rejects_changed_or_missing_ood(&prepared, &proof); let mut changed = proof.clone(); changed.root.0[0] ^= 1; @@ -100,11 +124,12 @@ fn nonidentity_map_uses_virtual_opening_and_checks_xor_relation() { assert_eq!(system.stats().committed_bits, 2); let witness = system.witness(&[true, false]).unwrap(); let data = system.commit(&witness).unwrap(); - let proof = system.prove(witness, &data).unwrap(); + let proof = system.prove(witness, data).unwrap(); CircuitProofSystem::new(PublicXor) .unwrap() .verify(&proof) .unwrap(); + rejects_changed_or_missing_ood(&system, &proof); assert!(matches!( system.witness(&[true, true]), Err(Error::Unsatisfied)