@@ -610,6 +610,114 @@ TYPED_TEST(ShpleminiTest, HighDegreeAttackReject)
610610 }
611611}
612612
613+ /* *
614+ * @brief Soundness of the to-be-shifted PCS backstop: a commitment with a non-zero constant
615+ * coefficient must be rejected.
616+ *
617+ * @details For "to-be-shifted-by-one" polynomials (e.g. z_perm in Honk flavors), the verifier
618+ * batches the commitment com(p) with scalar r^{-1} against the claimed MLE evaluation
619+ * p_shift(u). With G(X) the univariate whose coefficients are p in Lagrange basis, the
620+ * commitment side opens to G(r)/r = p[0]/r + G_shift(r), while the MLE side delivers
621+ * G_shift(r) via the Gemini fold. The two sides differ by the algebraic term p[0]/r; when
622+ * p[0] != 0 the would-be Shplonk quotient is a rational function rather than a polynomial,
623+ * and the KZG pairing check rejects with overwhelming probability over the FS challenges.
624+ *
625+ * This is the implicit PCS-shift backstop that several relations rely on to enforce
626+ * z_perm[0] = 0 in disabled rows of ZK flavors (where row-disabling zeros the explicit
627+ * lagrange_first * z_perm subrelation). The test commits to (p + c * delta_0), claims the
628+ * shifted MLE evaluation of the honest p (which is unchanged by adding c at index 0, since
629+ * shifting drops the constant term), and confirms the verifier rejects.
630+ */
631+ TYPED_TEST (ShpleminiTest, ToBeShiftedNonZeroConstantTermRejected)
632+ {
633+ using Curve = typename TypeParam::Curve;
634+ using Fr = typename Curve::ScalarField;
635+ using GroupElement = typename Curve::Element;
636+ using Commitment = typename Curve::AffineElement;
637+ using CK = typename TypeParam::CommitmentKey;
638+ using ShpleminiProver = ShpleminiProver_<Curve>;
639+ using ShpleminiVerifier = ShpleminiVerifier_<Curve>;
640+
641+ CK ck = create_commitment_key<CK >(this ->n );
642+
643+ auto mle_opening_point = this ->random_evaluation_point (this ->log_n );
644+
645+ MockClaimGenerator<Curve> mock_claims (this ->n ,
646+ /* num_polynomials*/ this ->num_polynomials ,
647+ /* num_to_be_shifted*/ this ->num_shiftable ,
648+ mle_opening_point,
649+ ck);
650+
651+ auto prover_transcript = NativeTranscript::test_prover_init_empty ();
652+
653+ const auto opening_claim =
654+ ShpleminiProver::prove (this ->n , mock_claims.polynomial_batcher , mle_opening_point, ck, prover_transcript);
655+
656+ // For KZG, run the opening proof now: KZG never binds the claim into Fiat-Shamir, so the
657+ // verifier can be handed a tampered claim later without affecting the prover transcript.
658+ // For IPA, defer the opening proof until after the tampered batched claim is available; the
659+ // adversarial prover hashes that claim into its FS buffer to match the verifier (see below).
660+ if constexpr (!std::is_same_v<TypeParam, GrumpkinSettings>) {
661+ KZG <Curve>::compute_opening_proof (ck, opening_claim, prover_transcript);
662+ }
663+
664+ // Simulate adversary: replace the first to-be-shifted commitment with com(p + c * delta_0),
665+ // i.e. add c * [1]_1 to it. The shifted MLE evaluation is unchanged (shifting drops the [0]
666+ // coefficient). The unshifted MLE evaluation of p + c * delta_0 differs from the unshifted MLE
667+ // evaluation of p by c * prod_i (1 - u_i); update the unshifted counterpart claim accordingly
668+ // so that any rejection cannot be attributed to a stale unshifted-side mismatch.
669+ const Fr c = Fr::random_element ();
670+ const Commitment g1_identity = this ->vk ().get_g1_identity ();
671+ const auto tampered = Commitment (GroupElement (mock_claims.to_be_shifted .commitments [0 ]) + g1_identity * c);
672+
673+ Fr lagrange0_at_u = Fr (1 );
674+ for (const auto & u_i : mle_opening_point) {
675+ lagrange0_at_u *= (Fr (1 ) - u_i);
676+ }
677+ const size_t unshifted_idx = this ->num_polynomials - this ->num_shiftable ; // first to-be-shifted in unshifted batch
678+ mock_claims.to_be_shifted .commitments [0 ] = tampered;
679+ mock_claims.unshifted .commitments [unshifted_idx] = tampered;
680+ mock_claims.unshifted .evals [unshifted_idx] += c * lagrange0_at_u;
681+
682+ auto verifier_transcript = NativeTranscript::test_verifier_init_empty (prover_transcript);
683+
684+ auto batch_opening_claim = ShpleminiVerifier::compute_batch_opening_claim (
685+ mock_claims.claim_batcher , mle_opening_point, g1_identity, verifier_transcript)
686+ .batch_opening_claim ;
687+
688+ if constexpr (std::is_same_v<TypeParam, GrumpkinSettings>) {
689+ // Adversarial IPA prover: stage the prover transcript with the same reduced claim the
690+ // verifier will hash via add_claim_to_hash_buffer, then fold the honest polynomial. This
691+ // keeps prover/verifier FS in sync so the rejection isolates the inner-product relation
692+ // rather than transcript divergence.
693+ const auto reduced = TestFixture::IPA::reduce_batch_opening_claim (batch_opening_claim);
694+ prover_transcript->add_to_hash_buffer (" IPA:commitment" , reduced.commitment );
695+ prover_transcript->add_to_hash_buffer (" IPA:challenge" , reduced.opening_pair .challenge );
696+ prover_transcript->add_to_hash_buffer (" IPA:evaluation" , reduced.opening_pair .evaluation );
697+ TestFixture::IPA::compute_opening_proof_internal (ck, opening_claim, prover_transcript);
698+
699+ // The verifier transcript was initialized before the IPA prover wrote its bytes; refresh
700+ // its view of proof_data so the IPA verifier can read them.
701+ verifier_transcript->test_get_proof_data () = prover_transcript->test_get_proof_data ();
702+
703+ auto result =
704+ TestFixture::IPA::reduce_verify_batch_opening_claim (batch_opening_claim, this ->vk (), verifier_transcript);
705+ EXPECT_EQ (result, false );
706+ } else {
707+ const auto pairing_points =
708+ KZG <Curve>::reduce_verify_batch_opening_claim (std::move (batch_opening_claim), verifier_transcript);
709+ EXPECT_EQ (pairing_points.check (), false );
710+ }
711+
712+ // Confirm the rejection is not an artifact of transcript divergence: a fresh challenge with
713+ // the same label drawn from both transcripts must agree. For KZG this is automatic (the
714+ // claim is never hashed into FS). For IPA we matched the prover and verifier hash buffers
715+ // explicitly above; if this check fails, the rejection above could be attributed to the
716+ // prover and verifier consuming different challenges rather than the PCS check itself.
717+ EXPECT_EQ (prover_transcript->template get_challenge <Fr>(" transcript_sync_check" ),
718+ verifier_transcript->template get_challenge <Fr>(" transcript_sync_check" ));
719+ }
720+
613721/* *
614722 * @brief Test that consistency_checked is false when a Libra univariate evaluation is corrupted.
615723 * @details This test simulates a malicious prover sending a corrupted Libra evaluation via the
0 commit comments