p3_sumcheck/ring_switch/mod.rs
1//! Ring switching (IACR eprint 2024/504, Construction 3.1): a reduction from an evaluation
2//! claim about a small-field multilinear to a claim about its packed extension-field
3//! multilinear.
4//!
5//! The prover holds `t` over `F` and its packing `t'` over `EF`, and the verifier holds a claim
6//! `t(r) = s`. Splitting `r = (r_high, r_low)` at the `κ = log2([EF:F])` variables one packed
7//! element absorbs, the reduction runs one degree-2 sumcheck of `ℓ' = ℓ − κ` rounds and leaves
8//! a claim `t'(r') = s'` about the packed polynomial alone. Discharging that claim against a
9//! commitment to `t'` is the caller's business.
10//!
11//! The tensor element `ŝ` is the only prover message the two checks share.
12//!
13//! ```text
14//! read by columns -> tests the incoming claim
15//! read by rows -> gives the sumcheck its initial sum
16//! ```
17//!
18//! The verifier derives that initial sum itself.
19//!
20//! It never takes the sum from the prover.
21//!
22//! # A reduction, not a filter
23//!
24//! A false input claim is not rejected outright.
25//!
26//! It survives as a false surviving claim `t'(r') = s'`.
27//!
28//! The probability of anything else is bounded by the error below.
29//!
30//! # Two things this does not catch
31//!
32//! The degenerate case `A(r') = 0` is one.
33//!
34//! There the final check reads `sum == 0`.
35//!
36//! That constrains `s'` not at all.
37//!
38//! A false input claim then survives as a *true* surviving claim.
39//!
40//! That is the one direction discharging `s'` against a commitment cannot catch.
41//!
42//! The `2ℓ'/|EF|` term of the bound covers it.
43//!
44//! The second is a prover who tampers with `ŝ`.
45//!
46//! Adapting the rest of the proof to the implied sum defeats the checks here.
47//!
48//! Only the caller catches it, by discharging `s'` against a commitment to `t'`.
49//!
50//! # What the row reading buys
51//!
52//! It buys the `κ/|EF|` term of the bound.
53//!
54//! A tampered `ŝ` shifts the derived initial sum away from an honest packing's value.
55//!
56//! It does so at the Schwartz–Zippel rate of the batching draw.
57//!
58//! # Soundness
59//!
60//! The reduction's own soundness error is `(κ + 2ℓ') / |EF|` (eprint 2024/504 §3.2, Theorem
61//! 3.5), split as:
62//!
63//! - `κ/|EF|` from the Schwartz–Zippel argument on the `r''` batching draw, which collapses
64//! the `2^κ` row claims into one — the draw names `κ` variables, which is where the `κ`
65//! comes from; and
66//! - `2ℓ'/|EF|` for the `ℓ'` rounds of degree-2 sumcheck.
67//!
68//! Both terms are stated for the interactive protocol. This module is Fiat–Shamir compiled and
69//! offers no grinding knob — `pow_bits` is zero on both sides and a proof carrying PoW
70//! witnesses is rejected — so the bound as a whole is a per-attempt probability rather than a
71//! total one: the `r''` draw and every round challenge alike are functions of messages the
72//! prover chooses, and a prover may resample any of them. That is out of reach at
73//! `|EF| = 2^128`; a composing protocol instantiating this over a small `EF` must supply the
74//! grinding itself, outside this call.
75//!
76//! `r` is bound by the reduction. Both sides observe it before `ŝ`, so the bound above holds
77//! unconditionally rather than resting on the caller having bound `r` first. Without that
78//! binding the proof would be replayable: `ŝ` is a function of `r_high` alone, so the same
79//! proof verifies verbatim at every `r_low`, and the verifier's one `r_high`-dependent test —
80//! `A(r')` in the final check — is multi-affine over `EF` in the coordinates of `r_high`. Move
81//! a single coordinate by `δ` and the change it induces in `A(r')` is `F`-linear in `δ`, so
82//! wherever that map is singular a prover can pick a nonzero `δ` from its kernel and have a
83//! false input claim accepted alongside a *true* surviving claim, which is the one direction a
84//! caller discharging `s'` against a commitment cannot catch.
85//!
86//! That figure is wired into **no** soundness estimator: nothing here touches `p3-security`,
87//! and neither the reduction nor its callers subtract it from any security target. A caller
88//! composing this reduction into a proof system must account for it in that system's own
89//! error budget, on top of whatever the commitment scheme discharging `t'(r') = s'` costs.
90
91use p3_challenger::fs::TranscriptField;
92use p3_challenger::{FieldChallenger, GrindingChallenger};
93use p3_field::{ExtensionField, Field};
94use p3_multilinear_util::point::Point;
95use p3_multilinear_util::poly::Poly;
96use serde::{Deserialize, Serialize};
97use thiserror::Error;
98
99use crate::data::SumcheckData;
100use crate::error::SumcheckError;
101use crate::product_polynomial::ProductPolynomial;
102use crate::strategy::{Basis, SumcheckProver, VariableOrder};
103
104pub mod bits;
105pub mod equality;
106pub mod packing;
107pub mod tensor;
108pub mod transcript;
109pub mod weights;
110
111pub use equality::equality_element;
112use packing::compute_s_hat_with_eq;
113pub use packing::{compute_s_hat, pack, packed_vars};
114pub use tensor::TensorAlgebra;
115pub use transcript::{RingSwitchProverTranscript, RingSwitchShape, RingSwitchVerifierTranscript};
116use weights::batched_weights_with_eq;
117pub use weights::{batch_rows, batched_weights};
118
119#[cfg(test)]
120pub(crate) mod test_util;
121
122/// The messages one ring-switching reduction puts on the wire.
123#[derive(Clone, Debug, Serialize, Deserialize)]
124#[serde(bound(
125 serialize = "F: Field, EF: ExtensionField<F>",
126 deserialize = "F: Field, EF: ExtensionField<F>"
127))]
128pub struct RingSwitchProof<F, EF> {
129 /// `ŝ = Σ_w eq̃(r_high, w) ⊗ t'(w)`, read by columns and by rows.
130 pub s_hat: TensorAlgebra<F, EF>,
131 /// The `ℓ'` rounds of the batched degree-2 sumcheck.
132 pub sumcheck: SumcheckData<F, EF>,
133 /// `s' = t'(r')`, the value of the surviving claim.
134 pub final_eval: EF,
135}
136
137/// Why a ring-switching proof was rejected.
138#[derive(Error, Debug, PartialEq, Eq)]
139pub enum RingSwitchError {
140 /// `ŝ` does not carry `DIMENSION²` base coefficients, so neither of its readings is defined.
141 #[error("Ring switching: s_hat carries {actual} base coefficients, expected {expected}")]
142 MalformedTensor {
143 /// The number of coefficients a tensor element must carry.
144 expected: usize,
145 /// The number the proof supplied.
146 actual: usize,
147 },
148
149 /// The evaluation point does not name the coordinate count the description fixes.
150 ///
151 /// Neither side can then bind it as the step the description names.
152 #[error("Ring switching: the evaluation point names {actual} coordinates, expected {expected}")]
153 PointWidthMismatch {
154 /// The coordinate count the description fixes.
155 expected: usize,
156 /// The number the point supplied.
157 actual: usize,
158 },
159
160 /// The claimed evaluation is not the `eq̃(·, r_low)`-combination of `ŝ`'s columns.
161 #[error("Ring switching: the claimed evaluation is not the column reading of s_hat")]
162 ClaimMismatch,
163
164 /// A round of the batched sumcheck failed.
165 #[error(transparent)]
166 Sumcheck(#[from] SumcheckError),
167
168 /// The surviving claim does not close the sumcheck against the equality element.
169 #[error("Ring switching: the surviving claim does not close the sumcheck")]
170 FinalCheck,
171
172 /// The sumcheck carries PoW witnesses this reduction never grinds for, so a proof carrying
173 /// junk witnesses would otherwise verify identically to one carrying none.
174 #[error("Ring switching: the sumcheck carries {actual} PoW witnesses, expected none")]
175 NonEmptyPowWitnesses {
176 /// The number of witnesses the proof supplied.
177 actual: usize,
178 },
179}
180
181/// Proves the reduction of `t(r) = s` to a claim about `packed`, the packing of `t`.
182///
183/// # Returns
184///
185/// - The proof.
186/// - The sumcheck's random point `r'`.
187/// - The surviving claim's value `s' = t'(r')`.
188///
189/// # Why only the packed polynomial
190///
191/// Every value that reaches the proof is computed from it.
192///
193/// The claim it proves is a statement about it.
194///
195/// Callers hold `t` and pack it themselves.
196///
197/// Pack the wrong `t` and the claim proves a statement about a different multilinear.
198///
199/// Nothing here can detect that.
200///
201/// The caller's commitment to `packed` is what pins it down.
202///
203/// # Packing
204///
205/// The sumcheck runs on a scalar product polynomial.
206///
207/// ```text
208/// binary tower -> extension packing is the extension itself, nothing to gain
209/// wider packing -> throughput left unclaimed
210/// ```
211///
212/// # What the transcript binds
213///
214/// The evaluation point and the surviving claim are both bound here.
215///
216/// ```text
217/// r absorbed before s_hat
218/// final_eval absorbed after the sumcheck rounds
219/// ```
220///
221/// A caller composing this into a larger protocol need not bind `r` itself.
222///
223/// # Panics
224///
225/// Unless `r` names at least the `κ` packed variables.
226///
227/// Unless `packed` has exactly the `ℓ − κ` variables that leaves.
228pub fn prove_ring_switch<F, EF, Challenger>(
229 packed: &Poly<EF>,
230 r: &Point<EF>,
231 challenger: &mut Challenger,
232) -> (RingSwitchProof<F, EF>, Point<EF>, EF)
233where
234 F: TranscriptField,
235 EF: ExtensionField<F>,
236 Challenger: FieldChallenger<F> + GrindingChallenger<Witness = F>,
237{
238 // The description is derived from the point handed in, never from a proof.
239 //
240 // Every width the transcript declares follows from it and from the field pair:
241 //
242 // point width -> the coordinates `r` names
243 // tensor width -> the extension degree, squared
244 // batching width -> log2 of the extension degree
245 let shape = RingSwitchShape::new(r.num_variables());
246 let ell_prime = shape.sumcheck_rounds::<F, EF>();
247 assert_eq!(
248 packed.num_variables(),
249 ell_prime,
250 "the packed polynomial must have the {ell_prime} variables the evaluation point leaves \
251 once the packed ones are removed"
252 );
253 let (r_high, _) = r.split_at(ell_prime);
254
255 // `ŝ` and the weight multilinear are both readings of the same `eq̃(r_high, ·)` table, so it
256 // is built once, shared, and freed before the rounds rather than held across them.
257 let eq = Poly::<EF>::new_from_point(r_high.as_slice(), EF::ONE);
258
259 // `ŝ` is a function of `r_high` alone.
260 //
261 // It is computed before the transcript needs it.
262 let s_hat = compute_s_hat_with_eq::<F, EF>(packed, &eq);
263
264 // Seeding folds the description's fingerprint into the borrowed sponge.
265 let mut transcript = RingSwitchProverTranscript::<Challenger, F, EF>::new(challenger, shape);
266
267 // One call binds the point, binds `ŝ`'s base coefficients, and draws `r''`.
268 //
269 // The point goes first.
270 //
271 // Every later check the verifier forms from `r` it forms locally.
272 //
273 // An unbound `r` would leave the proof replayable at a different point.
274 //
275 // What is bound for `ŝ` is the coefficients that cross the wire.
276 //
277 // Neither derived reading is bound directly.
278 //
279 // Both are still pinned: both are readings of that one step.
280 let r_batch = transcript.statement(r, s_hat.coefficients());
281
282 // `ℓ'` rounds on `h(X) = A(X) · t'(X)`, whose sum over the hypercube is the batched row
283 // reading of `ŝ`.
284 let weights = batched_weights_with_eq::<F, EF>(&eq, &r_batch);
285 drop(eq);
286 let poly = ProductPolynomial::new_unpacked(VariableOrder::Prefix, packed.clone(), weights);
287 let mut prover = SumcheckProver::new(poly, batch_rows::<F, EF>(&s_hat, &r_batch));
288 let mut sumcheck = SumcheckData::default();
289
290 // The rounds are a sub-protocol.
291 //
292 // They seed a transcript of their own from this sponge.
293 //
294 // The bracket records the delegation and hands the sponge over for it.
295 let r_prime = transcript.batched_sumcheck(|challenger| {
296 prover.compute_sumcheck_polynomials(&mut sumcheck, challenger, ell_prime, 0, None)
297 });
298
299 // After the last round the evaluation side has been folded to a single value, which is
300 // `t'(r')` — the same number a fresh `packed.eval_ext(&r_prime)` would recompute in
301 // another full pass over the packed evaluations.
302 let final_eval = prover.evals().as_slice()[0];
303
304 // The surviving claim is bound before the sponge goes back.
305 //
306 // A caller discharging it discharges the value this run produced.
307 transcript.surviving_claim(final_eval);
308 transcript.finish();
309
310 (
311 RingSwitchProof {
312 s_hat,
313 sumcheck,
314 final_eval,
315 },
316 r_prime,
317 final_eval,
318 )
319}
320
321/// Verifies the reduction of `claimed_sum = t(r)`.
322///
323/// # Returns
324///
325/// The surviving claim `t'(r') = s'`, as the pair `(r', s')`.
326///
327/// # Where the round count comes from
328///
329/// It is taken from `r`, the point the verifier owns.
330///
331/// A proof carrying a different number of rounds is rejected by the round replay.
332///
333/// # What the transcript binds
334///
335/// ```text
336/// r absorbed before s_hat
337/// final_eval absorbed after the rounds
338/// ```
339///
340/// Both land at the same two points the prover uses.
341///
342/// A proof is therefore usable only against the point it was produced for.
343///
344/// The claimed sum is bound indirectly, by the column check against `ŝ`.
345///
346/// Both structural rejections below happen before the challenger is touched.
347///
348/// A malformed proof cannot leave a half-advanced transcript.
349///
350/// # Panics
351///
352/// Unless `r` names at least the `κ` packed variables.
353///
354/// Everything read out of the proof is validated and reported as an error instead.
355///
356/// # Errors
357///
358/// - A malformed `ŝ`.
359/// - A non-empty proof-of-work witness list, since this reduction never grinds.
360/// - A claim that disagrees with `ŝ`'s columns.
361/// - A failed sumcheck round.
362/// - A final claim that does not close the sumcheck.
363///
364/// The point width is never among them.
365///
366/// The description is derived from `r` itself.
367///
368/// The two cannot disagree.
369pub fn verify_ring_switch<F, EF, Challenger>(
370 proof: &RingSwitchProof<F, EF>,
371 r: &Point<EF>,
372 claimed_sum: EF,
373 challenger: &mut Challenger,
374) -> Result<(Point<EF>, EF), RingSwitchError>
375where
376 F: TranscriptField,
377 EF: ExtensionField<F>,
378 Challenger: FieldChallenger<F> + GrindingChallenger<Witness = F>,
379{
380 // The description is derived from the point this side holds, never from the proof.
381 //
382 // Both sides therefore declare the same widths.
383 //
384 // A proof disagreeing with any of them is rejected.
385 //
386 // It is never left to desynchronise the sponge.
387 let shape = RingSwitchShape::new(r.num_variables());
388 let ell_prime = shape.sumcheck_rounds::<F, EF>();
389
390 // `columns` and `rows` index a `DIMENSION × DIMENSION` matrix, so a short `ŝ` must be
391 // rejected before either reading is taken.
392 if !proof.s_hat.is_well_formed() {
393 return Err(RingSwitchError::MalformedTensor {
394 expected: RingSwitchShape::tensor_coefficients::<F, EF>(),
395 actual: proof.s_hat.coefficients().len(),
396 });
397 }
398
399 // This reduction never grinds, so a proof carrying PoW witnesses is carrying data that is
400 // neither checked nor bound to the transcript; reject it outright instead of ignoring it.
401 if !proof.sumcheck.pow_witnesses.is_empty() {
402 return Err(RingSwitchError::NonEmptyPowWitnesses {
403 actual: proof.sumcheck.pow_witnesses.len(),
404 });
405 }
406
407 let (r_high, r_low) = r.split_at(ell_prime);
408
409 // Seeding folds the description's fingerprint into the borrowed sponge.
410 let mut transcript = RingSwitchVerifierTranscript::<Challenger, F, EF>::new(challenger, shape);
411
412 // One call absorbs the point, absorbs `ŝ`'s base coefficients, and redraws `r''`.
413 //
414 // A width the description does not allow is reported here.
415 //
416 // The failing step releases the driver's completeness check on its way out.
417 let r_batch = transcript.statement(r, proof.s_hat.coefficients())?;
418
419 // The incoming claim must be the `eq̃(·, r_low)`-combination of `ŝ`'s columns. This is the
420 // only use of `r_low`.
421 let eq_low = Poly::<EF>::new_from_point(r_low.as_slice(), EF::ONE);
422 let combined: EF = proof
423 .s_hat
424 .columns()
425 .iter()
426 .zip(eq_low.as_slice())
427 .map(|(&column, &weight)| column * weight)
428 .sum();
429 if combined != claimed_sum {
430 // Two described steps are still unplayed, and this rejection plays neither.
431 //
432 // Releasing the completeness check keeps this the only failure reported.
433 transcript.abort();
434 return Err(RingSwitchError::ClaimMismatch);
435 }
436
437 // The sumcheck's initial sum is derived from `ŝ`'s rows, never sent, which is what makes a
438 // dishonest `ŝ` catchable: the two readings are of the same coefficients.
439 let mut sum = batch_rows::<F, EF>(&proof.s_hat, &r_batch);
440
441 // The rounds are a sub-protocol.
442 //
443 // They seed a transcript of their own from this sponge.
444 //
445 // The bracket closes whatever the delegated replay returned.
446 //
447 // A rejection inside it leaves only the closing step unplayed.
448 let rounds = transcript.batched_sumcheck(|challenger| {
449 proof
450 .sumcheck
451 .verify_rounds(challenger, &mut sum, ell_prime, 0, Basis::Evaluation)
452 });
453 let r_prime = match rounds {
454 Ok(r_prime) => r_prime,
455 Err(error) => {
456 transcript.abort();
457 return Err(error.into());
458 }
459 };
460
461 // The surviving claim is absorbed where the prover absorbed it.
462 //
463 // The sponge is handed back in the state the prover left it in.
464 transcript.surviving_claim(proof.final_eval);
465 transcript.finish();
466
467 // The batched rows of the equality element are `A(r')`, so the sumcheck closes on
468 // `A(r') · t'(r')`.
469 let e = equality_element::<F, EF>(&r_high, &r_prime);
470 if sum != batch_rows::<F, EF>(&e, &r_batch) * proof.final_eval {
471 return Err(RingSwitchError::FinalCheck);
472 }
473
474 Ok((r_prime, proof.final_eval))
475}
476
477#[cfg(test)]
478mod tests {
479 use p3_challenger::CanSample;
480 use p3_field::PrimeCharacteristicRing;
481 use p3_multilinear_util::point::Point;
482 use proptest::prelude::*;
483 use rand::SeedableRng;
484 use rand::rngs::SmallRng;
485
486 use super::test_util::{Challenger, EF, F, base_poly, challenger};
487 use super::*;
488
489 /// Prover and verifier agree, and the surviving claim is true.
490 #[test]
491 fn the_reduction_round_trips() {
492 let ell = 7;
493 let t = base_poly(ell, 13);
494 let packed = pack::<F, EF>(t.clone());
495 let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(14), ell);
496 let s = t.eval_base(&r);
497
498 let mut p_chal = challenger();
499 let (proof, r_prime_p, s_prime_p) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
500
501 let mut v_chal = challenger();
502 let (r_prime_v, s_prime_v) =
503 verify_ring_switch::<F, EF, _>(&proof, &r, s, &mut v_chal).unwrap();
504
505 assert_eq!(r_prime_p, r_prime_v);
506 assert_eq!(s_prime_p, s_prime_v);
507 // The surviving claim is the truth about the committed polynomial.
508 assert_eq!(s_prime_v, packed.eval_ext::<F>(&r_prime_v));
509 }
510
511 proptest! {
512 // Sixteen cases keep the suite fast.
513 //
514 // Each one runs a full reduction on both sides.
515 #![proptest_config(ProptestConfig { cases: 16, ..ProptestConfig::default() })]
516
517 #[test]
518 fn the_reduction_round_trips_over_random_inputs(
519 ell in 5usize..=8,
520 poly_seed in any::<u64>(),
521 point_seed in any::<u64>(),
522 ) {
523 // Completeness: an honest run replays, and the claim it leaves is the truth.
524 //
525 // Fixture state: 4 packed variables.
526 //
527 // `ell` between 5 and 8 then leaves 1 to 4 rounds.
528 //
529 // ell = 5 -> 32 base evaluations -> 2 packed elements -> 1 round
530 // ell = 8 -> 256 base evaluations -> 16 packed elements -> 4 rounds
531 let t = base_poly(ell, poly_seed);
532 let packed = pack::<F, EF>(t.clone());
533
534 // The point is the verifier's own input, and the claim is the truth at it.
535 let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(point_seed), ell);
536 let s = t.eval_base(&r);
537
538 // Both sides start from the same fresh sponge, as a composing protocol would.
539 let mut p_chal = challenger();
540 let (proof, r_prime_p, s_prime_p) =
541 prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
542
543 let mut v_chal = challenger();
544 let (r_prime_v, s_prime_v) =
545 verify_ring_switch::<F, EF, _>(&proof, &r, s, &mut v_chal)
546 .expect("an honest reduction must verify");
547
548 // The two sides walked one description.
549 //
550 // They landed on one surviving claim.
551 prop_assert_eq!(&r_prime_p, &r_prime_v);
552 prop_assert_eq!(s_prime_p, s_prime_v);
553
554 // The surviving claim is the truth about the committed polynomial.
555 prop_assert_eq!(s_prime_v, packed.eval_ext::<F>(&r_prime_v));
556
557 // The sponge is handed back in one state.
558 //
559 // The caller stays in step afterwards.
560 prop_assert_eq!(
561 CanSample::<F>::sample(&mut p_chal),
562 CanSample::<F>::sample(&mut v_chal),
563 );
564 }
565 }
566
567 /// A wrong claimed value is rejected at the column check.
568 #[test]
569 fn a_wrong_claim_is_rejected() {
570 let ell = 6;
571 let t = base_poly(ell, 15);
572 let packed = pack::<F, EF>(t.clone());
573 let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(16), ell);
574 let s = t.eval_base(&r);
575
576 let mut p_chal = challenger();
577 let (proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
578
579 let mut v_chal = challenger();
580 assert_eq!(
581 verify_ring_switch::<F, EF, _>(&proof, &r, s + EF::ONE, &mut v_chal),
582 Err(RingSwitchError::ClaimMismatch)
583 );
584 }
585
586 /// A `ŝ` perturbed after proving is rejected. The claim handed to the verifier is the
587 /// perturbed element's own column reading, formed the way the verifier forms it, so the
588 /// column check passes by construction; the rejection comes from the final check instead.
589 ///
590 /// This does not, on its own, demonstrate that the reduction catches a prover who tampers
591 /// with `ŝ` and adapts the rest of the proof to it: perturbing `ŝ` after the honest sumcheck
592 /// was already recorded moves the verifier's derived `r''` and every later round challenge
593 /// away from the values those recorded messages were generated under, so verification fails
594 /// here even in a hypothetical design where the sumcheck's initial sum were prover-supplied.
595 /// An adaptive prover — one who tampers with `ŝ` first and then runs the genuine sumcheck
596 /// from the sum that tampered `ŝ` implies — is not caught by this check; see the module
597 /// docs for what does and does not stop that prover.
598 #[test]
599 fn a_perturbed_tensor_element_is_rejected() {
600 let ell = 6;
601 let t = base_poly(ell, 17);
602 let packed = pack::<F, EF>(t);
603 let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(18), ell);
604
605 let mut p_chal = challenger();
606 let (mut proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
607 proof.s_hat.perturb_for_test(0, F::ONE);
608
609 let (_, r_low) = r.split_at(ell - packed_vars::<F, EF>());
610 let eq_low = Poly::<EF>::new_from_point(r_low.as_slice(), EF::ONE);
611 let tampered_claim: EF = proof
612 .s_hat
613 .columns()
614 .iter()
615 .zip(eq_low.as_slice())
616 .map(|(&column, &weight)| column * weight)
617 .sum();
618
619 let mut v_chal = challenger();
620 assert_eq!(
621 verify_ring_switch::<F, EF, _>(&proof, &r, tampered_claim, &mut v_chal),
622 Err(RingSwitchError::FinalCheck)
623 );
624 }
625
626 /// The surviving claim is pinned by the final check: perturbing `s'` on an otherwise
627 /// honest proof is rejected.
628 #[test]
629 fn a_perturbed_final_claim_is_rejected() {
630 let ell = 6;
631 let t = base_poly(ell, 33);
632 let packed = pack::<F, EF>(t.clone());
633 let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(34), ell);
634 let s = t.eval_base(&r);
635
636 let mut p_chal = challenger();
637 let (mut proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
638 proof.final_eval += EF::ONE;
639
640 let mut v_chal = challenger();
641 assert_eq!(
642 verify_ring_switch::<F, EF, _>(&proof, &r, s, &mut v_chal),
643 Err(RingSwitchError::FinalCheck)
644 );
645 }
646
647 /// Perturbing one round message desynchronises every challenge sampled after it, so the
648 /// proof fails at the final check rather than being caught round-by-round.
649 #[test]
650 fn a_perturbed_round_message_is_rejected() {
651 let ell = 6;
652 let t = base_poly(ell, 35);
653 let packed = pack::<F, EF>(t.clone());
654 let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(36), ell);
655 let s = t.eval_base(&r);
656
657 let mut p_chal = challenger();
658 let (mut proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
659 proof.sumcheck.polynomial_evaluations[0][0] += EF::ONE;
660
661 let mut v_chal = challenger();
662 assert_eq!(
663 verify_ring_switch::<F, EF, _>(&proof, &r, s, &mut v_chal),
664 Err(RingSwitchError::FinalCheck)
665 );
666 }
667
668 /// A proof is bound to the evaluation point it was produced for. `ŝ` is a function of
669 /// `r_high` alone, so without `r` in the transcript the same proof would verify verbatim
670 /// at every `r_low`, against the honest claim at each of those points — a true statement
671 /// each time, but it makes the proof a transferable object rather than one tied to a
672 /// point. Observing `r` is what rejects it. The column check still passes here, since the
673 /// claim supplied really is `ŝ`'s column reading at the new `r_low`; the rejection comes
674 /// from the challenges the observed `r` moved.
675 #[test]
676 fn a_proof_does_not_verify_against_a_different_low_point() {
677 let ell = 6;
678 let kappa = packed_vars::<F, EF>();
679 let t = base_poly(ell, 37);
680 let packed = pack::<F, EF>(t.clone());
681 let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(38), ell);
682
683 let mut p_chal = challenger();
684 let (proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
685
686 // Same `r_high`, one coordinate of `r_low` moved, with the honest claim there.
687 let (r_high, r_low) = r.split_at(ell - kappa);
688 let mut moved = r_low.as_slice().to_vec();
689 moved[0] += EF::ONE;
690 let mut coords = r_high.as_slice().to_vec();
691 coords.extend_from_slice(&moved);
692 let other_r = Point::new(coords);
693 let other_s = t.eval_base(&other_r);
694
695 let mut v_chal = challenger();
696 assert_eq!(
697 verify_ring_switch::<F, EF, _>(&proof, &other_r, other_s, &mut v_chal),
698 Err(RingSwitchError::FinalCheck)
699 );
700 }
701
702 /// The `r_high` half is bound too, and this is the arm that matters. The verifier's only
703 /// `r_high`-dependent test is `A(r')` in the final check; moving one coordinate of `r_high`
704 /// by `δ` changes it by an `F`-linear function of `δ`, and wherever that function is
705 /// singular an unbound `r` would let a prover pick a nonzero `δ` from its kernel and have a
706 /// false input claim accepted alongside a *true* surviving claim — the one direction a
707 /// caller discharging `s'` against a commitment cannot catch. Here the claim is `ŝ`'s
708 /// column reading at the unchanged `r_low`, so the column arm passes by construction and
709 /// only the transcript can reject.
710 #[test]
711 fn a_proof_does_not_verify_against_a_different_high_point() {
712 let ell = 6;
713 let kappa = packed_vars::<F, EF>();
714 let t = base_poly(ell, 45);
715 let packed = pack::<F, EF>(t.clone());
716 let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(46), ell);
717 let s = t.eval_base(&r);
718
719 let mut p_chal = challenger();
720 let (proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
721
722 let (r_high, r_low) = r.split_at(ell - kappa);
723 let mut moved = r_high.as_slice().to_vec();
724 moved[0] += EF::ONE;
725 moved.extend_from_slice(r_low.as_slice());
726 let other_r = Point::new(moved);
727
728 let mut v_chal = challenger();
729 assert_eq!(
730 verify_ring_switch::<F, EF, _>(&proof, &other_r, s, &mut v_chal),
731 Err(RingSwitchError::FinalCheck)
732 );
733 }
734
735 /// This reduction never grinds, so a proof carrying PoW witnesses must be rejected rather
736 /// than accepted with junk data nothing checks.
737 #[test]
738 fn a_proof_with_pow_witnesses_is_rejected() {
739 let ell = 6;
740 let t = base_poly(ell, 40);
741 let packed = pack::<F, EF>(t.clone());
742 let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(41), ell);
743 let s = t.eval_base(&r);
744
745 let mut p_chal = challenger();
746 let (mut proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
747 proof.sumcheck.pow_witnesses.push(F::ONE);
748
749 let mut v_chal = challenger();
750 assert_eq!(
751 verify_ring_switch::<F, EF, _>(&proof, &r, s, &mut v_chal),
752 Err(RingSwitchError::NonEmptyPowWitnesses { actual: 1 })
753 );
754 }
755
756 /// An adaptive prover who tampers with `ŝ` before proving, then runs a genuine sumcheck from
757 /// the sum that tampered `ŝ` implies, is accepted by `verify_ring_switch` — with a surviving
758 /// claim that is false. This is the reduction working as documented, not a soundness gap: a
759 /// false input claim survives as a false surviving claim, caught only when the caller
760 /// discharges it against a commitment to the real packed polynomial, which this test does
761 /// not do. Contrast with `a_perturbed_tensor_element_is_rejected`, which perturbs `ŝ` after
762 /// proving and is rejected for an unrelated reason — transcript desynchronisation.
763 #[test]
764 fn an_adaptive_cheat_on_s_hat_is_accepted_with_a_false_surviving_claim() {
765 let ell = 6;
766 let t = base_poly(ell, 43);
767 let packed = pack::<F, EF>(t);
768 let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(44), ell);
769 let kappa = packed_vars::<F, EF>();
770 let ell_prime = ell - kappa;
771 let (r_high, r_low) = r.split_at(ell_prime);
772
773 // Tamper with `ŝ` before it ever reaches the transcript, unlike the post-hoc
774 // perturbation above.
775 let mut s_hat = compute_s_hat::<F, EF>(&packed, &r_high);
776 s_hat.perturb_for_test(0, F::ONE);
777
778 // The claim that makes the column check pass for the tampered element, formed the way
779 // the verifier forms it.
780 let eq_low = Poly::<EF>::new_from_point(r_low.as_slice(), EF::ONE);
781 let claimed_sum: EF = s_hat
782 .columns()
783 .iter()
784 .zip(eq_low.as_slice())
785 .map(|(&column, &weight)| column * weight)
786 .sum();
787
788 // Driven through the same transcript the honest prover uses.
789 //
790 // The tampered element reaches the sponge where an honest one would.
791 let mut chal = challenger();
792 let shape = RingSwitchShape::new(ell);
793 let mut transcript = RingSwitchProverTranscript::<Challenger, F, EF>::new(&mut chal, shape);
794 let r_batch = transcript.statement(&r, s_hat.coefficients());
795
796 // The genuine sumcheck's initial sum, derived from the tampered `ŝ` exactly as the
797 // verifier will derive it — not the true dot product of any real polynomial.
798 let shifted_sum = batch_rows::<F, EF>(&s_hat, &r_batch);
799
800 // A product polynomial that actually dots to `shifted_sum`: nothing downstream of this
801 // point checks that the evaluations are `packed`'s, only that the recorded rounds are
802 // internally consistent, so perturbing one evaluation is enough.
803 let weights = batched_weights::<F, EF>(&r_high, &r_batch);
804 let honest_sum = batch_rows::<F, EF>(&compute_s_hat::<F, EF>(&packed, &r_high), &r_batch);
805 let index = weights
806 .as_slice()
807 .iter()
808 .position(|&w| w != EF::ZERO)
809 .expect("a generic r_high, r_batch pair leaves no weight identically zero");
810 let mut fake_evals = packed.as_slice().to_vec();
811 fake_evals[index] += (shifted_sum - honest_sum) * weights.as_slice()[index].inverse();
812 let fake_packed = Poly::new(fake_evals);
813
814 let poly = ProductPolynomial::new_unpacked(VariableOrder::Prefix, fake_packed, weights);
815 let mut prover = SumcheckProver::new(poly, shifted_sum);
816 let mut sumcheck = SumcheckData::default();
817 let r_prime = transcript.batched_sumcheck(|challenger| {
818 prover.compute_sumcheck_polynomials(&mut sumcheck, challenger, ell_prime, 0, None)
819 });
820 let sum_final = prover.claimed_sum();
821
822 // `final_eval` set to whatever the final identity needs, computed the same way the
823 // verifier computes it.
824 let e = equality_element::<F, EF>(&r_high, &r_prime);
825 let a_r_prime = batch_rows::<F, EF>(&e, &r_batch);
826 let final_eval = sum_final * a_r_prime.inverse();
827
828 // The description is only fully played once the surviving claim is bound.
829 transcript.surviving_claim(final_eval);
830 transcript.finish();
831
832 let proof = RingSwitchProof {
833 s_hat,
834 sumcheck,
835 final_eval,
836 };
837
838 let mut v_chal = challenger();
839 let (r_prime_v, s_prime_v) =
840 verify_ring_switch::<F, EF, _>(&proof, &r, claimed_sum, &mut v_chal).unwrap();
841
842 assert_eq!(r_prime_v, r_prime);
843 assert_eq!(s_prime_v, final_eval);
844 assert_ne!(s_prime_v, packed.eval_ext::<F>(&r_prime_v));
845 }
846
847 /// The evaluation point must name at least the packed variables, or the split that
848 /// separates `r_high` from `r_low` is not defined.
849 #[test]
850 #[should_panic(expected = "the evaluation point must name at least the 4 packed variables")]
851 fn prove_rejects_a_point_shorter_than_the_packed_variables() {
852 let t = base_poly(6, 19);
853 let packed = pack::<F, EF>(t);
854 let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(20), 3);
855 let mut chal = challenger();
856 let _ = prove_ring_switch::<F, EF, _>(&packed, &r, &mut chal);
857 }
858
859 /// A packed polynomial of the wrong arity must panic rather than be zipped against a
860 /// weight table of a different length.
861 #[test]
862 #[should_panic(expected = "the packed polynomial must have the 2 variables")]
863 fn prove_rejects_a_packed_polynomial_of_the_wrong_arity() {
864 let t = base_poly(7, 21);
865 let packed = pack::<F, EF>(t);
866 let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(22), 6);
867 let mut chal = challenger();
868 let _ = prove_ring_switch::<F, EF, _>(&packed, &r, &mut chal);
869 }
870
871 /// The verifier's own point is subject to the same requirement.
872 #[test]
873 #[should_panic(expected = "the evaluation point must name at least the 4 packed variables")]
874 fn verify_rejects_a_point_shorter_than_the_packed_variables() {
875 let ell = 6;
876 let t = base_poly(ell, 26);
877 let packed = pack::<F, EF>(t);
878 let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(27), ell);
879
880 let mut p_chal = challenger();
881 let (proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
882
883 let short = Point::<EF>::rand(&mut SmallRng::seed_from_u64(28), 3);
884 let mut v_chal = challenger();
885 let _ = verify_ring_switch::<F, EF, _>(&proof, &short, EF::ZERO, &mut v_chal);
886 }
887
888 /// The round count is taken from `r`, so a proof carrying a different one is rejected
889 /// rather than silently desynchronising the transcript.
890 #[test]
891 fn verify_rejects_a_proof_with_the_wrong_round_count() {
892 let ell = 6;
893 let t = base_poly(ell, 29);
894 let packed = pack::<F, EF>(t.clone());
895 let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(30), ell);
896 let s = t.eval_base(&r);
897
898 let mut p_chal = challenger();
899 let (mut proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
900 proof.sumcheck.polynomial_evaluations.pop();
901
902 let mut v_chal = challenger();
903 assert_eq!(
904 verify_ring_switch::<F, EF, _>(&proof, &r, s, &mut v_chal),
905 Err(RingSwitchError::Sumcheck(
906 SumcheckError::RoundCountMismatch {
907 expected: ell - 4,
908 actual: ell - 5,
909 }
910 ))
911 );
912 }
913
914 /// A `ŝ` of the wrong size is reported rather than read out of range: both of its
915 /// readings index a `DIMENSION × DIMENSION` matrix.
916 #[test]
917 fn verify_rejects_a_malformed_tensor() {
918 let ell = 6;
919 let t = base_poly(ell, 31);
920 let packed = pack::<F, EF>(t.clone());
921 let r = Point::<EF>::rand(&mut SmallRng::seed_from_u64(32), ell);
922 let s = t.eval_base(&r);
923
924 let mut p_chal = challenger();
925 let (mut proof, _, _) = prove_ring_switch::<F, EF, _>(&packed, &r, &mut p_chal);
926 proof.s_hat.truncate_for_test(1);
927
928 let dimension = TensorAlgebra::<F, EF>::DIMENSION;
929 let mut v_chal = challenger();
930 assert_eq!(
931 verify_ring_switch::<F, EF, _>(&proof, &r, s, &mut v_chal),
932 Err(RingSwitchError::MalformedTensor {
933 expected: dimension * dimension,
934 actual: 1,
935 })
936 );
937 }
938}