diff --git a/.github/workflows/fuzz-crates.yml b/.github/workflows/fuzz-crates.yml index 7e75763b52..79f43addb7 100644 --- a/.github/workflows/fuzz-crates.yml +++ b/.github/workflows/fuzz-crates.yml @@ -29,7 +29,6 @@ jobs: - jolt-field - jolt-poly - jolt-sumcheck - - jolt-transcript name: Fuzz ${{ matrix.crate }} concurrency: group: fuzz-crates-${{ github.head_ref || github.ref }}-${{ matrix.crate }} diff --git a/CLAUDE.md b/CLAUDE.md index 7cf20e6ce7..35c79b95dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ Arkworks dependencies use a fork: `a16z/arkworks-algebra` branch `dev/twist-shou - `field/`: `JoltField` trait and BN254 scalar field implementation - `subprotocols/`: Sumcheck (batched, streaming, univariate skip), booleanity checks, BlindFold ZK protocol - `msm/`: Multi-scalar multiplication -- `transcripts/`: Fiat-Shamir transcripts (Blake2b, Keccak) +- `transcript_msgs.rs`: jolt-core's Fiat-Shamir vocabulary over the spongefish NARG (`FsAbsorb`/`FsChallenge`/`ProverFs`/`VerifierFs` from the `jolt-transcript` crate). The old hand-rolled `transcripts/` module was deleted in the spongefish migration. **tracer** — RISC-V emulator producing execution traces (`Cycle` per instruction) @@ -89,9 +89,11 @@ Most core types are generic over three parameters: ``` F: JoltField — scalar field (BN254 Fr) PCS: CommitmentScheme — polynomial commitment (DoryCommitmentScheme) -ProofTranscript: Transcript — Fiat-Shamir transcript (Blake2bTranscript) +H: DuplexSpongeInterface — spongefish sponge (RV64IMACSponge: Blake2b512 default / Keccak / Poseidon, cfg-selected) ``` +Prove/verify take the role-split spongefish surface (`ProverFs` / `VerifierFs` from `transcript_msgs.rs`), not a single `Transcript` trait. The proof carries the sponge as `PhantomData (C, H)>` — a compile-time link so a proof made under sponge `H` can only be verified under `H`. + ### Prover Pipeline 1. **Trace**: Execute guest ELF in tracer emulator → `Vec` + `JoltDevice` (I/O) @@ -136,16 +138,17 @@ The `zk` Cargo feature (`cfg(feature = "zk")`) controls zero-knowledge mode: |---|---|---| | Sumcheck proving | `BatchedSumcheck::prove` — cleartext round polys | `BatchedSumcheck::prove_zk` — Pedersen-committed | | Uni-skip | `prove_uniskip_round` | `prove_uniskip_round_zk` | -| Proof contains | `Claims` (all opening claims) | `BlindFoldProof` (no cleartext claims) | +| Proof contains | NARG + `Claims` (opening claims, structural) | NARG only — BlindFold written per-field into the NARG (no `BlindFoldProof` field) | | `input_claim()` | Called, appended to Fiat-Shamir transcript | Skipped; `input_claim_constraint()` used by BlindFold | | Output claim check | Explicit equality check | Skipped; verified by BlindFold R1CS | | Opening proof | `bind_opening_inputs` (raw eval) | `bind_opening_inputs_zk` (committed eval) | **Key cfg-gated items:** -- `JoltProof::opening_claims: Claims` — `#[cfg(not(feature = "zk"))]` -- `JoltProof::blindfold_proof: BlindFoldProof` — `#[cfg(feature = "zk")]` -- Prover uses `#[cfg(feature = "zk")]` / `#[cfg(not(feature = "zk"))]` blocks — compile-time path selection, no runtime `zk_mode` field -- Verifier detects mode from proof at runtime: `proof.stage1_sumcheck_proof.is_zk()` — stored as `VerifierOpeningAccumulator::zk_mode` +- `JoltProof::opening_claims: Claims` — `#[cfg(not(feature = "zk"))]`. **Structural** (a deliberate NARG hard-limit), NOT in the NARG. +- `JoltProof::blindfold_proof` was **removed** under the full-NARG migration: the BlindFold proof is written **per-field into the NARG** (`subprotocols/blindfold/protocol.rs`), not a struct field. The dory `joint_opening_proof` likewise stays structural (the other NARG hard-limit); everything else (commitments, advice presence frame, sumcheck/uni-skip round data, ZK Pedersen commitments) lives in the `JoltProof::narg` byte-string, sealed by `check_eof` + the MAL-1 outer-envelope guard. +- `JoltProof::zk_mode: bool` — a **runtime** mode flag that replaced the per-stage `SumcheckInstanceProof::{Clear,Zk}` / `UniSkipFirstRoundProofVariant` markers and `verify_zk_consistency` (both removed). The prover sets it from `cfg!(feature = "zk")`; the verifier selects which NARG read-frames to expect off `proof.zk_mode`. +- Prover uses `#[cfg(feature = "zk")]` / `#[cfg(not(feature = "zk"))]` blocks for compile-time path selection; the proof additionally carries the runtime `zk_mode` bool so the (compile-time-mode-agnostic) verifier can dispatch. +- Verifier detects mode at runtime via `proof.zk_mode` — stored as `VerifierOpeningAccumulator::zk_mode`. **CRITICAL — Verifier `new_from_verifier` must support both modes:** diff --git a/Cargo.lock b/Cargo.lock index 0db7b64433..f69d1284b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2799,6 +2799,7 @@ dependencies = [ name = "jolt-blindfold" version = "0.1.0" dependencies = [ + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "jolt-claims", "jolt-crypto", "jolt-field", @@ -2853,7 +2854,7 @@ dependencies = [ "jolt-optimizations", "jolt-program", "jolt-riscv", - "light-poseidon", + "jolt-transcript", "memory-stats", "num", "num-derive 0.4.2", @@ -2914,6 +2915,7 @@ dependencies = [ "jolt-openings", "jolt-poly", "jolt-transcript", + "rand 0.8.5", "rand_chacha 0.3.1", "rand_core 0.6.4", "rayon", @@ -3144,6 +3146,7 @@ dependencies = [ name = "jolt-openings" version = "0.1.0" dependencies = [ + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "criterion", "jolt-crypto", "jolt-field", @@ -3291,14 +3294,13 @@ dependencies = [ name = "jolt-sumcheck" version = "0.1.0" dependencies = [ + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "jolt-crypto", "jolt-field", "jolt-openings", "jolt-poly", "jolt-r1cs", "jolt-transcript", - "num-traits", - "rand_core 0.6.4", "serde", "thiserror 2.0.18", "tracing", @@ -3310,10 +3312,9 @@ version = "0.1.0" dependencies = [ "ark-bn254 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "ark-ff 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", - "criterion", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "jolt-field", "light-poseidon", - "num-traits", "rand 0.8.5", "spongefish", ] @@ -5823,10 +5824,11 @@ dependencies = [ [[package]] name = "spongefish" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "074f823019979d89e8d46a966feb3d173f3db9a21c6764f8c2282e137017bba5" +checksum = "eb0d95570dbcde026922fee5a1a588b553c4d80395bfe73522de41bc42c7b3fc" dependencies = [ + "ark-ec 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", "ark-ff 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", "ark-serialize 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", "blake2 0.11.0-rc.6", @@ -5836,6 +5838,18 @@ dependencies = [ "rand 0.8.5", "sha2 0.11.0", "sha3 0.11.0-rc.9", + "spongefish-derive", +] + +[[package]] +name = "spongefish-derive" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f51141f0144afcfb88571b29aa160006e63d56ffaaa16d364be382aaa73d61c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -6288,11 +6302,15 @@ dependencies = [ "clap", "common", "jolt-core", + "jolt-transcript", "light-poseidon", "num-bigint", + "postcard", "rand_core 0.6.4", + "rayon", "serde", "serde_json", + "serial_test", "zklean-extractor", ] diff --git a/Cargo.toml b/Cargo.toml index 88323d8c87..55e1f77371 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,8 +110,8 @@ members = [ "examples/sig-recovery/host", "examples/sig-recovery/guest", "zklean-extractor", - "z3-verifier", "transpiler", + "z3-verifier", "jolt-eval", "jolt-eval/macros", "jolt-eval/guest-sandbox", @@ -259,7 +259,7 @@ blake2 = "0.11.0-rc.6" blake3 = { version = "1.8.5" } light-poseidon = "0.4" digest = "0.11" -spongefish = { version = "0.6.1", default-features = false, features = [ +spongefish = { version = "0.6.2", default-features = false, features = [ "sha3", ] } jolt-optimizations = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" } diff --git a/crates/jolt-blindfold/Cargo.toml b/crates/jolt-blindfold/Cargo.toml index d0d9f572de..cfa208d3ee 100644 --- a/crates/jolt-blindfold/Cargo.toml +++ b/crates/jolt-blindfold/Cargo.toml @@ -9,6 +9,7 @@ description = "Generic BlindFold verifier infrastructure for the Jolt zkVM" workspace = true [dependencies] +ark-serialize.workspace = true jolt-claims.workspace = true jolt-crypto.workspace = true jolt-field.workspace = true diff --git a/crates/jolt-blindfold/src/protocol.rs b/crates/jolt-blindfold/src/protocol.rs index 6f206b2d07..f54494fc62 100644 --- a/crates/jolt-blindfold/src/protocol.rs +++ b/crates/jolt-blindfold/src/protocol.rs @@ -611,6 +611,7 @@ mod tests { use crate::{ BlindFoldStage, BlindFoldStatement, CommittedClaimRows, FinalOpeningBinding, OpeningAlias, }; + use ark_serialize::CanonicalSerialize; use jolt_claims::{constant, opening, Expr}; use jolt_crypto::{Bn254, Bn254G1, JoltGroup, Pedersen, PedersenSetup, VectorCommitment}; use jolt_field::{Fr, FromPrimitiveInt}; @@ -618,7 +619,7 @@ mod tests { CommittedOutputClaims, CommittedRound, CommittedSumcheckProof, SumcheckDomainSpec, SumcheckError, SumcheckStatement, }; - use jolt_transcript::{AppendToTranscript, Blake2bTranscript, Transcript}; + use jolt_transcript::{prover_transcript, Blake2b512}; #[derive(Clone, Debug)] struct TestStage { @@ -696,7 +697,7 @@ mod tests { final_openings: Vec>, ) -> Result, VerificationError> where - Com: Clone + AppendToTranscript, + Com: Clone + CanonicalSerialize, { if stages.len() != proofs.len() { return Err(VerificationError::StageCountMismatch { @@ -705,7 +706,7 @@ mod tests { }); } - let mut transcript = Blake2bTranscript::::new(b"blindfold"); + let mut transcript = prover_transcript(b"blindfold", [0u8; 32], Blake2b512::default()); let mut next_opening_id = 0usize; let stages = stages .iter() @@ -744,7 +745,7 @@ mod tests { final_openings: Vec>, ) -> BlindFoldProtocol where - Com: Clone + AppendToTranscript, + Com: Clone + CanonicalSerialize, { let statement = try_statement_from_proofs(stages, proofs, final_openings) .expect("statement builds from committed proofs"); @@ -910,7 +911,8 @@ mod tests { let statement = SumcheckStatement::new(1, 1); let proof = proof(&[(11, 1)], &[21]); - let mut transcript = Blake2bTranscript::::new(b"blindfold-alias"); + let mut transcript = + prover_transcript(b"blindfold-alias", [0u8; 32], Blake2b512::default()); let consistency = proof .verify_committed_consistency(statement, &mut transcript) .expect("committed proof is consistent"); @@ -942,7 +944,8 @@ mod tests { let statement = SumcheckStatement::new(1, 1); let proof = proof(&[(11, 1)], &[21]); - let mut transcript = Blake2bTranscript::::new(b"blindfold-alias"); + let mut transcript = + prover_transcript(b"blindfold-alias", [0u8; 32], Blake2b512::default()); let consistency = proof .verify_committed_consistency(statement, &mut transcript) .expect("committed proof is consistent"); @@ -1047,7 +1050,11 @@ mod tests { fn rejects_extra_output_claim_rows_without_typed_openings() { let statement = SumcheckStatement::new(1, 1); let proof = proof(&[(11, 1)], &[21, 22]); - let mut transcript = Blake2bTranscript::::new(b"blindfold-output-row-count"); + let mut transcript = prover_transcript( + b"blindfold-output-row-count", + [0u8; 32], + Blake2b512::default(), + ); let consistency = proof .verify_committed_consistency(statement, &mut transcript) .expect("committed proof is consistent"); diff --git a/crates/jolt-blindfold/src/verify.rs b/crates/jolt-blindfold/src/verify.rs index d6a81baf51..44d74caa13 100644 --- a/crates/jolt-blindfold/src/verify.rs +++ b/crates/jolt-blindfold/src/verify.rs @@ -1,9 +1,10 @@ +use ark_serialize::CanonicalSerialize; use jolt_crypto::{HomomorphicCommitment, VectorCommitment, VectorCommitmentOpening}; use jolt_field::{Field, FieldCore, RingAccumulator, WithAccumulator}; use jolt_poly::EqPolynomial; use jolt_r1cs::{ConstraintMatrices, MatrixColumnContributions}; -use jolt_sumcheck::{BooleanHypercube, SumcheckClaim, SUMCHECK_ROUND_TRANSCRIPT_LABEL}; -use jolt_transcript::{AppendToTranscript, Label, Transcript}; +use jolt_sumcheck::{BooleanHypercube, SumcheckClaim}; +use jolt_transcript::{FsAbsorb, FsTranscript}; use crate::{ BlindFoldProof, BlindFoldProtocol, RelaxedError, RelaxedInstance, VerificationError, @@ -12,12 +13,11 @@ use crate::{ const OUTER_SUMCHECK_DEGREE: usize = 3; const INNER_SUMCHECK_DEGREE: usize = 2; -const INNER_SUMCHECK_LABEL: &[u8] = b"inner_sumcheck_poly"; impl BlindFoldProtocol where - F: Field + AppendToTranscript, - Com: Copy + HomomorphicCommitment + AppendToTranscript, + F: Field, + Com: Copy + HomomorphicCommitment + CanonicalSerialize, ::Accumulator: RingAccumulator, { pub fn verify( @@ -28,7 +28,7 @@ where ) -> Result<(), VerificationError> where VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { let folded = self.folded_instance_from_proof(proof, transcript)?; ensure_len( @@ -51,8 +51,8 @@ where impl BlindFoldProtocol where - F: Field + AppendToTranscript, - Com: Clone + HomomorphicCommitment + AppendToTranscript, + F: Field, + Com: Clone + HomomorphicCommitment + CanonicalSerialize, { fn folded_instance_from_proof( &self, @@ -60,16 +60,10 @@ where transcript: &mut T, ) -> Result, VerificationError> where - T: Transcript, + T: FsTranscript, { let committed = self.committed_relaxed_instance(&proof.auxiliary_row_commitments)?; - committed.append_to_transcript( - transcript, - b"bf_committed_u", - b"bf_committed_w", - b"bf_committed_e", - b"bf_committed_eval", - ); + committed.append_to_transcript(transcript); let random = self.random_relaxed_instance( &proof.random_round_commitments, @@ -79,16 +73,10 @@ where &proof.random_eval_commitments, proof.random_u, )?; - random.append_to_transcript( - transcript, - b"bf_random_u", - b"bf_random_w", - b"bf_random_e", - b"bf_random_eval", - ); + random.append_to_transcript(transcript); self.validate_cross_term_error_rows(&proof.cross_term_error_row_commitments)?; - transcript.append_values(b"bf_cross_e", &proof.cross_term_error_row_commitments); + transcript.absorb(&proof.cross_term_error_row_commitments); let folding_challenge = transcript.challenge(); Ok(committed.fold( @@ -101,31 +89,24 @@ where impl RelaxedInstance where - F: AppendToTranscript, - Com: AppendToTranscript, + F: Field, + Com: CanonicalSerialize, { - fn append_to_transcript( - &self, - transcript: &mut T, - u_label: &'static [u8], - witness_label: &'static [u8], - error_label: &'static [u8], - eval_label: &'static [u8], - ) where - T: Transcript, + fn append_to_transcript(&self, transcript: &mut T) + where + T: FsAbsorb, { - transcript.append(&Label(u_label)); - self.u.append_to_transcript(transcript); - transcript.append_values(witness_label, &self.witness_row_commitments); - transcript.append_values(error_label, &self.error_row_commitments); - transcript.append_values(eval_label, &self.eval_commitments); + transcript.absorb_field(&self.u); + transcript.absorb(&self.witness_row_commitments); + transcript.absorb(&self.error_row_commitments); + transcript.absorb(&self.eval_commitments); } } impl BlindFoldProtocol where - F: Field + AppendToTranscript, - Com: Copy + HomomorphicCommitment + AppendToTranscript, + F: Field, + Com: Copy + HomomorphicCommitment + CanonicalSerialize, ::Accumulator: RingAccumulator, { fn verify_outer_folded_r1cs( @@ -137,7 +118,7 @@ where ) -> Result, VerificationError> where VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { let error_row_count = self.dimensions.error.row_count; if error_row_count == 0 || !error_row_count.is_power_of_two() { @@ -169,17 +150,11 @@ where }); } - transcript.append(&Label(b"bf_spartan")); let tau = transcript.challenge_vector(num_vars); let claim = SumcheckClaim::new(num_vars, OUTER_SUMCHECK_DEGREE, F::zero()); let outer = proof .outer_sumcheck - .verify( - &claim, - BooleanHypercube, - SUMCHECK_ROUND_TRANSCRIPT_LABEL, - transcript, - ) + .verify(&claim, BooleanHypercube, transcript) .map_err(|source| VerificationError::OuterSumcheck { source })?; let (row_point, entry_point) = outer.point.split_at(row_vars); @@ -200,13 +175,8 @@ where }); } - transcript.append_values(b"bf_az_bz_cz", &[proof.az_rx, proof.bz_rx, proof.cz_rx]); - append_vector_opening( - transcript, - b"bf_error_opening", - b"bf_error_blind", - &proof.error_opening, - ); + transcript.absorb_field_slice(&[proof.az_rx, proof.bz_rx, proof.cz_rx]); + append_vector_opening(transcript, &proof.error_opening); Ok(OuterCheck { point: outer.point.into_vec(), @@ -217,7 +187,7 @@ where impl BlindFoldProof where F: Field, - Com: Copy + AppendToTranscript, + Com: Copy + CanonicalSerialize, { fn verify_folded_eval_commitments( &self, @@ -245,8 +215,8 @@ where impl BlindFoldProtocol where - F: Field + AppendToTranscript, - Com: Copy + HomomorphicCommitment + AppendToTranscript, + F: Field, + Com: Copy + HomomorphicCommitment + CanonicalSerialize, ::Accumulator: RingAccumulator, { fn verify_folded_eval_witness_bindings( @@ -258,7 +228,7 @@ where ) -> Result<(), VerificationError> where VC: VectorCommitment, - T: Transcript, + T: FsAbsorb, { let coordinates = self.final_opening_witness_coordinates()?; ensure_len( @@ -303,12 +273,7 @@ where }); } coordinate.require_dedicated_row(opening, "output", index)?; - append_vector_opening( - transcript, - b"bf_eval_out_open", - b"bf_eval_out_blind", - opening, - ); + append_vector_opening(transcript, opening); } if let Some(coordinate) = coordinates.blinding { @@ -327,12 +292,7 @@ where }); } coordinate.require_dedicated_row(opening, "blinding", index)?; - append_vector_opening( - transcript, - b"bf_eval_blind_open", - b"bf_eval_blind_bl", - opening, - ); + append_vector_opening(transcript, opening); } } @@ -398,8 +358,8 @@ impl WitnessCoordinate { impl BlindFoldProtocol where - F: Field + AppendToTranscript, - Com: Copy + HomomorphicCommitment + AppendToTranscript, + F: Field, + Com: Copy + HomomorphicCommitment + CanonicalSerialize, ::Accumulator: RingAccumulator, { fn verify_inner_folded_r1cs( @@ -412,7 +372,7 @@ where ) -> Result<(), VerificationError> where VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { let ra = transcript.challenge(); let rb = transcript.challenge(); @@ -454,12 +414,7 @@ where let inner_claim = SumcheckClaim::new(num_vars, INNER_SUMCHECK_DEGREE, claim); let inner = proof .inner_sumcheck - .verify( - &inner_claim, - BooleanHypercube, - INNER_SUMCHECK_LABEL, - transcript, - ) + .verify(&inner_claim, BooleanHypercube, transcript) .map_err(|source| VerificationError::InnerSumcheck { source })?; let (row_point, entry_point) = inner.point.split_at(row_vars); @@ -480,12 +435,7 @@ where }); } - append_vector_opening( - transcript, - b"bf_witness_opening", - b"bf_witness_blind", - &proof.witness_opening, - ); + append_vector_opening(transcript, &proof.witness_opening); Ok(()) } @@ -496,18 +446,13 @@ struct OuterCheck { point: Vec, } -fn append_vector_opening( - transcript: &mut T, - row_label: &'static [u8], - blinding_label: &'static [u8], - opening: &VectorCommitmentOpening, -) where - F: AppendToTranscript, - T: Transcript, +fn append_vector_opening(transcript: &mut T, opening: &VectorCommitmentOpening) +where + F: Field, + T: FsAbsorb, { - transcript.append_values(row_label, &opening.combined_vector); - transcript.append(&Label(blinding_label)); - opening.combined_blinding.append_to_transcript(transcript); + transcript.absorb_field_slice(&opening.combined_vector); + transcript.absorb_field(&opening.combined_blinding); } fn public_contributions( @@ -601,7 +546,7 @@ mod tests { use jolt_poly::CompressedPoly; use jolt_r1cs::ConstraintMatrices; use jolt_sumcheck::CompressedSumcheckProof; - use jolt_transcript::Blake2bTranscript; + use jolt_transcript::{prover_transcript, Blake2b512, FsChallenge}; fn f(value: u64) -> Fr { Fr::from_u64(value) @@ -829,23 +774,12 @@ mod tests { proof.random_u, ) .expect("random instance builds"); - let mut transcript = Blake2bTranscript::::new(b"blindfold-verify"); - committed.append_to_transcript( - &mut transcript, - b"bf_committed_u", - b"bf_committed_w", - b"bf_committed_e", - b"bf_committed_eval", - ); - random.append_to_transcript( - &mut transcript, - b"bf_random_u", - b"bf_random_w", - b"bf_random_e", - b"bf_random_eval", - ); - transcript.append_values(b"bf_cross_e", &proof.cross_term_error_row_commitments); - transcript.challenge() + let mut transcript = + prover_transcript(b"blindfold-verify", [0u8; 32], Blake2b512::default()); + committed.append_to_transcript(&mut transcript); + random.append_to_transcript(&mut transcript); + transcript.absorb(&proof.cross_term_error_row_commitments); + FsChallenge::::challenge(&mut transcript) } fn proof_with_valid_eval_opening( @@ -864,7 +798,8 @@ mod tests { let setup = setup(); let protocol = protocol(&setup); let proof = proof(&setup, &protocol); - let mut transcript = Blake2bTranscript::::new(b"blindfold-verify"); + let mut transcript = + prover_transcript(b"blindfold-verify", [0u8; 32], Blake2b512::default()); let error = protocol .verify::, _>(&proof, &setup, &mut transcript) @@ -884,7 +819,8 @@ mod tests { let protocol = protocol(&setup); let proof = proof(&setup, &protocol); - let mut transcript = Blake2bTranscript::::new(b"blindfold-verify"); + let mut transcript = + prover_transcript(b"blindfold-verify", [0u8; 32], Blake2b512::default()); let folded = protocol .folded_instance_from_proof(&proof, &mut transcript) .expect("fold inputs are well-shaped"); @@ -902,23 +838,12 @@ mod tests { proof.random_u, ) .expect("random instance builds"); - let mut manual_transcript = Blake2bTranscript::::new(b"blindfold-verify"); - committed.append_to_transcript( - &mut manual_transcript, - b"bf_committed_u", - b"bf_committed_w", - b"bf_committed_e", - b"bf_committed_eval", - ); - random.append_to_transcript( - &mut manual_transcript, - b"bf_random_u", - b"bf_random_w", - b"bf_random_e", - b"bf_random_eval", - ); - manual_transcript.append_values(b"bf_cross_e", &proof.cross_term_error_row_commitments); - let folding_challenge = manual_transcript.challenge(); + let mut manual_transcript = + prover_transcript(b"blindfold-verify", [0u8; 32], Blake2b512::default()); + committed.append_to_transcript(&mut manual_transcript); + random.append_to_transcript(&mut manual_transcript); + manual_transcript.absorb(&proof.cross_term_error_row_commitments); + let folding_challenge = FsChallenge::::challenge(&mut manual_transcript); let expected = committed .fold( &random, @@ -928,7 +853,11 @@ mod tests { .expect("fold dimensions match"); assert_eq!(folded, expected); - assert_eq!(transcript.state(), manual_transcript.state()); + // The spongefish surface has no `state()` peek; instead assert both + // transcripts advanced identically by squeezing one more challenge. + let post: Fr = FsChallenge::::challenge_scalar(&mut transcript); + let manual_post: Fr = FsChallenge::::challenge_scalar(&mut manual_transcript); + assert_eq!(post, manual_post, "transcripts diverged after folding"); } #[test] @@ -937,7 +866,8 @@ mod tests { let protocol = coefficient_row_protocol(); let mut proof = proof(&setup, &protocol); let _ = proof.random_round_commitments.pop(); - let mut transcript = Blake2bTranscript::::new(b"blindfold-verify"); + let mut transcript = + prover_transcript(b"blindfold-verify", [0u8; 32], Blake2b512::default()); let error = protocol .verify::, _>(&proof, &setup, &mut transcript) @@ -958,7 +888,8 @@ mod tests { let protocol = protocol(&setup); let mut proof = proof(&setup, &protocol); proof.folded_eval_outputs.push(f(7)); - let mut transcript = Blake2bTranscript::::new(b"blindfold-verify"); + let mut transcript = + prover_transcript(b"blindfold-verify", [0u8; 32], Blake2b512::default()); let error = protocol .verify::, _>(&proof, &setup, &mut transcript) @@ -975,7 +906,8 @@ mod tests { let setup = setup(); let protocol = protocol_with_eval(&setup); let proof = proof_with_valid_eval_opening(&setup, &protocol); - let mut transcript = Blake2bTranscript::::new(b"blindfold-verify"); + let mut transcript = + prover_transcript(b"blindfold-verify", [0u8; 32], Blake2b512::default()); let folded = protocol .folded_instance_from_proof(&proof, &mut transcript) .expect("folded instance builds"); @@ -991,7 +923,8 @@ mod tests { let protocol = protocol_with_eval(&setup); let mut proof = proof_with_valid_eval_opening(&setup, &protocol); proof.folded_eval_outputs[0] += f(1); - let mut transcript = Blake2bTranscript::::new(b"blindfold-verify"); + let mut transcript = + prover_transcript(b"blindfold-verify", [0u8; 32], Blake2b512::default()); let error = protocol .verify::, _>(&proof, &setup, &mut transcript) @@ -1009,7 +942,8 @@ mod tests { let protocol = outer_round_protocol(); let mut proof = proof(&setup, &protocol); let _ = proof.outer_sumcheck.round_polynomials.pop(); - let mut transcript = Blake2bTranscript::::new(b"blindfold-verify"); + let mut transcript = + prover_transcript(b"blindfold-verify", [0u8; 32], Blake2b512::default()); let error = protocol .verify::, _>(&proof, &setup, &mut transcript) @@ -1030,7 +964,8 @@ mod tests { let mut proof = proof(&setup, &protocol); proof.outer_sumcheck.round_polynomials[0] = CompressedPoly::new(vec![f(0), f(0), f(0), f(0)]); - let mut transcript = Blake2bTranscript::::new(b"blindfold-verify"); + let mut transcript = + prover_transcript(b"blindfold-verify", [0u8; 32], Blake2b512::default()); let error = protocol .verify::, _>(&proof, &setup, &mut transcript) @@ -1050,7 +985,8 @@ mod tests { let protocol = outer_round_protocol(); let mut proof = proof(&setup, &protocol); proof.error_opening.combined_blinding = f(1); - let mut transcript = Blake2bTranscript::::new(b"blindfold-verify"); + let mut transcript = + prover_transcript(b"blindfold-verify", [0u8; 32], Blake2b512::default()); let error = protocol .verify::, _>(&proof, &setup, &mut transcript) @@ -1069,7 +1005,8 @@ mod tests { let mut proof = proof(&setup, &protocol); proof.az_rx = f(1); proof.bz_rx = f(1); - let mut transcript = Blake2bTranscript::::new(b"blindfold-verify"); + let mut transcript = + prover_transcript(b"blindfold-verify", [0u8; 32], Blake2b512::default()); let error = protocol .verify::, _>(&proof, &setup, &mut transcript) @@ -1086,7 +1023,8 @@ mod tests { let setup = setup(); let protocol = inner_round_protocol(); let proof = proof(&setup, &protocol); - let mut transcript = Blake2bTranscript::::new(b"blindfold-verify"); + let mut transcript = + prover_transcript(b"blindfold-verify", [0u8; 32], Blake2b512::default()); let error = protocol .verify::, _>(&proof, &setup, &mut transcript) @@ -1110,7 +1048,8 @@ mod tests { let mut proof = proof(&setup, &protocol); add_zero_inner_round(&mut proof); proof.witness_opening.combined_blinding = f(1); - let mut transcript = Blake2bTranscript::::new(b"blindfold-verify"); + let mut transcript = + prover_transcript(b"blindfold-verify", [0u8; 32], Blake2b512::default()); let error = protocol .verify::, _>(&proof, &setup, &mut transcript) @@ -1136,7 +1075,8 @@ mod tests { combined_vector: vec![f(5)], combined_blinding: f(50), }; - let mut transcript = Blake2bTranscript::::new(b"blindfold-verify"); + let mut transcript = + prover_transcript(b"blindfold-verify", [0u8; 32], Blake2b512::default()); let error = protocol .verify::, _>(&proof, &setup, &mut transcript) diff --git a/crates/jolt-blindfold/tests/proof_pipeline.rs b/crates/jolt-blindfold/tests/proof_pipeline.rs index af1e3e6058..0a5e982593 100644 --- a/crates/jolt-blindfold/tests/proof_pipeline.rs +++ b/crates/jolt-blindfold/tests/proof_pipeline.rs @@ -4,7 +4,7 @@ mod support; use jolt_blindfold::VerificationError; use jolt_poly::CompressedPoly; -use jolt_transcript::{Blake2bTranscript, Transcript}; +use jolt_transcript::{prover_transcript, Blake2b512}; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; use support::*; @@ -12,7 +12,11 @@ use support::*; fn verify_blindfold_protocol_pipeline( full: &BlindFoldTestProof, ) -> Result<(), VerificationError> { - let mut transcript = Blake2bTranscript::::new(b"protocol-backed-blindfold-proof"); + let mut transcript = prover_transcript( + b"protocol-backed-blindfold-proof", + [0u8; 32], + Blake2b512::default(), + ); append_protocol_transcript_prefix(&full.protocol, &mut transcript); full.protocol .verify::(&full.proof, &full.setup, &mut transcript) @@ -53,26 +57,14 @@ fn blindfold_protocol_pipeline_randomness_is_empirically_independent() { let values = [ field_low_u64(full.proof.random_u), - transcript_projection( - b"auxiliary_commitment", - &full.proof.auxiliary_row_commitments[0], - ), - transcript_projection( - b"random_round_commitment", - &full.proof.random_round_commitments[0], - ), - transcript_projection( - b"random_error_commitment", - &full.proof.random_error_row_commitments[0], - ), - transcript_projection( - b"cross_term_commitment", - &full.proof.cross_term_error_row_commitments[0], - ), - compressed_sumcheck_projection(b"outer_sumcheck", &full.proof.outer_sumcheck), - compressed_sumcheck_projection(b"inner_sumcheck", &full.proof.inner_sumcheck), - opening_projection(b"witness_opening", &full.proof.witness_opening), - opening_projection(b"error_opening", &full.proof.error_opening), + transcript_projection(&full.proof.auxiliary_row_commitments[0]), + transcript_projection(&full.proof.random_round_commitments[0]), + transcript_projection(&full.proof.random_error_row_commitments[0]), + transcript_projection(&full.proof.cross_term_error_row_commitments[0]), + compressed_sumcheck_projection(&full.proof.outer_sumcheck), + compressed_sumcheck_projection(&full.proof.inner_sumcheck), + opening_projection(&full.proof.witness_opening), + opening_projection(&full.proof.error_opening), ]; for (projection, value) in projections.iter_mut().zip(values) { @@ -144,7 +136,7 @@ fn blindfold_protocol_pipeline_rejects_tampered_error_opening_blinding() { fn blindfold_protocol_pipeline_rejects_wrong_transcript() { let mut rng = ChaCha20Rng::from_seed([76; 32]); let full = prove_blindfold_protocol_pipeline(&mut rng); - let mut transcript = Blake2bTranscript::::new(b"wrong-transcript"); + let mut transcript = prover_transcript(b"wrong-transcript", [0u8; 32], Blake2b512::default()); append_protocol_transcript_prefix(&full.protocol, &mut transcript); assert!(full diff --git a/crates/jolt-blindfold/tests/sumcheck_pipeline.rs b/crates/jolt-blindfold/tests/sumcheck_pipeline.rs index 5baa60f38f..9d96f2df66 100644 --- a/crates/jolt-blindfold/tests/sumcheck_pipeline.rs +++ b/crates/jolt-blindfold/tests/sumcheck_pipeline.rs @@ -4,7 +4,7 @@ mod support; use jolt_crypto::VectorCommitment; use jolt_sumcheck::RoundMessage; -use jolt_transcript::{Blake2bTranscript, Transcript}; +use jolt_transcript::{prover_transcript, Blake2b512, FsChallenge}; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; use support::*; @@ -31,18 +31,9 @@ fn committed_sumcheck_pipeline_satisfies_deep_r1cs_and_randomness_checks() { assert!(build_deep_relation(&stage1, &stage2, &stage3, &values).is_ok()); let sample = [ - transcript_projection( - b"stage1_round_commitment", - &stage1.proof.rounds[0].commitment, - ), - transcript_projection( - b"stage2_round_commitment", - &stage2.proof.rounds[0].commitment, - ), - transcript_projection( - b"stage3_round_commitment", - &stage3.proof.rounds[0].commitment, - ), + transcript_projection(&stage1.proof.rounds[0].commitment), + transcript_projection(&stage2.proof.rounds[0].commitment), + transcript_projection(&stage3.proof.rounds[0].commitment), field_low_u64(stage1.blindings[0]), field_low_u64(stage2.blindings[0]), field_low_u64(stage3.blindings[0]), @@ -80,9 +71,13 @@ fn committed_round_blinding_is_empirically_independent_from_commitments_and_chal for _ in 0..SAMPLES { let blinding = rng_field(&mut rng); let round = commit_round_with_blinding(&setup, coefficients.clone(), blinding); - let mut transcript = Blake2bTranscript::::new(b"blindfold-r1cs-independence"); - round.append_to_transcript(&mut transcript); - let challenge = transcript.challenge(); + let mut transcript = prover_transcript( + b"blindfold-r1cs-independence", + [0u8; 32], + Blake2b512::default(), + ); + RoundMessage::::append_to_transcript(&round, &mut transcript); + let challenge: F = FsChallenge::::challenge(&mut transcript); assert!(VC::verify( &setup, @@ -97,10 +92,7 @@ fn committed_round_blinding_is_empirically_independent_from_commitments_and_chal &(blinding + f(1)) )); blindings.push(field_low_u64(blinding)); - commitments.push(transcript_projection( - b"round_commitment", - &round.commitment, - )); + commitments.push(transcript_projection(&round.commitment)); challenges.push(field_low_u64(challenge)); } diff --git a/crates/jolt-blindfold/tests/support/mod.rs b/crates/jolt-blindfold/tests/support/mod.rs index a7a0886951..121b9b1617 100644 --- a/crates/jolt-blindfold/tests/support/mod.rs +++ b/crates/jolt-blindfold/tests/support/mod.rs @@ -4,6 +4,7 @@ reason = "shared integration-test harness is intentionally broader than each test file" )] +use ark_serialize::CanonicalSerialize; use jolt_blindfold::{ BlindFoldProof, BlindFoldProtocol, BlindFoldStage, BlindFoldStatement, CommittedClaimRows, FinalOpeningBinding, WitnessCoordinate, @@ -18,9 +19,9 @@ use jolt_r1cs::{ClaimSourceTable, ConstraintMatrices, R1csBuilder}; use jolt_sumcheck::{ CommittedOutputClaims, CommittedRound, CommittedRoundWitness, CommittedSumcheckConsistency, CommittedSumcheckProof, CompressedSumcheckProof, RoundMessage, SumcheckDomainSpec, - SumcheckR1csLayout, SumcheckStatement, SUMCHECK_ROUND_TRANSCRIPT_LABEL, + SumcheckR1csLayout, SumcheckStatement, }; -use jolt_transcript::{AppendToTranscript, Blake2bTranscript, Label, Transcript}; +use jolt_transcript::{prover_transcript, Blake2b512, FsAbsorb, FsChallenge, FsTranscript}; use rand_core::RngCore; pub type F = Fr; @@ -151,37 +152,38 @@ pub fn field_low_u64(value: F) -> u64 { ]) } -pub fn transcript_projection(label: &'static [u8], value: &A) -> u64 { - let mut transcript = Blake2bTranscript::::new(b"blindfold-statistical-projection"); - transcript.append(&Label(label)); - value.append_to_transcript(&mut transcript); - field_low_u64(transcript.challenge()) +pub fn transcript_projection(value: &C) -> u64 { + let mut transcript = prover_transcript( + b"blindfold-statistical-projection", + [0u8; 32], + Blake2b512::default(), + ); + transcript.absorb(value); + field_low_u64(FsChallenge::::challenge(&mut transcript)) } -pub fn field_slice_projection(label: &'static [u8], values: &[F]) -> u64 { - let mut transcript = Blake2bTranscript::::new(b"blindfold-statistical-projection"); - transcript.append_values(label, values); - field_low_u64(transcript.challenge()) +pub fn field_slice_projection(values: &[F]) -> u64 { + let mut transcript = prover_transcript( + b"blindfold-statistical-projection", + [0u8; 32], + Blake2b512::default(), + ); + transcript.absorb_field_slice(values); + field_low_u64(FsChallenge::::challenge(&mut transcript)) } -pub fn compressed_sumcheck_projection( - label: &'static [u8], - proof: &CompressedSumcheckProof, -) -> u64 { +pub fn compressed_sumcheck_projection(proof: &CompressedSumcheckProof) -> u64 { let mut values = Vec::new(); for round in &proof.round_polynomials { values.extend_from_slice(round.coeffs_except_linear_term()); } - field_slice_projection(label, &values) + field_slice_projection(&values) } -pub fn opening_projection( - label: &'static [u8], - opening: &jolt_crypto::VectorCommitmentOpening, -) -> u64 { +pub fn opening_projection(opening: &jolt_crypto::VectorCommitmentOpening) -> u64 { let mut values = opening.combined_vector.clone(); values.push(opening.combined_blinding); - field_slice_projection(label, &values) + field_slice_projection(&values) } pub fn assert_empirical_distribution(projection: &StatisticalProjection) { @@ -383,7 +385,7 @@ impl SumcheckTestProver { pub fn prove_stage( &mut self, setup: &PedersenSetup, - transcript: &mut Blake2bTranscript, + transcript: &mut impl FsTranscript, statement: SumcheckStatement, input_claim: F, ) -> GeneratedStage { @@ -393,7 +395,7 @@ impl SumcheckTestProver { pub fn prove_stage_with_output_claims( &mut self, setup: &PedersenSetup, - transcript: &mut Blake2bTranscript, + transcript: &mut impl FsTranscript, statement: SumcheckStatement, input_claim: F, output_claim_count: usize, @@ -457,7 +459,7 @@ impl SumcheckTestProver { statement: SumcheckStatement, input_claim: F, ) -> GeneratedStage { - let mut transcript = Blake2bTranscript::::new(transcript_label); + let mut transcript = prover_transcript(transcript_label, [0u8; 32], Blake2b512::default()); self.prove_stage(setup, &mut transcript, statement, input_claim) } } @@ -485,7 +487,7 @@ pub fn stage_consistency( statement: SumcheckStatement, proof: &CommittedSumcheckProof, ) -> CommittedSumcheckConsistency { - let mut transcript = Blake2bTranscript::::new(b"blindfold-r1cs-e2e"); + let mut transcript = prover_transcript(b"blindfold-r1cs-e2e", [0u8; 32], Blake2b512::default()); proof .verify_committed_consistency(statement, &mut transcript) .expect("committed proof transcript verifies") @@ -501,7 +503,7 @@ pub fn stage_consistency_for_transcript_label( transcript_label: &'static [u8], stages: &[&GeneratedStage], ) -> Vec> { - let mut transcript = Blake2bTranscript::::new(transcript_label); + let mut transcript = prover_transcript(transcript_label, [0u8; 32], Blake2b512::default()); stages .iter() .map(|stage| { @@ -529,7 +531,7 @@ where stages.len(), "relations and generated stages must align" ); - let mut transcript = Blake2bTranscript::::new(transcript_label); + let mut transcript = prover_transcript(transcript_label, [0u8; 32], Blake2b512::default()); let stages = relations .iter() .zip(stages) @@ -712,7 +714,7 @@ pub fn generated_deep_triple( let setup = pedersen_setup(4); let statement = SumcheckStatement::new(4, 3); let mut values = deep_values_without_links(); - let mut transcript = Blake2bTranscript::::new(b"blindfold-r1cs-e2e"); + let mut transcript = prover_transcript(b"blindfold-r1cs-e2e", [0u8; 32], Blake2b512::default()); let stage1 = prover.prove_stage( &setup, &mut transcript, @@ -771,7 +773,7 @@ pub fn prove_blindfold_protocol_pipeline(rng: &mut R) -> BlindFoldTe let (stage1, stage2) = { let mut prover = SumcheckTestProver::new(&mut *rng); - let mut transcript = Blake2bTranscript::::new(transcript_label); + let mut transcript = prover_transcript(transcript_label, [0u8; 32], Blake2b512::default()); let stage1 = prover.prove_stage_with_output_claims(&setup, &mut transcript, statement1, input1, 2); let stage2 = @@ -794,7 +796,7 @@ pub fn prove_blindfold_protocol_pipeline(rng: &mut R) -> BlindFoldTe .map(|(&output, blinding)| VC::commit(&setup, &[output], blinding)) .collect::>(); - let mut transcript = Blake2bTranscript::::new(transcript_label); + let mut transcript = prover_transcript(transcript_label, [0u8; 32], Blake2b512::default()); let stage1_consistency = stage1 .proof .verify_committed_consistency(stage1.statement, &mut transcript) @@ -843,7 +845,7 @@ pub fn prove_blindfold_protocol_pipeline(rng: &mut R) -> BlindFoldTe ); let protocol = blindfold_protocol_from_statement(&statement) .expect("protocol builds from committed statement"); - let mut transcript = Blake2bTranscript::::new(transcript_label); + let mut transcript = prover_transcript(transcript_label, [0u8; 32], Blake2b512::default()); append_protocol_transcript_prefix(&protocol, &mut transcript); let (real_witness_rows, real_witness_blindings) = protocol_backed_witness( &protocol, @@ -905,7 +907,7 @@ where pub fn append_protocol_transcript_prefix( protocol: &BlindFoldProtocol, - transcript: &mut Blake2bTranscript, + transcript: &mut impl FsTranscript, ) { for (stage, output_claims) in protocol .sumcheck_consistency @@ -1056,7 +1058,7 @@ struct ProtocolWitness<'a> { fn prove_from_protocol_witness( setup: &PedersenSetup, protocol: &BlindFoldProtocol, - transcript: &mut Blake2bTranscript, + transcript: &mut impl FsTranscript, witness: ProtocolWitness<'_>, rng: &mut R, ) -> BlindFoldProof { @@ -1192,12 +1194,6 @@ fn prove_from_protocol_witness( append_relaxed_instance_from_parts( transcript, - RelaxedInstanceLabels { - u: b"bf_committed_u", - witness: b"bf_committed_w", - error: b"bf_committed_e", - eval: b"bf_committed_eval", - }, committed.u, &committed.witness_row_commitments, &committed.error_row_commitments, @@ -1205,18 +1201,12 @@ fn prove_from_protocol_witness( ); append_relaxed_instance_from_parts( transcript, - RelaxedInstanceLabels { - u: b"bf_random_u", - witness: b"bf_random_w", - error: b"bf_random_e", - eval: b"bf_random_eval", - }, random_u, &random_instance.witness_row_commitments, &random_instance.error_row_commitments, &random_instance.eval_commitments, ); - transcript.append_values(b"bf_cross_e", &cross_term_error_row_commitments); + transcript.absorb(&cross_term_error_row_commitments); let folding_challenge = transcript.challenge(); let folded_u = f(1) + folding_challenge * random_u; @@ -1277,43 +1267,25 @@ fn prove_from_protocol_witness( } } for opening in &folded_eval_output_openings { - append_vector_opening( - transcript, - b"bf_eval_out_open", - b"bf_eval_out_blind", - opening, - ); + append_vector_opening(transcript, opening); } for opening in &folded_eval_blinding_openings { - append_vector_opening( - transcript, - b"bf_eval_blind_open", - b"bf_eval_blind_bl", - opening, - ); + append_vector_opening(transcript, opening); } - transcript.append(&Label(b"bf_spartan")); let outer_num_vars = log2(protocol.dimensions.error.row_count) + log2(protocol.dimensions.error.row_len); let tau = transcript.challenge_vector(outer_num_vars); - let outer_trace = prove_slow_sumcheck( - outer_num_vars, - 3, - f(0), - SUMCHECK_ROUND_TRANSCRIPT_LABEL, - transcript, - |point| { - outer_function( - &protocol.r1cs, - folded_u, - &flatten(&folded_witness_rows), - &folded_error_rows, - &tau, - point, - ) - }, - ); + let outer_trace = prove_slow_sumcheck(outer_num_vars, 3, f(0), transcript, |point| { + outer_function( + &protocol.r1cs, + folded_u, + &flatten(&folded_witness_rows), + &folded_error_rows, + &tau, + point, + ) + }); let (az_rx, bz_rx, cz_rx) = abc_at_point( &protocol.r1cs, @@ -1333,13 +1305,8 @@ fn prove_from_protocol_witness( ) .expect("folded error rows open"); - transcript.append_values(b"bf_az_bz_cz", &[az_rx, bz_rx, cz_rx]); - append_vector_opening( - transcript, - b"bf_error_opening", - b"bf_error_blind", - &error_opening, - ); + transcript.absorb_field_slice(&[az_rx, bz_rx, cz_rx]); + append_vector_opening(transcript, &error_opening); let ra = transcript.challenge(); let rb = transcript.challenge(); @@ -1352,24 +1319,17 @@ fn prove_from_protocol_witness( .public_column_contributions(&row_weights, 0, folded_u) .expect("public column contributions evaluate"); let inner_claim = ra * (az_rx - public.a) + rb * (bz_rx - public.b) + rc * (cz_rx - public.c); - let inner_trace = prove_slow_sumcheck( - inner_num_vars, - 2, - inner_claim, - b"inner_sumcheck_poly", - transcript, - |point| { - inner_function( - &protocol.r1cs, - &outer_trace.point, - &folded_witness_rows, - ra, - rb, - rc, - point, - ) - }, - ); + let inner_trace = prove_slow_sumcheck(inner_num_vars, 2, inner_claim, transcript, |point| { + inner_function( + &protocol.r1cs, + &outer_trace.point, + &folded_witness_rows, + ra, + rb, + rc, + point, + ) + }); let (witness_row_point, witness_entry_point) = inner_trace .point .split_at(log2(protocol.dimensions.witness.row_count)); @@ -1438,38 +1398,28 @@ fn boolean_point(index: usize, num_vars: usize) -> Vec { .collect() } -#[derive(Clone, Copy, Debug)] -struct RelaxedInstanceLabels { - u: &'static [u8], - witness: &'static [u8], - error: &'static [u8], - eval: &'static [u8], -} - fn append_relaxed_instance_from_parts( - transcript: &mut Blake2bTranscript, - labels: RelaxedInstanceLabels, + transcript: &mut impl FsAbsorb, u: F, witness_commitments: &[Bn254G1], error_commitments: &[Bn254G1], eval_commitments: &[Bn254G1], ) { - transcript.append(&Label(labels.u)); - u.append_to_transcript(transcript); - transcript.append_values(labels.witness, witness_commitments); - transcript.append_values(labels.error, error_commitments); - transcript.append_values(labels.eval, eval_commitments); + transcript.absorb_field(&u); + // Absorb each commitment vector as a single value, like jolt-core + // (`absorb(&witness_row_commitments)`); the verifier side holds these as + // `Vec`, so absorb the same `Vec` serialization here. + transcript.absorb(&witness_commitments.to_vec()); + transcript.absorb(&error_commitments.to_vec()); + transcript.absorb(&eval_commitments.to_vec()); } fn append_vector_opening( - transcript: &mut Blake2bTranscript, - row_label: &'static [u8], - blinding_label: &'static [u8], + transcript: &mut impl FsAbsorb, opening: &jolt_crypto::VectorCommitmentOpening, ) { - transcript.append_values(row_label, &opening.combined_vector); - transcript.append(&Label(blinding_label)); - opening.combined_blinding.append_to_transcript(transcript); + transcript.absorb_field_slice(&opening.combined_vector); + transcript.absorb_field(&opening.combined_blinding); } fn zero_rows(row_count: usize, row_len: usize) -> Vec> { @@ -1659,8 +1609,7 @@ fn prove_slow_sumcheck( num_vars: usize, degree: usize, claim: F, - label: &'static [u8], - transcript: &mut Blake2bTranscript, + transcript: &mut impl FsTranscript, eval: impl Fn(&[F]) -> F, ) -> SumcheckTrace { let mut running_sum = claim; @@ -1689,7 +1638,9 @@ fn prove_slow_sumcheck( let mut compressed = Vec::with_capacity(degree); compressed.push(coefficients[0]); compressed.extend_from_slice(&coefficients[2..]); - transcript.append_values(label, &compressed); + // One message for the whole compressed coefficient vector, matching + // jolt-sumcheck's verifier (`absorb_field_slice(coeffs_except_linear_term)`). + transcript.absorb_field_slice(&compressed); let challenge = transcript.challenge(); running_sum = eval_poly(&coefficients, challenge); prefix.push(challenge); diff --git a/crates/jolt-crypto/src/commitment.rs b/crates/jolt-crypto/src/commitment.rs index 2727b5fd67..b2e3c9f9f6 100644 --- a/crates/jolt-crypto/src/commitment.rs +++ b/crates/jolt-crypto/src/commitment.rs @@ -3,9 +3,9 @@ use std::{ fmt::{self, Debug}, }; +use ark_serialize::CanonicalSerialize; use jolt_field::{AdditiveAccumulator, Field, RingAccumulator, WithAccumulator}; use jolt_poly::EqPolynomial; -use jolt_transcript::AppendToTranscript; use serde::{de::DeserializeOwned, Deserialize, Serialize}; #[cfg(feature = "parallel")] @@ -29,7 +29,7 @@ pub trait Commitment: Clone + Debug + Eq + Send + Sync + 'static { /// elements with a blinding factor. Uses `Self::Output` from the supertrait /// as the commitment value type. pub trait VectorCommitment: - Commitment + Commitment { type Field: Field; diff --git a/crates/jolt-crypto/src/ec/bn254/gt.rs b/crates/jolt-crypto/src/ec/bn254/gt.rs index 603089517e..6ba31e3a26 100644 --- a/crates/jolt-crypto/src/ec/bn254/gt.rs +++ b/crates/jolt-crypto/src/ec/bn254/gt.rs @@ -3,10 +3,9 @@ use std::ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub, SubAssign}; use ark_bn254::{Fq12, Fr}; use ark_ff::{AdditiveGroup, Field as ArkField, PrimeField}; +use ark_serialize::CanonicalSerialize; use jolt_field::Field; -use jolt_transcript::{AppendToTranscript, Transcript}; - use crate::JoltGroup; use super::field_to_fr; @@ -128,21 +127,19 @@ impl MulAssign for Bn254GT { } } -#[expect(clippy::expect_used)] -impl AppendToTranscript for Bn254GT { - fn append_to_transcript(&self, transcript: &mut T) { - use ark_serialize::CanonicalSerialize; - let mut buf = Vec::with_capacity(self.0.uncompressed_size()); - self.0 - .serialize_uncompressed(&mut buf) - .expect("GT serialization cannot fail"); - buf.reverse(); - transcript.append_bytes(&buf); +impl CanonicalSerialize for Bn254GT { + #[inline] + fn serialize_with_mode( + &self, + writer: W, + compress: ark_serialize::Compress, + ) -> Result<(), ark_serialize::SerializationError> { + self.0.serialize_with_mode(writer, compress) } - fn transcript_payload_len(&self) -> Option { - use ark_serialize::CanonicalSerialize; - Some(self.0.uncompressed_size() as u64) + #[inline] + fn serialized_size(&self, compress: ark_serialize::Compress) -> usize { + self.0.serialized_size(compress) } } diff --git a/crates/jolt-crypto/src/ec/bn254/mod.rs b/crates/jolt-crypto/src/ec/bn254/mod.rs index 38f713e6b9..c1bca5215e 100644 --- a/crates/jolt-crypto/src/ec/bn254/mod.rs +++ b/crates/jolt-crypto/src/ec/bn254/mod.rs @@ -5,7 +5,7 @@ //! the public API — all conversions happen internally. /// Generates a `#[repr(transparent)]` wrapper over an arkworks projective curve type, -/// with all operator impls, serde, `AppendToTranscript`, `JoltGroup`, compile-time +/// with all operator impls, serde, `CanonicalSerialize`, `JoltGroup`, compile-time /// size assertions, and a safe `into_inner` accessor. /// /// Paths are fully qualified so the macro does not inject `use` items into the caller's @@ -129,14 +129,19 @@ macro_rules! impl_jolt_group_wrapper { } } - impl ::jolt_transcript::AppendToTranscript for $wrapper { - fn append_to_transcript(&self, transcript: &mut T) { - use ::ark_serialize::CanonicalSerialize; - let mut buf = Vec::with_capacity(self.0.compressed_size()); - self.0 - .serialize_compressed(&mut buf) - .expect(concat!(stringify!($wrapper), " serialization cannot fail")); - transcript.append_bytes(&buf); + impl ::ark_serialize::CanonicalSerialize for $wrapper { + #[inline] + fn serialize_with_mode( + &self, + writer: W, + compress: ::ark_serialize::Compress, + ) -> Result<(), ::ark_serialize::SerializationError> { + self.0.serialize_with_mode(writer, compress) + } + + #[inline] + fn serialized_size(&self, compress: ::ark_serialize::Compress) -> usize { + self.0.serialized_size(compress) } } @@ -278,41 +283,54 @@ pub(crate) fn field_to_fr(f: &F) -> ark_bn254::Fr { mod tests { use ark_serialize::CanonicalSerialize; use jolt_field::Fr; - use jolt_transcript::{AppendToTranscript, Blake2bTranscript, Transcript}; + use jolt_transcript::{prover_transcript, Blake2b512, FsAbsorb, FsChallenge}; use super::Bn254; + const SESSION: &[u8] = b"jolt-crypto-ec-test/v1"; + + /// `FsAbsorb::absorb` of a group element absorbs exactly its compressed + /// serialization — so deriving a challenge after `absorb(&point)` matches + /// deriving it after absorbing the raw compressed bytes. #[test] fn g1_transcript_encoding_uses_compressed_commitment_bytes() { let point = Bn254::g1_generator(); - let mut actual = Blake2bTranscript::::new(b"test"); - point.append_to_transcript(&mut actual); + let instance = [0u8; 32]; + + let mut actual = prover_transcript(SESSION, instance, Blake2b512::default()); + FsAbsorb::absorb(&mut actual, &point); + let actual_challenge = FsChallenge::::challenge(&mut actual); - let mut expected = Blake2bTranscript::::new(b"test"); let mut bytes = Vec::new(); point .0 .serialize_compressed(&mut bytes) .expect("serialize G1"); - expected.append_bytes(&bytes); + let mut expected = prover_transcript(SESSION, instance, Blake2b512::default()); + FsAbsorb::absorb_bytes(&mut expected, &bytes); + let expected_challenge = FsChallenge::::challenge(&mut expected); - assert_eq!(actual.state(), expected.state()); + assert_eq!(actual_challenge, expected_challenge); } #[test] fn g2_transcript_encoding_uses_compressed_commitment_bytes() { let point = Bn254::g2_generator(); - let mut actual = Blake2bTranscript::::new(b"test"); - point.append_to_transcript(&mut actual); + let instance = [0u8; 32]; + + let mut actual = prover_transcript(SESSION, instance, Blake2b512::default()); + FsAbsorb::absorb(&mut actual, &point); + let actual_challenge = FsChallenge::::challenge(&mut actual); - let mut expected = Blake2bTranscript::::new(b"test"); let mut bytes = Vec::new(); point .0 .serialize_compressed(&mut bytes) .expect("serialize G2"); - expected.append_bytes(&bytes); + let mut expected = prover_transcript(SESSION, instance, Blake2b512::default()); + FsAbsorb::absorb_bytes(&mut expected, &bytes); + let expected_challenge = FsChallenge::::challenge(&mut expected); - assert_eq!(actual.state(), expected.state()); + assert_eq!(actual_challenge, expected_challenge); } } diff --git a/crates/jolt-crypto/src/ec/group.rs b/crates/jolt-crypto/src/ec/group.rs index 9268a1bdc4..6fe82b2a85 100644 --- a/crates/jolt-crypto/src/ec/group.rs +++ b/crates/jolt-crypto/src/ec/group.rs @@ -1,8 +1,8 @@ use std::fmt::Debug; use std::ops::{Add, AddAssign, Neg, Sub, SubAssign}; +use ark_serialize::CanonicalSerialize; use jolt_field::Field; -use jolt_transcript::AppendToTranscript; use serde::{Deserialize, Serialize}; /// Cryptographic group suitable for commitments. @@ -15,8 +15,9 @@ use serde::{Deserialize, Serialize}; /// All elements are `Copy` and thread-safe. Implementors must provide /// scalar multiplication and multi-scalar multiplication (MSM). /// -/// Requires [`AppendToTranscript`] so group elements can be absorbed into -/// Fiat-Shamir transcripts (e.g., Pedersen commitments in ZK sumcheck). +/// Requires [`CanonicalSerialize`] so group elements can be absorbed into +/// Fiat-Shamir transcripts (e.g., Pedersen commitments in ZK sumcheck) via +/// the split-trait surface's `FsAbsorb::absorb`. pub trait JoltGroup: Clone + Copy @@ -35,7 +36,7 @@ pub trait JoltGroup: + SubAssign + Serialize + for<'de> Deserialize<'de> - + AppendToTranscript + + CanonicalSerialize { /// Group identity element. #[must_use] diff --git a/crates/jolt-dory/Cargo.toml b/crates/jolt-dory/Cargo.toml index 30e3ea95ea..8030666ac5 100644 --- a/crates/jolt-dory/Cargo.toml +++ b/crates/jolt-dory/Cargo.toml @@ -23,6 +23,7 @@ ark-serialize = { workspace = true } [dev-dependencies] criterion = { workspace = true } serde_json = { workspace = true } +rand = { workspace = true } rand_core = { workspace = true } rand_chacha = { workspace = true } diff --git a/crates/jolt-dory/benches/dory.rs b/crates/jolt-dory/benches/dory.rs index d733c8822d..a339e3b565 100644 --- a/crates/jolt-dory/benches/dory.rs +++ b/crates/jolt-dory/benches/dory.rs @@ -6,10 +6,12 @@ use jolt_dory::{DoryScheme, DoryVerifierSetup}; use jolt_field::{Fr, RandomSampling}; use jolt_openings::{CommitmentScheme, StreamingCommitment, ZkOpeningScheme}; use jolt_poly::{OneHotPolynomial, Polynomial}; -use jolt_transcript::Transcript; +use jolt_transcript::{prover_transcript, Blake2b512}; use rand_chacha::ChaCha20Rng; use rand_core::{RngCore, SeedableRng}; +const INSTANCE: [u8; 32] = [0u8; 32]; + fn bench_setup_prover(c: &mut Criterion) { let mut group = c.benchmark_group("setup_prover"); for num_vars in [4, 8, 12] { @@ -65,7 +67,8 @@ fn bench_open(c: &mut Criterion) { (poly, point, eval) }, |(poly, point, eval)| { - let mut transcript = jolt_transcript::Blake2bTranscript::new(b"bench-open"); + let mut transcript = + prover_transcript(b"bench-open", INSTANCE, Blake2b512::default()); DoryScheme::open(&poly, &point, eval, &setup, None, &mut transcript) }, criterion::BatchSize::SmallInput, @@ -95,14 +98,14 @@ fn bench_verify(c: &mut Criterion) { let eval = poly.evaluate(&point); let (commitment, _) = DoryScheme::commit(poly.evaluations(), &setup); let mut transcript = - jolt_transcript::Blake2bTranscript::new(b"bench-verify"); + prover_transcript(b"bench-verify", INSTANCE, Blake2b512::default()); let proof = DoryScheme::open(&poly, &point, eval, &setup, None, &mut transcript); (commitment, point, eval, proof) }, |(commitment, point, eval, proof)| { let mut transcript = - jolt_transcript::Blake2bTranscript::new(b"bench-verify"); + prover_transcript(b"bench-verify", INSTANCE, Blake2b512::default()); DoryScheme::verify( &commitment, &point, @@ -233,7 +236,7 @@ fn bench_open_zk(c: &mut Criterion) { }, |(poly, point, eval, hint)| { let mut transcript = - jolt_transcript::Blake2bTranscript::new(b"bench-open-zk"); + prover_transcript(b"bench-open-zk", INSTANCE, Blake2b512::default()); DoryScheme::open_zk(&poly, &point, eval, &setup, hint, &mut transcript) }, criterion::BatchSize::SmallInput, @@ -264,14 +267,14 @@ fn bench_verify_zk(c: &mut Criterion) { let (commitment, hint) = ::commit_zk(poly.evaluations(), &setup); let mut transcript = - jolt_transcript::Blake2bTranscript::new(b"bench-verify-zk"); + prover_transcript(b"bench-verify-zk", INSTANCE, Blake2b512::default()); let (proof, _eval_com, _blind) = DoryScheme::open_zk(&poly, &point, eval, &setup, hint, &mut transcript); (commitment, point, proof) }, |(commitment, point, proof)| { let mut transcript = - jolt_transcript::Blake2bTranscript::new(b"bench-verify-zk"); + prover_transcript(b"bench-verify-zk", INSTANCE, Blake2b512::default()); DoryScheme::verify_zk( &commitment, &point, diff --git a/crates/jolt-dory/fuzz/fuzz_targets/verify_tampered.rs b/crates/jolt-dory/fuzz/fuzz_targets/verify_tampered.rs index 05c183dba2..5340ed92d8 100644 --- a/crates/jolt-dory/fuzz/fuzz_targets/verify_tampered.rs +++ b/crates/jolt-dory/fuzz/fuzz_targets/verify_tampered.rs @@ -6,7 +6,7 @@ use jolt_dory::{DoryCommitment, DoryProof, DoryScheme, DoryVerifierSetup}; use jolt_field::{Fr, RandomSampling}; use jolt_openings::CommitmentScheme; use jolt_poly::Polynomial; -use jolt_transcript::Blake2bTranscript; +use jolt_transcript::{prover_transcript, Blake2b512}; use libfuzzer_sys::fuzz_target; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; @@ -50,7 +50,7 @@ fuzz_target!(|data: &[u8]| { }, }; - let mut transcript = Blake2bTranscript::new(b"fuzz-tampered"); + let mut transcript = prover_transcript(b"fuzz-tampered", [0u8; 32], Blake2b512::default()); let result = DoryScheme::verify( &fix.commitment, &fix.point, diff --git a/crates/jolt-dory/src/scheme.rs b/crates/jolt-dory/src/scheme.rs index 63526d87a8..049b2b1f00 100644 --- a/crates/jolt-dory/src/scheme.rs +++ b/crates/jolt-dory/src/scheme.rs @@ -18,7 +18,7 @@ use jolt_crypto::{Bn254G1, Bn254GT, Commitment, DeriveSetup, JoltGroup, Pedersen use jolt_field::Fr; use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme, OpeningsError, ZkOpeningScheme}; use jolt_poly::MultilinearPoly; -use jolt_transcript::{AppendToTranscript, Label, LabelWithCount, Transcript}; +use jolt_transcript::FsTranscript; use rayon::prelude::*; use crate::transcript::JoltToDoryTranscript; @@ -164,7 +164,7 @@ impl CommitmentScheme for DoryScheme { _eval: Fr, setup: &Self::ProverSetup, hint: Option, - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, ) -> Self::Proof { let num_vars = point.len(); let adapter = DorySourceAdapter::new(poly); @@ -209,7 +209,7 @@ impl CommitmentScheme for DoryScheme { eval: Fr, proof: &Self::Proof, setup: &Self::VerifierSetup, - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, ) -> Result<(), OpeningsError> { let ark_point: Vec = point.iter().rev().map(jolt_fr_to_ark).collect(); let ark_eval = jolt_fr_to_ark(&eval); @@ -237,16 +237,12 @@ impl CommitmentScheme for DoryScheme { } fn bind_opening_inputs( - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, point: &[Self::Field], eval: &Self::Field, ) { - transcript.append(&LabelWithCount(b"dory_opening_point", point.len() as u64)); - for p in point { - p.append_to_transcript(transcript); - } - transcript.append(&Label(b"dory_opening_eval")); - eval.append_to_transcript(transcript); + transcript.absorb_field_slice(point); + transcript.absorb_field(eval); } } @@ -314,7 +310,7 @@ impl ZkOpeningScheme for DoryScheme { _eval: Fr, setup: &Self::ProverSetup, hint: Self::OpeningHint, - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, ) -> (Self::Proof, Self::HidingCommitment, Self::Blind) { let num_vars = point.len(); let adapter = DorySourceAdapter::new(poly); @@ -350,7 +346,7 @@ impl ZkOpeningScheme for DoryScheme { point: &[Fr], proof: &Self::Proof, setup: &Self::VerifierSetup, - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, ) -> Result { let ark_point: Vec = point.iter().rev().map(jolt_fr_to_ark).collect(); // In ZK mode dory::verify reads the evaluation commitment from `proof.y_com`, @@ -378,16 +374,12 @@ impl ZkOpeningScheme for DoryScheme { } fn bind_zk_opening_inputs( - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, point: &[Self::Field], hiding_commitment: &Self::HidingCommitment, ) { - transcript.append(&LabelWithCount(b"dory_opening_point", point.len() as u64)); - for p in point { - p.append_to_transcript(transcript); - } - transcript.append(&Label(b"dory_eval_commitment")); - hiding_commitment.append_to_transcript(transcript); + transcript.absorb_field_slice(point); + transcript.absorb(hiding_commitment); } } @@ -533,9 +525,12 @@ mod tests { use jolt_crypto::{Pedersen, VectorCommitment}; use jolt_field::{FromPrimitiveInt, RandomSampling}; use jolt_poly::Polynomial; + use jolt_transcript::{prover_transcript, Blake2b512}; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; + const INSTANCE: [u8; 32] = [0u8; 32]; + #[test] fn commit_open_verify_round_trip() { let num_vars = 4; @@ -552,7 +547,7 @@ mod tests { let (commitment, hint) = DoryScheme::commit(poly.evaluations(), &prover_setup); - let mut prove_transcript = jolt_transcript::Blake2bTranscript::new(b"test"); + let mut prove_transcript = prover_transcript(b"test", INSTANCE, Blake2b512::default()); let proof = DoryScheme::open( &poly, &point, @@ -562,7 +557,7 @@ mod tests { &mut prove_transcript, ); - let mut verify_transcript = jolt_transcript::Blake2bTranscript::new(b"test"); + let mut verify_transcript = prover_transcript(b"test", INSTANCE, Blake2b512::default()); let result = DoryScheme::verify( &commitment, &point, @@ -626,7 +621,7 @@ mod tests { let (commitment, hint) = ::commit_zk(poly.evaluations(), &prover_setup); - let mut prove_transcript = jolt_transcript::Blake2bTranscript::new(b"zk-test"); + let mut prove_transcript = prover_transcript(b"zk-test", INSTANCE, Blake2b512::default()); let (proof, _eval_com, _blinding) = DoryScheme::open_zk( &poly, &point, @@ -636,7 +631,7 @@ mod tests { &mut prove_transcript, ); - let mut verify_transcript = jolt_transcript::Blake2bTranscript::new(b"zk-test"); + let mut verify_transcript = prover_transcript(b"zk-test", INSTANCE, Blake2b512::default()); let result = DoryScheme::verify_zk( &commitment, &point, diff --git a/crates/jolt-dory/src/transcript.rs b/crates/jolt-dory/src/transcript.rs index 6d2ee4f055..382ba48a85 100644 --- a/crates/jolt-dory/src/transcript.rs +++ b/crates/jolt-dory/src/transcript.rs @@ -14,52 +14,44 @@ use dory::primitives::arithmetic::Group as DoryGroup; use dory::primitives::transcript::Transcript as DoryTranscript; use dory::primitives::DorySerialize; use jolt_field::Fr; -use jolt_transcript::domain::{Label, LabelWithCount}; -use jolt_transcript::{AppendToTranscript, Transcript}; +use jolt_transcript::FsTranscript; use crate::scheme::{ark_to_jolt_fr, jolt_fr_to_ark, ArkFr}; -pub struct JoltToDoryTranscript<'a, T: Transcript> { +pub struct JoltToDoryTranscript<'a, T: FsTranscript> { transcript: &'a mut T, } -impl<'a, T: Transcript> JoltToDoryTranscript<'a, T> { +impl<'a, T: FsTranscript> JoltToDoryTranscript<'a, T> { pub fn new(transcript: &'a mut T) -> Self { Self { transcript } } } -impl> DoryTranscript for JoltToDoryTranscript<'_, T> { +impl> DoryTranscript for JoltToDoryTranscript<'_, T> { type Curve = BN254; fn append_bytes(&mut self, _label: &[u8], bytes: &[u8]) { - self.transcript - .append(&LabelWithCount(b"dory_bytes", bytes.len() as u64)); - self.transcript.append_bytes(bytes); + self.transcript.absorb_bytes(bytes); } fn append_field(&mut self, _label: &[u8], x: &ArkFr) { let jolt_scalar = ark_to_jolt_fr(x); - self.transcript.append(&Label(b"dory_field")); - jolt_scalar.append_to_transcript(self.transcript); + self.transcript.absorb_field(&jolt_scalar); } fn append_group(&mut self, _label: &[u8], g: &G) { let mut buffer = Vec::new(); g.serialize_compressed(&mut buffer) .expect("group serialization should not fail"); - self.transcript - .append(&LabelWithCount(b"dory_group", buffer.len() as u64)); - self.transcript.append_bytes(&buffer); + self.transcript.absorb_bytes(&buffer); } fn append_serde(&mut self, _label: &[u8], s: &S) { let mut buffer = Vec::new(); s.serialize_compressed(&mut buffer) .expect("DorySerialize serialization should not fail"); - self.transcript - .append(&LabelWithCount(b"dory_serde", buffer.len() as u64)); - self.transcript.append_bytes(&buffer); + self.transcript.absorb_bytes(&buffer); } fn challenge_scalar(&mut self, _label: &[u8]) -> ArkFr { diff --git a/crates/jolt-dory/src/types.rs b/crates/jolt-dory/src/types.rs index bdce8d8fc5..e521e64504 100644 --- a/crates/jolt-dory/src/types.rs +++ b/crates/jolt-dory/src/types.rs @@ -8,7 +8,6 @@ use dory::backends::arkworks::{ }; use jolt_crypto::{Bn254G1, Bn254GT, HomomorphicCommitment}; use jolt_field::Fr; -use jolt_transcript::{AppendToTranscript, Transcript}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; /// Caps the upstream `Vec::with_capacity(num_rounds)` allocation against @@ -42,13 +41,19 @@ impl<'de> Deserialize<'de> for DoryCommitment { } } -impl AppendToTranscript for DoryCommitment { - fn append_to_transcript(&self, transcript: &mut T) { - self.0.append_to_transcript(transcript); +impl CanonicalSerialize for DoryCommitment { + #[inline] + fn serialize_with_mode( + &self, + writer: W, + compress: ark_serialize::Compress, + ) -> Result<(), ark_serialize::SerializationError> { + self.0.serialize_with_mode(writer, compress) } - fn transcript_payload_len(&self) -> Option { - self.0.transcript_payload_len() + #[inline] + fn serialized_size(&self, compress: ark_serialize::Compress) -> usize { + self.0.serialized_size(compress) } } @@ -169,12 +174,14 @@ mod tests { use jolt_field::RandomSampling; use jolt_openings::CommitmentScheme; use jolt_poly::Polynomial; - use jolt_transcript::Transcript; + use jolt_transcript::{prover_transcript, Blake2b512}; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; use jolt_field::Fr; + const INSTANCE: [u8; 32] = [0u8; 32]; + #[test] fn dory_commitment_serde_round_trip() { let num_vars = 3; @@ -210,7 +217,7 @@ mod tests { let eval = poly.evaluate(&point); let (commitment, hint) = crate::DoryScheme::commit(poly.evaluations(), &prover_setup); - let mut prove_transcript = jolt_transcript::Blake2bTranscript::new(b"serde-vs"); + let mut prove_transcript = prover_transcript(b"serde-vs", INSTANCE, Blake2b512::default()); let proof = crate::DoryScheme::open( &poly, &point, @@ -220,7 +227,7 @@ mod tests { &mut prove_transcript, ); - let mut verify_transcript = jolt_transcript::Blake2bTranscript::new(b"serde-vs"); + let mut verify_transcript = prover_transcript(b"serde-vs", INSTANCE, Blake2b512::default()); let result = crate::DoryScheme::verify( &commitment, &point, @@ -248,7 +255,7 @@ mod tests { .collect(); let eval = poly.evaluate(&point); - let mut transcript = jolt_transcript::Blake2bTranscript::new(b"serde-bp"); + let mut transcript = prover_transcript(b"serde-bp", INSTANCE, Blake2b512::default()); let proof = crate::DoryScheme::open(&poly, &point, eval, &prover_setup, None, &mut transcript); @@ -259,7 +266,7 @@ mod tests { let verifier_setup = DoryVerifierSetup(prover_setup.0.to_verifier_setup()); let (commitment, _) = crate::DoryScheme::commit(poly.evaluations(), &prover_setup); - let mut verify_transcript = jolt_transcript::Blake2bTranscript::new(b"serde-bp"); + let mut verify_transcript = prover_transcript(b"serde-bp", INSTANCE, Blake2b512::default()); let result = crate::DoryScheme::verify( &commitment, &point, @@ -283,7 +290,7 @@ mod tests { .collect(); let eval = poly.evaluate(&point); - let mut transcript = jolt_transcript::Blake2bTranscript::new(b"serde-oversized"); + let mut transcript = prover_transcript(b"serde-oversized", INSTANCE, Blake2b512::default()); let proof = crate::DoryScheme::open(&poly, &point, eval, &prover_setup, None, &mut transcript); diff --git a/crates/jolt-dory/tests/commit_open_verify.rs b/crates/jolt-dory/tests/commit_open_verify.rs index 60aed67c93..9dd4461f69 100644 --- a/crates/jolt-dory/tests/commit_open_verify.rs +++ b/crates/jolt-dory/tests/commit_open_verify.rs @@ -12,11 +12,22 @@ use jolt_openings::{ AdditivelyHomomorphic, CommitmentScheme, StreamingCommitment, ZkOpeningScheme, }; use jolt_poly::{OneHotPolynomial, Polynomial}; -use jolt_transcript::{Blake2bTranscript, KeccakTranscript, Transcript}; +use jolt_transcript::{prover_transcript, Blake2b512, DuplexSpongeInterface, Keccak, ProverState}; +use rand::rngs::StdRng; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; -fn round_trip>(num_vars: usize, seed: u64, label: &'static [u8]) { +const INSTANCE: [u8; 32] = [0u8; 32]; + +/// Generic over the spongefish sponge. jolt-dory is a symmetric consumer: +/// both prover and verifier `absorb` every value through the same adapter, so +/// both sides build an independent `ProverState` over the same domain and +/// derive identical challenges. +fn round_trip(num_vars: usize, seed: u64, label: &'static [u8]) +where + H: DuplexSpongeInterface + Default, + ProverState: jolt_transcript::FsTranscript, +{ let mut rng = ChaCha20Rng::seed_from_u64(seed); let prover_setup = DoryScheme::setup_prover(num_vars); let verifier_setup = DoryScheme::setup_verifier(num_vars); @@ -28,18 +39,18 @@ fn round_trip>(num_vars: usize, seed: u64, label: let (commitment, hint) = DoryScheme::commit(poly.evaluations(), &prover_setup); // With hint - let mut pt = T::new(label); + let mut pt = prover_transcript(label, INSTANCE, H::default()); let proof = DoryScheme::open(&poly, &point, eval, &prover_setup, Some(hint), &mut pt); - let mut vt = T::new(label); + let mut vt = prover_transcript(label, INSTANCE, H::default()); DoryScheme::verify(&commitment, &point, eval, &proof, &verifier_setup, &mut vt) .expect("round-trip verification (with hint) must succeed"); // Without hint - let mut pt2 = T::new(label); + let mut pt2 = prover_transcript(label, INSTANCE, H::default()); let proof2 = DoryScheme::open(&poly, &point, eval, &prover_setup, None, &mut pt2); - let mut vt2 = T::new(label); + let mut vt2 = prover_transcript(label, INSTANCE, H::default()); DoryScheme::verify( &commitment, &point, @@ -54,15 +65,15 @@ fn round_trip>(num_vars: usize, seed: u64, label: #[test] fn commit_open_verify_various_sizes() { for num_vars in [2, 3, 4, 6] { - round_trip::(num_vars, 100 + num_vars as u64, b"cov-sizes"); + round_trip::(num_vars, 100 + num_vars as u64, b"cov-sizes"); } } #[test] fn commit_open_verify_both_transcripts() { let num_vars = 4; - round_trip::(num_vars, 200, b"blake2b-rt"); - round_trip::(num_vars, 200, b"keccak-rt"); + round_trip::(num_vars, 200, b"blake2b-rt"); + round_trip::(num_vars, 200, b"keccak-rt"); } #[test] @@ -146,11 +157,11 @@ fn streaming_zk_commitment_is_blinded_and_verifies() { "streaming ZK commitments must use fresh blinding" ); - let mut pt = Blake2bTranscript::new(b"stream-zk"); + let mut pt = prover_transcript(b"stream-zk", INSTANCE, Blake2b512::default()); let (proof, eval_com, _blind) = DoryScheme::open_zk(&poly, &point, eval, &prover_setup, hint, &mut pt); - let mut vt = Blake2bTranscript::new(b"stream-zk"); + let mut vt = prover_transcript(b"stream-zk", INSTANCE, Blake2b512::default()); let verified_eval_com = DoryScheme::verify_zk(&commitment, &point, &proof, &verifier_setup, &mut vt) .expect("streaming ZK commitment must verify"); @@ -171,11 +182,11 @@ fn wrong_eval_rejected() { let eval = poly.evaluate(&point); let (commitment, hint) = DoryScheme::commit(poly.evaluations(), &prover_setup); - let mut pt = Blake2bTranscript::new(b"wrong-eval"); + let mut pt = prover_transcript(b"wrong-eval", INSTANCE, Blake2b512::default()); let proof = DoryScheme::open(&poly, &point, eval, &prover_setup, Some(hint), &mut pt); let tampered_eval = eval + Fr::from_u64(1); - let mut vt = Blake2bTranscript::new(b"wrong-eval"); + let mut vt = prover_transcript(b"wrong-eval", INSTANCE, Blake2b512::default()); let result = DoryScheme::verify( &commitment, &point, @@ -201,12 +212,12 @@ fn wrong_point_rejected() { let eval = poly.evaluate(&point); let (commitment, hint) = DoryScheme::commit(poly.evaluations(), &prover_setup); - let mut pt = Blake2bTranscript::new(b"wrong-point"); + let mut pt = prover_transcript(b"wrong-point", INSTANCE, Blake2b512::default()); let proof = DoryScheme::open(&poly, &point, eval, &prover_setup, Some(hint), &mut pt); let mut tampered_point = point.clone(); tampered_point[0] += Fr::from_u64(1); - let mut vt = Blake2bTranscript::new(b"wrong-point"); + let mut vt = prover_transcript(b"wrong-point", INSTANCE, Blake2b512::default()); let result = DoryScheme::verify( &commitment, &tampered_point, @@ -293,7 +304,7 @@ fn wrong_commitment_rejected() { let eval = poly.evaluate(&point); let (commitment, hint) = DoryScheme::commit(poly.evaluations(), &prover_setup); - let mut pt = Blake2bTranscript::new(b"wrong-commit"); + let mut pt = prover_transcript(b"wrong-commit", INSTANCE, Blake2b512::default()); let proof = DoryScheme::open(&poly, &point, eval, &prover_setup, Some(hint), &mut pt); // Commit to a different polynomial @@ -301,7 +312,7 @@ fn wrong_commitment_rejected() { let (wrong_commitment, _) = DoryScheme::commit(wrong_poly.evaluations(), &prover_setup); assert_ne!(commitment, wrong_commitment); - let mut vt = Blake2bTranscript::new(b"wrong-commit"); + let mut vt = prover_transcript(b"wrong-commit", INSTANCE, Blake2b512::default()); let result = DoryScheme::verify( &wrong_commitment, &point, @@ -327,10 +338,10 @@ fn wrong_transcript_domain_rejected() { let eval = poly.evaluate(&point); let (commitment, hint) = DoryScheme::commit(poly.evaluations(), &prover_setup); - let mut pt = Blake2bTranscript::new(b"correct-domain"); + let mut pt = prover_transcript(b"correct-domain", INSTANCE, Blake2b512::default()); let proof = DoryScheme::open(&poly, &point, eval, &prover_setup, Some(hint), &mut pt); - let mut vt = Blake2bTranscript::new(b"wrong-domain"); + let mut vt = prover_transcript(b"wrong-domain", INSTANCE, Blake2b512::default()); let result = DoryScheme::verify(&commitment, &point, eval, &proof, &verifier_setup, &mut vt); assert!(result.is_err(), "wrong transcript domain must be rejected"); } @@ -339,11 +350,15 @@ fn wrong_transcript_domain_rejected() { fn property_based_round_trip() { for seed in 0..10u64 { let num_vars = 2 + (seed as usize % 4); // 2..5 - round_trip::(num_vars, 800 + seed, b"prop-rt"); + round_trip::(num_vars, 800 + seed, b"prop-rt"); } } -fn zk_round_trip>(num_vars: usize, seed: u64, label: &'static [u8]) { +fn zk_round_trip(num_vars: usize, seed: u64, label: &'static [u8]) +where + H: DuplexSpongeInterface + Default, + ProverState: jolt_transcript::FsTranscript, +{ let mut rng = ChaCha20Rng::seed_from_u64(seed); let prover_setup = DoryScheme::setup_prover(num_vars); let verifier_setup = DoryScheme::setup_verifier(num_vars); @@ -355,11 +370,11 @@ fn zk_round_trip>(num_vars: usize, seed: u64, labe let (commitment, hint) = ::commit_zk(poly.evaluations(), &prover_setup); - let mut pt = T::new(label); + let mut pt = prover_transcript(label, INSTANCE, H::default()); let (proof, eval_com, _blind) = DoryScheme::open_zk(&poly, &point, eval, &prover_setup, hint, &mut pt); - let mut vt = T::new(label); + let mut vt = prover_transcript(label, INSTANCE, H::default()); let verified_eval_com = DoryScheme::verify_zk(&commitment, &point, &proof, &verifier_setup, &mut vt) .expect("ZK round-trip verification must succeed"); @@ -369,15 +384,15 @@ fn zk_round_trip>(num_vars: usize, seed: u64, labe #[test] fn zk_round_trip_various_sizes() { for num_vars in [2, 3, 4, 6] { - zk_round_trip::(num_vars, 1100 + num_vars as u64, b"zk-cov-sizes"); + zk_round_trip::(num_vars, 1100 + num_vars as u64, b"zk-cov-sizes"); } } #[test] fn zk_round_trip_both_transcripts() { let num_vars = 4; - zk_round_trip::(num_vars, 1200, b"zk-blake2b-rt"); - zk_round_trip::(num_vars, 1200, b"zk-keccak-rt"); + zk_round_trip::(num_vars, 1200, b"zk-blake2b-rt"); + zk_round_trip::(num_vars, 1200, b"zk-keccak-rt"); } #[test] @@ -395,11 +410,19 @@ fn transparent_verify_rejects_zk_opening_proof() { let (commitment, hint) = ::commit_zk(poly.evaluations(), &prover_setup); - let mut pt = Blake2bTranscript::new(b"zk-proof-transparent-verify"); + let mut pt = prover_transcript( + b"zk-proof-transparent-verify", + INSTANCE, + Blake2b512::default(), + ); let (proof, _eval_com, _blind) = DoryScheme::open_zk(&poly, &point, eval, &prover_setup, hint, &mut pt); - let mut vt = Blake2bTranscript::new(b"zk-proof-transparent-verify"); + let mut vt = prover_transcript( + b"zk-proof-transparent-verify", + INSTANCE, + Blake2b512::default(), + ); let result = DoryScheme::verify(&commitment, &point, eval, &proof, &verifier_setup, &mut vt); assert!( result.is_err(), @@ -423,7 +446,7 @@ fn zk_wrong_commitment_rejected() { let (commitment, hint) = ::commit_zk(poly.evaluations(), &prover_setup); - let mut pt = Blake2bTranscript::new(b"zk-wrong-commit"); + let mut pt = prover_transcript(b"zk-wrong-commit", INSTANCE, Blake2b512::default()); let (proof, _eval_com, _blind) = DoryScheme::open_zk(&poly, &point, eval, &prover_setup, hint, &mut pt); @@ -432,7 +455,7 @@ fn zk_wrong_commitment_rejected() { ::commit_zk(wrong_poly.evaluations(), &prover_setup); assert_ne!(commitment, wrong_commitment); - let mut vt = Blake2bTranscript::new(b"zk-wrong-commit"); + let mut vt = prover_transcript(b"zk-wrong-commit", INSTANCE, Blake2b512::default()); let result = DoryScheme::verify_zk(&wrong_commitment, &point, &proof, &verifier_setup, &mut vt); assert!(result.is_err(), "ZK: wrong commitment must be rejected"); } @@ -454,11 +477,11 @@ fn transparent_commitment_rejected_for_zk_blinded_proof() { let (_zk_commitment, hint) = ::commit_zk(poly.evaluations(), &prover_setup); - let mut pt = Blake2bTranscript::new(b"zk-transparent-reject"); + let mut pt = prover_transcript(b"zk-transparent-reject", INSTANCE, Blake2b512::default()); let (proof, _eval_com, _blind) = DoryScheme::open_zk(&poly, &point, eval, &prover_setup, hint, &mut pt); - let mut vt = Blake2bTranscript::new(b"zk-transparent-reject"); + let mut vt = prover_transcript(b"zk-transparent-reject", INSTANCE, Blake2b512::default()); let result = DoryScheme::verify_zk( &transparent_commitment, &point, @@ -504,7 +527,7 @@ fn zk_combined_commitment_and_hint_verify() { .collect(); let eval = weighted_poly.evaluate(&point); - let mut pt = Blake2bTranscript::new(b"zk-combined"); + let mut pt = prover_transcript(b"zk-combined", INSTANCE, Blake2b512::default()); let (proof, eval_com, _blind) = DoryScheme::open_zk( &weighted_poly, &point, @@ -514,7 +537,7 @@ fn zk_combined_commitment_and_hint_verify() { &mut pt, ); - let mut vt = Blake2bTranscript::new(b"zk-combined"); + let mut vt = prover_transcript(b"zk-combined", INSTANCE, Blake2b512::default()); let verified_eval_com = DoryScheme::verify_zk( &combined_commitment, &point, @@ -541,7 +564,7 @@ fn wrong_eval_commitment_rejected_zk() { let (commitment, hint) = ::commit_zk(poly.evaluations(), &prover_setup); - let mut pt = Blake2bTranscript::new(b"zk-tampered-y-com"); + let mut pt = prover_transcript(b"zk-tampered-y-com", INSTANCE, Blake2b512::default()); let (mut proof, _eval_com, _blind) = DoryScheme::open_zk(&poly, &point, eval, &prover_setup, hint, &mut pt); @@ -550,7 +573,7 @@ fn wrong_eval_commitment_rejected_zk() { // bind y_com cryptographically to the rest of the proof. proof.0.y_com = Some(ArkG1::default()); - let mut vt = Blake2bTranscript::new(b"zk-tampered-y-com"); + let mut vt = prover_transcript(b"zk-tampered-y-com", INSTANCE, Blake2b512::default()); let result = DoryScheme::verify_zk(&commitment, &point, &proof, &verifier_setup, &mut vt); assert!(result.is_err(), "tampered proof.y_com must be rejected"); } @@ -570,11 +593,11 @@ fn zk_wrong_transcript_domain_rejected() { let (commitment, hint) = ::commit_zk(poly.evaluations(), &prover_setup); - let mut pt = Blake2bTranscript::new(b"zk-correct-domain"); + let mut pt = prover_transcript(b"zk-correct-domain", INSTANCE, Blake2b512::default()); let (proof, _eval_com, _blind) = DoryScheme::open_zk(&poly, &point, eval, &prover_setup, hint, &mut pt); - let mut vt = Blake2bTranscript::new(b"zk-wrong-domain"); + let mut vt = prover_transcript(b"zk-wrong-domain", INSTANCE, Blake2b512::default()); let result = DoryScheme::verify_zk(&commitment, &point, &proof, &verifier_setup, &mut vt); assert!( result.is_err(), diff --git a/crates/jolt-hyperkzg/benches/hyperkzg.rs b/crates/jolt-hyperkzg/benches/hyperkzg.rs index 27c587e741..2c5958a56a 100644 --- a/crates/jolt-hyperkzg/benches/hyperkzg.rs +++ b/crates/jolt-hyperkzg/benches/hyperkzg.rs @@ -5,12 +5,14 @@ use jolt_field::{Fr, RandomSampling}; use jolt_hyperkzg::{HyperKZGProverSetup, HyperKZGScheme, HyperKZGVerifierSetup}; use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme}; use jolt_poly::Polynomial; -use jolt_transcript::Transcript; +use jolt_transcript::{prover_transcript, verifier_transcript, Blake2b512}; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; type TestScheme = HyperKZGScheme; +const INSTANCE: [u8; 32] = [0u8; 32]; + fn make_setup(max_degree: usize) -> (HyperKZGProverSetup, HyperKZGVerifierSetup) { let mut rng = ChaCha20Rng::seed_from_u64(0xbe0c); let g1 = Bn254::g1_generator(); @@ -61,7 +63,8 @@ fn bench_open(c: &mut Criterion) { (poly, point, eval) }, |(poly, point, eval)| { - let mut transcript = jolt_transcript::Blake2bTranscript::new(b"bench-open"); + let mut transcript = + prover_transcript(b"bench-open", INSTANCE, Blake2b512::default()); ::open( &poly, &point, @@ -96,7 +99,7 @@ fn bench_verify(c: &mut Criterion) { let eval = poly.evaluate(&point); let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); let mut transcript = - jolt_transcript::Blake2bTranscript::new(b"bench-verify"); + prover_transcript(b"bench-verify", INSTANCE, Blake2b512::default()); let proof = ::open( &poly, &point, @@ -108,8 +111,12 @@ fn bench_verify(c: &mut Criterion) { (commitment, point, eval, proof) }, |(commitment, point, eval, proof)| { - let mut transcript = - jolt_transcript::Blake2bTranscript::new(b"bench-verify"); + let mut transcript = verifier_transcript( + b"bench-verify", + INSTANCE, + Blake2b512::default(), + &[], + ); ::verify( &commitment, &point, diff --git a/crates/jolt-hyperkzg/fuzz/fuzz_targets/commit_open_verify.rs b/crates/jolt-hyperkzg/fuzz/fuzz_targets/commit_open_verify.rs index 51c8079b54..f2175a8f82 100644 --- a/crates/jolt-hyperkzg/fuzz/fuzz_targets/commit_open_verify.rs +++ b/crates/jolt-hyperkzg/fuzz/fuzz_targets/commit_open_verify.rs @@ -7,13 +7,15 @@ use jolt_field::{Field, Fr}; use jolt_hyperkzg::HyperKZGScheme; use jolt_openings::CommitmentScheme; use jolt_poly::Polynomial; -use jolt_transcript::{Blake2bTranscript, Transcript}; +use jolt_transcript::{prover_transcript, verifier_transcript, Blake2b512}; use libfuzzer_sys::fuzz_target; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; type TestScheme = HyperKZGScheme; +const INSTANCE: [u8; 32] = [0u8; 32]; + fuzz_target!(|data: &[u8]| { if data.len() < 8 { return; @@ -35,11 +37,11 @@ fuzz_target!(|data: &[u8]| { let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - let mut pt = Blake2bTranscript::new(b"fuzz"); + let mut pt = prover_transcript(b"fuzz", INSTANCE, Blake2b512::default()); let proof = ::open(&poly, &point, eval, &pk, None, &mut pt); - let mut vt = Blake2bTranscript::new(b"fuzz"); + let mut vt = verifier_transcript(b"fuzz", INSTANCE, Blake2b512::default(), &[]); ::verify(&commitment, &point, eval, &proof, &vk, &mut vt) .expect("valid proof must verify"); }); diff --git a/crates/jolt-hyperkzg/fuzz/fuzz_targets/tampered_proof.rs b/crates/jolt-hyperkzg/fuzz/fuzz_targets/tampered_proof.rs index 1363e2487c..fcfed48173 100644 --- a/crates/jolt-hyperkzg/fuzz/fuzz_targets/tampered_proof.rs +++ b/crates/jolt-hyperkzg/fuzz/fuzz_targets/tampered_proof.rs @@ -10,13 +10,15 @@ use jolt_field::{Field, Fr}; use jolt_hyperkzg::HyperKZGScheme; use jolt_openings::CommitmentScheme; use jolt_poly::Polynomial; -use jolt_transcript::{Blake2bTranscript, Transcript}; +use jolt_transcript::{prover_transcript, verifier_transcript, Blake2b512}; use libfuzzer_sys::fuzz_target; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; type TestScheme = HyperKZGScheme; +const INSTANCE: [u8; 32] = [0u8; 32]; + fuzz_target!(|data: &[u8]| { if data.len() < 10 { return; @@ -37,7 +39,7 @@ fuzz_target!(|data: &[u8]| { let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - let mut pt = Blake2bTranscript::new(b"fuzz-tamper"); + let mut pt = prover_transcript(b"fuzz-tamper", INSTANCE, Blake2b512::default()); let proof = ::open(&poly, &point, eval, &pk, None, &mut pt); @@ -77,7 +79,7 @@ fuzz_target!(|data: &[u8]| { } } - let mut vt = Blake2bTranscript::new(b"fuzz-tamper"); + let mut vt = verifier_transcript(b"fuzz-tamper", INSTANCE, Blake2b512::default(), &[]); let result = ::verify( &commitment, &point, diff --git a/crates/jolt-hyperkzg/fuzz/fuzz_targets/wrong_eval.rs b/crates/jolt-hyperkzg/fuzz/fuzz_targets/wrong_eval.rs index 4c15d1af2f..aa05219827 100644 --- a/crates/jolt-hyperkzg/fuzz/fuzz_targets/wrong_eval.rs +++ b/crates/jolt-hyperkzg/fuzz/fuzz_targets/wrong_eval.rs @@ -10,13 +10,15 @@ use jolt_field::{Field, Fr}; use jolt_hyperkzg::HyperKZGScheme; use jolt_openings::CommitmentScheme; use jolt_poly::Polynomial; -use jolt_transcript::{Blake2bTranscript, Transcript}; +use jolt_transcript::{prover_transcript, verifier_transcript, Blake2b512}; use libfuzzer_sys::fuzz_target; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; type TestScheme = HyperKZGScheme; +const INSTANCE: [u8; 32] = [0u8; 32]; + fuzz_target!(|data: &[u8]| { if data.len() < 32 { return; @@ -42,11 +44,11 @@ fuzz_target!(|data: &[u8]| { let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - let mut pt = Blake2bTranscript::new(b"fuzz-wrong-eval"); + let mut pt = prover_transcript(b"fuzz-wrong-eval", INSTANCE, Blake2b512::default()); let proof = ::open(&poly, &point, eval, &pk, None, &mut pt); - let mut vt = Blake2bTranscript::new(b"fuzz-wrong-eval"); + let mut vt = verifier_transcript(b"fuzz-wrong-eval", INSTANCE, Blake2b512::default(), &[]); let result = ::verify( &commitment, &point, diff --git a/crates/jolt-hyperkzg/src/kzg.rs b/crates/jolt-hyperkzg/src/kzg.rs index 00dcff0ea1..82d6ba8883 100644 --- a/crates/jolt-hyperkzg/src/kzg.rs +++ b/crates/jolt-hyperkzg/src/kzg.rs @@ -5,7 +5,7 @@ use jolt_crypto::{JoltGroup, PairingGroup}; use jolt_field::Field; -use jolt_transcript::{AppendToTranscript, Transcript}; +use jolt_transcript::FsTranscript; use num_traits::{One, Zero}; use crate::error::HyperKZGError; @@ -72,9 +72,7 @@ pub(crate) fn kzg_open_batch( ) -> ([P::G1; 3], [Vec; 3]) where P: PairingGroup, - T: Transcript, - P::ScalarField: AppendToTranscript, - P::G1: AppendToTranscript, + T: FsTranscript, { let k = f.len(); @@ -83,11 +81,8 @@ where (*u).map(|ui| f.iter().map(|fj| eval_univariate(fj, ui)).collect()); // Absorb all evaluations into transcript - for row in &v { - for val in row { - transcript.append(val); - } - } + let scalars: Vec = v.iter().flatten().copied().collect(); + transcript.absorb_field_slice(&scalars); // Derive batching challenge and compute powers q, q^2, ..., q^{k-1} let q: P::ScalarField = transcript.challenge(); @@ -110,9 +105,7 @@ where // Absorb witness commitments and mirror the verifier's `d_0` challenge // to keep prover/verifier transcripts in sync. - for wi in &w { - transcript.append(wi); - } + transcript.absorb(&w.to_vec()); let _d_0: P::ScalarField = transcript.challenge(); (w, v) @@ -132,9 +125,7 @@ pub(crate) fn kzg_verify_batch( ) -> bool where P: PairingGroup, - T: Transcript, - P::ScalarField: AppendToTranscript, - P::G1: AppendToTranscript, + T: FsTranscript, { let k = com.len(); @@ -143,19 +134,14 @@ where } // Absorb evaluations - for row in v { - for val in row { - transcript.append(val); - } - } + let scalars: Vec = v.iter().flatten().copied().collect(); + transcript.absorb_field_slice(&scalars); let q: P::ScalarField = transcript.challenge(); let q_powers = challenge_powers(q, k); // Absorb witness commitments - for wi in wit { - transcript.append(wi); - } + transcript.absorb(&wit.to_vec()); let d_0: P::ScalarField = transcript.challenge(); let d_1 = d_0 * d_0; diff --git a/crates/jolt-hyperkzg/src/scheme.rs b/crates/jolt-hyperkzg/src/scheme.rs index 426038f4a5..e598d1ee6b 100644 --- a/crates/jolt-hyperkzg/src/scheme.rs +++ b/crates/jolt-hyperkzg/src/scheme.rs @@ -14,7 +14,7 @@ use jolt_crypto::{Commitment, DeriveSetup, JoltGroup, PairingGroup, PedersenSetu use jolt_field::{FromPrimitiveInt, RandomSampling}; use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme, OpeningsError}; use jolt_poly::Polynomial; -use jolt_transcript::{AppendToTranscript, Label, LabelWithCount, Transcript}; +use jolt_transcript::FsTranscript; use num_traits::{One, Zero}; use rayon::prelude::*; @@ -31,11 +31,7 @@ pub struct HyperKZGScheme { _phantom: PhantomData

, } -impl HyperKZGScheme

-where - P::ScalarField: AppendToTranscript, - P::G1: AppendToTranscript, -{ +impl HyperKZGScheme

{ /// Generates an SRS from a random generator and secret scalar. /// /// `max_degree` is the maximum polynomial length (number of evaluations). @@ -109,7 +105,7 @@ where /// Full HyperKZG opening proof. #[tracing::instrument(skip_all, name = "HyperKZG::open")] - pub fn open>( + pub fn open>( setup: &HyperKZGProverSetup

, evals: &[P::ScalarField], point: &[P::ScalarField], @@ -134,9 +130,7 @@ where .collect(); // Phase 2: derive challenge r - for c in &com { - transcript.append(c); - } + transcript.absorb(&com); let r: P::ScalarField = transcript.challenge(); let u = [r, -r, r * r]; @@ -148,7 +142,7 @@ where /// HyperKZG verification. #[tracing::instrument(skip_all, name = "HyperKZG::verify")] - pub fn verify>( + pub fn verify>( vk: &HyperKZGVerifierSetup

, commitment: &HyperKZGCommitment

, point: &[P::ScalarField], @@ -175,9 +169,7 @@ where } // Absorb intermediate commitments - for c in &proof.com { - transcript.append(c); - } + transcript.absorb(&proof.com); let r: P::ScalarField = transcript.challenge(); if r.is_zero() { @@ -246,11 +238,7 @@ impl Commitment for HyperKZGScheme

{ type Output = HyperKZGCommitment

; } -impl CommitmentScheme for HyperKZGScheme

-where - P::ScalarField: AppendToTranscript, - P::G1: AppendToTranscript, -{ +impl CommitmentScheme for HyperKZGScheme

{ type Field = P::ScalarField; type Proof = HyperKZGProof

; type ProverSetup = HyperKZGProverSetup

; @@ -293,7 +281,7 @@ where _eval: Self::Field, setup: &Self::ProverSetup, _hint: Option, - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, ) -> Self::Proof { Self::open(setup, poly.evaluations(), point, transcript) .expect("HyperKZG open should not fail with valid inputs") @@ -305,34 +293,23 @@ where eval: Self::Field, proof: &Self::Proof, setup: &Self::VerifierSetup, - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, ) -> Result<(), OpeningsError> { Self::verify(setup, commitment, point, &eval, proof, transcript) .map_err(|_| OpeningsError::VerificationFailed) } fn bind_opening_inputs( - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, point: &[Self::Field], eval: &Self::Field, ) { - transcript.append(&LabelWithCount( - b"hyperkzg_opening_point", - point.len() as u64, - )); - for p in point { - p.append_to_transcript(transcript); - } - transcript.append(&Label(b"hyperkzg_opening_eval")); - eval.append_to_transcript(transcript); + transcript.absorb_field_slice(point); + transcript.absorb_field(eval); } } -impl AdditivelyHomomorphic for HyperKZGScheme

-where - P::ScalarField: AppendToTranscript, - P::G1: AppendToTranscript, -{ +impl AdditivelyHomomorphic for HyperKZGScheme

{ fn combine(commitments: &[Self::Output], scalars: &[Self::Field]) -> Self::Output { assert_eq!(commitments.len(), scalars.len()); let bases: Vec = commitments.iter().map(|c| c.point).collect(); @@ -348,12 +325,14 @@ mod tests { use jolt_crypto::Bn254; use jolt_field::Fr; use jolt_poly::Polynomial; - use jolt_transcript::Blake2bTranscript; + use jolt_transcript::{prover_transcript, verifier_transcript, Blake2b512}; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; type TestScheme = HyperKZGScheme; + const INSTANCE: [u8; 32] = [0u8; 32]; + fn test_setup(max_degree: usize) -> (HyperKZGProverSetup, HyperKZGVerifierSetup) { let mut rng = ChaCha20Rng::seed_from_u64(0xdead_beef); let g1 = Bn254::g1_generator(); @@ -376,7 +355,7 @@ mod tests { let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - let mut prover_transcript = Blake2bTranscript::new(b"test"); + let mut prover_transcript = prover_transcript(b"test", INSTANCE, Blake2b512::default()); let proof = ::open( &poly, &point, @@ -386,7 +365,8 @@ mod tests { &mut prover_transcript, ); - let mut verifier_transcript = Blake2bTranscript::new(b"test"); + let mut verifier_transcript = + verifier_transcript(b"test", INSTANCE, Blake2b512::default(), &[]); let result = ::verify( &commitment, &point, @@ -413,7 +393,7 @@ mod tests { let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - let mut prover_transcript = Blake2bTranscript::new(b"test-bad"); + let mut prover_transcript = prover_transcript(b"test-bad", INSTANCE, Blake2b512::default()); let proof = ::open( &poly, &point, @@ -423,7 +403,8 @@ mod tests { &mut prover_transcript, ); - let mut verifier_transcript = Blake2bTranscript::new(b"test-bad"); + let mut verifier_transcript = + verifier_transcript(b"test-bad", INSTANCE, Blake2b512::default(), &[]); let result = ::verify( &commitment, &point, @@ -448,7 +429,8 @@ mod tests { let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - let mut prover_transcript = Blake2bTranscript::new(b"test-missing-com"); + let mut prover_transcript = + prover_transcript(b"test-missing-com", INSTANCE, Blake2b512::default()); let mut proof = ::open( &poly, &point, @@ -459,7 +441,8 @@ mod tests { ); let _ = proof.com.pop(); - let mut verifier_transcript = Blake2bTranscript::new(b"test-missing-com"); + let mut verifier_transcript = + verifier_transcript(b"test-missing-com", INSTANCE, Blake2b512::default(), &[]); let result = TestScheme::verify( &vk, &commitment, @@ -498,7 +481,8 @@ mod tests { let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - let mut prover_transcript = Blake2bTranscript::new(b"test-tamper"); + let mut prover_transcript = + prover_transcript(b"test-tamper", INSTANCE, Blake2b512::default()); let mut proof = ::open( &poly, &point, @@ -512,7 +496,8 @@ mod tests { let v1 = proof.v[1].clone(); proof.v[0].clone_from(&v1); - let mut verifier_transcript = Blake2bTranscript::new(b"test-tamper"); + let mut verifier_transcript = + verifier_transcript(b"test-tamper", INSTANCE, Blake2b512::default(), &[]); let result = ::verify( &commitment, &point, @@ -596,11 +581,11 @@ mod tests { let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - let mut pt = Blake2bTranscript::new(b"rand-test"); + let mut pt = prover_transcript(b"rand-test", INSTANCE, Blake2b512::default()); let proof = ::open(&poly, &point, eval, &pk, None, &mut pt); - let mut vt = Blake2bTranscript::new(b"rand-test"); + let mut vt = verifier_transcript(b"rand-test", INSTANCE, Blake2b512::default(), &[]); ::verify( &commitment, &point, @@ -658,10 +643,10 @@ mod tests { let (commitment, ()) = TestScheme::commit(poly.evaluations(), &pk); - let mut pt = Blake2bTranscript::new(b"trivial"); + let mut pt = prover_transcript(b"trivial", INSTANCE, Blake2b512::default()); let proof = ::open(&poly, &point, eval, &pk, None, &mut pt); - let mut vt = Blake2bTranscript::new(b"trivial"); + let mut vt = verifier_transcript(b"trivial", INSTANCE, Blake2b512::default(), &[]); ::verify(&commitment, &point, eval, &proof, &vk, &mut vt) .expect("trivial polynomial should verify"); } diff --git a/crates/jolt-hyperkzg/tests/commit_open_verify.rs b/crates/jolt-hyperkzg/tests/commit_open_verify.rs index 52a8376880..1b28098cea 100644 --- a/crates/jolt-hyperkzg/tests/commit_open_verify.rs +++ b/crates/jolt-hyperkzg/tests/commit_open_verify.rs @@ -7,12 +7,14 @@ use jolt_field::{Fr, FromPrimitiveInt, RandomSampling}; use jolt_hyperkzg::{HyperKZGProverSetup, HyperKZGScheme, HyperKZGVerifierSetup}; use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme}; use jolt_poly::Polynomial; -use jolt_transcript::{Blake2bTranscript, Transcript}; +use jolt_transcript::{prover_transcript, verifier_transcript, Blake2b512}; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; type KzgPCS = HyperKZGScheme; +const INSTANCE: [u8; 32] = [0u8; 32]; + fn make_setup(max_degree: usize) -> (HyperKZGProverSetup, HyperKZGVerifierSetup) { let mut rng = ChaCha20Rng::seed_from_u64(0xdead_beef); let g1 = Bn254::g1_generator(); @@ -32,10 +34,10 @@ fn commit_open_verify( let eval = poly.evaluate(point); let (commitment, ()) = ::commit(poly.evaluations(), pk); - let mut t_p = Blake2bTranscript::new(label); + let mut t_p = prover_transcript(label, INSTANCE, Blake2b512::default()); let proof = ::open(poly, point, eval, pk, None, &mut t_p); - let mut t_v = Blake2bTranscript::new(label); + let mut t_v = verifier_transcript(label, INSTANCE, Blake2b512::default(), &[]); ::verify(&commitment, point, eval, &proof, vk, &mut t_v) .expect("verification should succeed"); } @@ -102,12 +104,12 @@ fn wrong_eval_rejected() { let (commitment, ()) = ::commit(poly.evaluations(), &pk); // Prover opens with correct eval - let mut t_p = Blake2bTranscript::new(b"kzg-wrong"); + let mut t_p = prover_transcript(b"kzg-wrong", INSTANCE, Blake2b512::default()); let proof = ::open(&poly, &point, correct_eval, &pk, None, &mut t_p); // Verifier checks with wrong eval - let mut t_v = Blake2bTranscript::new(b"kzg-wrong"); + let mut t_v = verifier_transcript(b"kzg-wrong", INSTANCE, Blake2b512::default(), &[]); let result = ::verify( &commitment, &point, @@ -141,10 +143,10 @@ fn homomorphic_sum() { let point: Vec = (0..nv).map(|_| Fr::random(&mut rng)).collect(); let eval = sum_poly.evaluate(&point); - let mut t_p = Blake2bTranscript::new(b"kzg-homo"); + let mut t_p = prover_transcript(b"kzg-homo", INSTANCE, Blake2b512::default()); let proof = ::open(&sum_poly, &point, eval, &pk, None, &mut t_p); - let mut t_v = Blake2bTranscript::new(b"kzg-homo"); + let mut t_v = verifier_transcript(b"kzg-homo", INSTANCE, Blake2b512::default(), &[]); ::verify(&combined_com, &point, eval, &proof, &vk, &mut t_v) .expect("homomorphic sum must verify"); } @@ -168,11 +170,11 @@ fn homomorphic_weighted_combination() { let point: Vec = (0..nv).map(|_| Fr::random(&mut rng)).collect(); let eval = weighted_poly.evaluate(&point); - let mut t_p = Blake2bTranscript::new(b"kzg-weighted"); + let mut t_p = prover_transcript(b"kzg-weighted", INSTANCE, Blake2b512::default()); let proof = ::open(&weighted_poly, &point, eval, &pk, None, &mut t_p); - let mut t_v = Blake2bTranscript::new(b"kzg-weighted"); + let mut t_v = verifier_transcript(b"kzg-weighted", INSTANCE, Blake2b512::default(), &[]); ::verify(&combined_com, &point, eval, &proof, &vk, &mut t_v) .expect("weighted combination must verify"); } @@ -202,9 +204,9 @@ fn deterministic_setup_from_secret() { // Verify with either setup let point = vec![Fr::from_u64(7)]; let eval = poly.evaluate(&point); - let mut t = Blake2bTranscript::new(b"det-setup"); + let mut t = prover_transcript(b"det-setup", INSTANCE, Blake2b512::default()); let proof = ::open(&poly, &point, eval, &pk1, None, &mut t); - let mut t = Blake2bTranscript::new(b"det-setup"); + let mut t = verifier_transcript(b"det-setup", INSTANCE, Blake2b512::default(), &[]); ::verify(&com1, &point, eval, &proof, &vk2, &mut t) .expect("cross-setup verification must work"); } diff --git a/crates/jolt-openings/Cargo.toml b/crates/jolt-openings/Cargo.toml index 8e23641600..f056d3d03f 100644 --- a/crates/jolt-openings/Cargo.toml +++ b/crates/jolt-openings/Cargo.toml @@ -9,6 +9,7 @@ description = "Polynomial commitment scheme traits and opening reduction for the workspace = true [dependencies] +ark-serialize.workspace = true jolt-crypto.workspace = true jolt-field.workspace = true jolt-poly.workspace = true diff --git a/crates/jolt-openings/src/mock.rs b/crates/jolt-openings/src/mock.rs index 5d079454f1..4304307dc6 100644 --- a/crates/jolt-openings/src/mock.rs +++ b/crates/jolt-openings/src/mock.rs @@ -2,10 +2,11 @@ use std::marker::PhantomData; +use ark_serialize::{CanonicalSerialize, Compress, SerializationError, Write}; use jolt_crypto::Commitment; use jolt_field::Field; use jolt_poly::Polynomial; -use jolt_transcript::{AppendToTranscript, Label, LabelWithCount, Transcript}; +use jolt_transcript::FsTranscript; use serde::{Deserialize, Serialize}; use jolt_crypto::HomomorphicCommitment; @@ -38,19 +39,6 @@ pub struct MockProof { evaluations: Vec, } -impl AppendToTranscript for MockCommitment { - fn append_to_transcript(&self, transcript: &mut T) { - let mut buf = Vec::with_capacity(self.evaluations.len() * F::NUM_BYTES); - for e in &self.evaluations { - let start = buf.len(); - buf.resize(start + F::NUM_BYTES, 0); - e.to_bytes_le(&mut buf[start..]); - } - buf.reverse(); - transcript.append_bytes(&buf); - } -} - impl Commitment for MockCommitmentScheme { type Output = MockCommitment; } @@ -87,7 +75,7 @@ impl CommitmentScheme for MockCommitmentScheme { _eval: Self::Field, _setup: &Self::ProverSetup, _hint: Option<()>, - _transcript: &mut impl Transcript, + _transcript: &mut impl FsTranscript, ) -> Self::Proof { MockProof { evaluations: poly.evaluations().to_vec(), @@ -100,7 +88,7 @@ impl CommitmentScheme for MockCommitmentScheme { eval: Self::Field, proof: &Self::Proof, _setup: &Self::VerifierSetup, - _transcript: &mut impl Transcript, + _transcript: &mut impl FsTranscript, ) -> Result<(), OpeningsError> { if commitment.evaluations != proof.evaluations { return Err(OpeningsError::CommitmentMismatch { @@ -119,7 +107,7 @@ impl CommitmentScheme for MockCommitmentScheme { } fn bind_opening_inputs( - _transcript: &mut impl Transcript, + _transcript: &mut impl FsTranscript, _point: &[Self::Field], _eval: &Self::Field, ) { @@ -169,9 +157,21 @@ pub struct MockHidingCommitment { pub eval: F, } -impl AppendToTranscript for MockHidingCommitment { - fn append_to_transcript(&self, transcript: &mut T) { - self.eval.append_to_transcript(transcript); +// `F: Field` is jolt's field newtype, which does not implement ark's +// `CanonicalSerialize`, so it is hand-rolled here as the little-endian field +// bytes (= `serialize_compressed` for BN254 `Fr`). +impl CanonicalSerialize for MockHidingCommitment { + fn serialize_with_mode( + &self, + mut writer: W, + _compress: Compress, + ) -> Result<(), SerializationError> { + writer.write_all(&self.eval.to_bytes_le_vec())?; + Ok(()) + } + + fn serialized_size(&self, _compress: Compress) -> usize { + F::NUM_BYTES } } @@ -192,7 +192,7 @@ impl ZkOpeningScheme for MockCommitmentScheme { eval: Self::Field, _setup: &Self::ProverSetup, _hint: Self::OpeningHint, - _transcript: &mut impl Transcript, + _transcript: &mut impl FsTranscript, ) -> (Self::Proof, Self::HidingCommitment, Self::Blind) { let proof = MockProof { evaluations: poly.evaluations().to_vec(), @@ -206,7 +206,7 @@ impl ZkOpeningScheme for MockCommitmentScheme { point: &[Self::Field], proof: &Self::Proof, _setup: &Self::VerifierSetup, - _transcript: &mut impl Transcript, + _transcript: &mut impl FsTranscript, ) -> Result { if commitment.evaluations != proof.evaluations { return Err(OpeningsError::CommitmentMismatch { @@ -221,19 +221,12 @@ impl ZkOpeningScheme for MockCommitmentScheme { } fn bind_zk_opening_inputs( - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, point: &[Self::Field], hiding_commitment: &Self::HidingCommitment, ) { - transcript.append(&LabelWithCount( - b"mock_zk_opening_point", - point.len() as u64, - )); - for p in point { - p.append_to_transcript(transcript); - } - transcript.append(&Label(b"mock_zk_eval_commitment")); - hiding_commitment.append_to_transcript(transcript); + transcript.absorb_field_slice(point); + transcript.absorb(hiding_commitment); } } @@ -246,7 +239,7 @@ mod tests { }; use jolt_field::{Fr, FromPrimitiveInt, RandomSampling}; use jolt_poly::Polynomial; - use jolt_transcript::Blake2bTranscript; + use jolt_transcript::{prover_transcript, Blake2b512}; use rand_chacha::rand_core::SeedableRng; use rand_chacha::ChaCha20Rng; @@ -261,10 +254,10 @@ mod tests { let (commitment, ()) = MockPCS::commit(poly.evaluations(), &()); - let mut transcript_p = Blake2bTranscript::new(b"test"); + let mut transcript_p = prover_transcript(b"test", [0u8; 32], Blake2b512::default()); let proof = MockPCS::open(&poly, &point, eval, &(), None, &mut transcript_p); - let mut transcript_v = Blake2bTranscript::new(b"test"); + let mut transcript_v = prover_transcript(b"test", [0u8; 32], Blake2b512::default()); MockPCS::verify(&commitment, &point, eval, &proof, &(), &mut transcript_v) .expect("valid proof should verify"); } @@ -279,10 +272,10 @@ mod tests { let (commitment, ()) = MockPCS::commit(poly.evaluations(), &()); - let mut transcript_p = Blake2bTranscript::new(b"test"); + let mut transcript_p = prover_transcript(b"test", [0u8; 32], Blake2b512::default()); let proof = MockPCS::open(&poly, &point, eval, &(), None, &mut transcript_p); - let mut transcript_v = Blake2bTranscript::new(b"test"); + let mut transcript_v = prover_transcript(b"test", [0u8; 32], Blake2b512::default()); let result = MockPCS::verify( &commitment, &point, @@ -304,10 +297,10 @@ mod tests { let (wrong_commitment, ()) = MockPCS::commit(poly2.evaluations(), &()); let eval = poly1.evaluate(&point); - let mut transcript_p = Blake2bTranscript::new(b"test"); + let mut transcript_p = prover_transcript(b"test", [0u8; 32], Blake2b512::default()); let proof = MockPCS::open(&poly1, &point, eval, &(), None, &mut transcript_p); - let mut transcript_v = Blake2bTranscript::new(b"test"); + let mut transcript_v = prover_transcript(b"test", [0u8; 32], Blake2b512::default()); let result = MockPCS::verify( &wrong_commitment, &point, @@ -363,7 +356,7 @@ mod tests { } // Prover: reduce + open - let mut transcript_p = Blake2bTranscript::new(b"e2e-test"); + let mut transcript_p = prover_transcript(b"e2e-test", [0u8; 32], Blake2b512::default()); let reduced_prover = reduce_prover(prover_claims, &mut transcript_p); let proofs: Vec<_> = reduced_prover .iter() @@ -380,7 +373,7 @@ mod tests { .collect(); // Verifier: reduce + verify - let mut transcript_v = Blake2bTranscript::new(b"e2e-test"); + let mut transcript_v = prover_transcript(b"e2e-test", [0u8; 32], Blake2b512::default()); let reduced_verifier = reduce_verifier::(verifier_claims, &mut transcript_v)?; assert_eq!(reduced_verifier.len(), proofs.len()); @@ -483,7 +476,7 @@ mod tests { }, ]; - let mut transcript = Blake2bTranscript::new(b"grouping"); + let mut transcript = prover_transcript(b"grouping", [0u8; 32], Blake2b512::default()); let reduced = reduce_prover(claims, &mut transcript); assert_eq!(reduced.len(), 2, "two distinct points → two reduced claims"); } @@ -497,11 +490,11 @@ mod tests { let (commitment, ()) = MockPCS::commit(poly.evaluations(), &()); - let mut transcript_p = Blake2bTranscript::new(b"zk-test"); + let mut transcript_p = prover_transcript(b"zk-test", [0u8; 32], Blake2b512::default()); let (proof, eval_com, _blinding) = MockPCS::open_zk(&poly, &point, eval, &(), (), &mut transcript_p); - let mut transcript_v = Blake2bTranscript::new(b"zk-test"); + let mut transcript_v = prover_transcript(b"zk-test", [0u8; 32], Blake2b512::default()); let verified_eval_com = MockPCS::verify_zk(&commitment, &point, &proof, &(), &mut transcript_v) .expect("valid ZK proof should verify"); diff --git a/crates/jolt-openings/src/reduction.rs b/crates/jolt-openings/src/reduction.rs index 1b5a074488..05fc7ff61f 100644 --- a/crates/jolt-openings/src/reduction.rs +++ b/crates/jolt-openings/src/reduction.rs @@ -2,7 +2,7 @@ use jolt_field::Field; use jolt_poly::{Point, HIGH_TO_LOW}; -use jolt_transcript::{AppendToTranscript, LabelWithCount, Transcript}; +use jolt_transcript::FsTranscript; use crate::claims::{EvaluationClaim, ProverOpeningClaim, VerifierOpeningClaim}; use crate::error::OpeningsError; @@ -11,7 +11,7 @@ use jolt_crypto::HomomorphicCommitment; /// Groups claims by point, draws ρ per group, combines: p = Σ ρ^i · p_i. #[tracing::instrument(skip_all, name = "reduce_prover")] -pub fn reduce_prover>( +pub fn reduce_prover>( claims: Vec>, transcript: &mut T, ) -> Vec> { @@ -19,9 +19,8 @@ pub fn reduce_prover>( return Vec::new(); } - transcript.append(&LabelWithCount(b"rlc_claims", claims.len() as u64)); for claim in &claims { - claim.evaluation.value.append_to_transcript(transcript); + transcript.absorb_field(&claim.evaluation.value); } let groups = group_prover_claims_by_point(claims); @@ -61,15 +60,14 @@ pub fn reduce_verifier( where PCS: AdditivelyHomomorphic, PCS::Output: HomomorphicCommitment, - T: Transcript, + T: FsTranscript, { if claims.is_empty() { return Ok(Vec::new()); } - transcript.append(&LabelWithCount(b"rlc_claims", claims.len() as u64)); for claim in &claims { - claim.evaluation.value.append_to_transcript(transcript); + transcript.absorb_field(&claim.evaluation.value); } let groups = group_verifier_claims_by_point(claims); diff --git a/crates/jolt-openings/src/schemes.rs b/crates/jolt-openings/src/schemes.rs index 97e68e59aa..6b72902203 100644 --- a/crates/jolt-openings/src/schemes.rs +++ b/crates/jolt-openings/src/schemes.rs @@ -7,10 +7,11 @@ use std::fmt::Debug; +use ark_serialize::CanonicalSerialize; use jolt_crypto::{Commitment, HomomorphicCommitment}; use jolt_field::Field; use jolt_poly::MultilinearPoly; -use jolt_transcript::{AppendToTranscript, Transcript}; +use jolt_transcript::FsTranscript; use serde::{de::DeserializeOwned, Serialize}; use crate::error::OpeningsError; @@ -44,7 +45,7 @@ pub trait CommitmentScheme: Commitment { eval: Self::Field, setup: &Self::ProverSetup, hint: Option, - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, ) -> Self::Proof; fn verify( @@ -53,11 +54,11 @@ pub trait CommitmentScheme: Commitment { eval: Self::Field, proof: &Self::Proof, setup: &Self::VerifierSetup, - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, ) -> Result<(), OpeningsError>; fn bind_opening_inputs( - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, point: &[Self::Field], eval: &Self::Field, ); @@ -103,7 +104,7 @@ pub trait ZkOpeningScheme: CommitmentScheme { + 'static + Serialize + DeserializeOwned - + AppendToTranscript; + + CanonicalSerialize; type Blind: Clone + Send + Sync; @@ -121,7 +122,7 @@ pub trait ZkOpeningScheme: CommitmentScheme { eval: Self::Field, setup: &Self::ProverSetup, hint: Self::OpeningHint, - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, ) -> (Self::Proof, Self::HidingCommitment, Self::Blind); /// Verify a ZK opening proof and return the hiding commitment to the @@ -131,11 +132,11 @@ pub trait ZkOpeningScheme: CommitmentScheme { point: &[Self::Field], proof: &Self::Proof, setup: &Self::VerifierSetup, - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, ) -> Result; fn bind_zk_opening_inputs( - transcript: &mut impl Transcript, + transcript: &mut impl FsTranscript, point: &[Self::Field], hiding_commitment: &Self::HidingCommitment, ); diff --git a/crates/jolt-openings/tests/reduction.rs b/crates/jolt-openings/tests/reduction.rs index a91335f186..ed676ef6a9 100644 --- a/crates/jolt-openings/tests/reduction.rs +++ b/crates/jolt-openings/tests/reduction.rs @@ -20,18 +20,28 @@ use jolt_openings::{ VerifierOpeningClaim, }; use jolt_poly::Polynomial; -use jolt_transcript::{Blake2bTranscript, KeccakTranscript, Transcript}; +use jolt_transcript::{ + prover_transcript, verifier_transcript, Blake2b512, DuplexSpongeInterface, Keccak, ProverState, + VerifierState, +}; +use rand::rngs::StdRng; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; type MockPCS = MockCommitmentScheme; -/// Full reduce → open → verify pipeline. -fn reduce_open_verify>( - polys: &[Polynomial], - points: &[Vec], - label: &'static [u8], -) { +const INSTANCE: [u8; 32] = [0u8; 32]; + +/// Full reduce → open → verify pipeline. Generic over the spongefish sponge: +/// the modular crates are symmetric (both sides `absorb`; no NARG), so the +/// prover uses a `ProverState` and the verifier an independently-built +/// `VerifierState` over an empty NARG, and they derive identical challenges. +fn reduce_open_verify(polys: &[Polynomial], points: &[Vec], label: &'static [u8]) +where + H: DuplexSpongeInterface + Default, + ProverState: jolt_transcript::FsTranscript, + for<'a> VerifierState<'a, H>: jolt_transcript::FsTranscript, +{ assert_eq!(polys.len(), points.len()); let mut prover_claims = Vec::new(); @@ -51,7 +61,7 @@ fn reduce_open_verify>( } // Prover side - let mut transcript_p = T::new(label); + let mut transcript_p = prover_transcript(label, INSTANCE, H::default()); let reduced_p = reduce_prover(prover_claims, &mut transcript_p); let proofs: Vec<_> = reduced_p .iter() @@ -68,7 +78,7 @@ fn reduce_open_verify>( .collect(); // Verifier side - let mut transcript_v = T::new(label); + let mut transcript_v = verifier_transcript(label, INSTANCE, H::default(), &[]); let reduced_v = reduce_verifier::(verifier_claims, &mut transcript_v) .expect("reduction should succeed"); @@ -91,7 +101,7 @@ fn single_claim_blake2b() { let mut rng = ChaCha20Rng::seed_from_u64(1000); let poly = Polynomial::::random(4, &mut rng); let point: Vec = (0..4).map(|_| Fr::random(&mut rng)).collect(); - reduce_open_verify::(&[poly], &[point], b"single-blake2b"); + reduce_open_verify::(&[poly], &[point], b"single-blake2b"); } #[test] @@ -99,7 +109,7 @@ fn single_claim_keccak() { let mut rng = ChaCha20Rng::seed_from_u64(1001); let poly = Polynomial::::random(4, &mut rng); let point: Vec = (0..4).map(|_| Fr::random(&mut rng)).collect(); - reduce_open_verify::(&[poly], &[point], b"single-keccak"); + reduce_open_verify::(&[poly], &[point], b"single-keccak"); } #[test] @@ -113,7 +123,7 @@ fn multiple_claims_shared_point() { .collect(); let points: Vec<_> = (0..5).map(|_| point.clone()).collect(); - reduce_open_verify::(&polys, &points, b"shared-point"); + reduce_open_verify::(&polys, &points, b"shared-point"); } #[test] @@ -128,7 +138,7 @@ fn multiple_claims_distinct_points() { .map(|_| (0..nv).map(|_| Fr::random(&mut rng)).collect()) .collect(); - reduce_open_verify::(&polys, &points, b"distinct-points"); + reduce_open_verify::(&polys, &points, b"distinct-points"); } #[test] @@ -151,16 +161,16 @@ fn mixed_shared_and_distinct_points() { other_point, ]; - reduce_open_verify::(&polys, &points, b"mixed-points"); + reduce_open_verify::(&polys, &points, b"mixed-points"); } #[test] fn empty_claims_is_noop() { - let mut transcript_p = Blake2bTranscript::new(b"empty"); + let mut transcript_p = prover_transcript(b"empty", INSTANCE, Blake2b512::default()); let reduced = reduce_prover::(Vec::new(), &mut transcript_p); assert!(reduced.is_empty()); - let mut transcript_v = Blake2bTranscript::new(b"empty"); + let mut transcript_v = verifier_transcript(b"empty", INSTANCE, Blake2b512::default(), &[]); let reduced_v = reduce_verifier::(Vec::new(), &mut transcript_v).unwrap(); assert!(reduced_v.is_empty()); } @@ -201,7 +211,7 @@ fn tampered_eval_detected() { }, ]; - let mut transcript_p = Blake2bTranscript::new(b"tampered"); + let mut transcript_p = prover_transcript(b"tampered", INSTANCE, Blake2b512::default()); let reduced_p = reduce_prover(prover_claims, &mut transcript_p); let proofs: Vec<_> = reduced_p .iter() @@ -217,7 +227,7 @@ fn tampered_eval_detected() { }) .collect(); - let mut transcript_v = Blake2bTranscript::new(b"tampered"); + let mut transcript_v = verifier_transcript(b"tampered", INSTANCE, Blake2b512::default(), &[]); let reduced_v = reduce_verifier::(verifier_claims, &mut transcript_v) .expect("reduction itself should succeed"); @@ -262,6 +272,6 @@ fn property_random_claims_always_verify() { .map(|i| points[i % num_points].clone()) .collect(); - reduce_open_verify::(&polys, &claim_points, b"property"); + reduce_open_verify::(&polys, &claim_points, b"property"); } } diff --git a/crates/jolt-sumcheck/Cargo.toml b/crates/jolt-sumcheck/Cargo.toml index 8b7e70f17c..8a2b781380 100644 --- a/crates/jolt-sumcheck/Cargo.toml +++ b/crates/jolt-sumcheck/Cargo.toml @@ -12,6 +12,7 @@ r1cs = ["dep:jolt-r1cs"] workspace = true [dependencies] +ark-serialize.workspace = true jolt-crypto.workspace = true jolt-field.workspace = true jolt-openings.workspace = true @@ -21,7 +22,3 @@ jolt-transcript.workspace = true serde = { workspace = true, features = ["derive"] } tracing.workspace = true thiserror.workspace = true - -[dev-dependencies] -num-traits = { workspace = true } -rand_core = { workspace = true } diff --git a/crates/jolt-sumcheck/fuzz/fuzz_targets/batched_sumcheck_verifier.rs b/crates/jolt-sumcheck/fuzz/fuzz_targets/batched_sumcheck_verifier.rs index 0f1d280c46..7aa6fb4599 100644 --- a/crates/jolt-sumcheck/fuzz/fuzz_targets/batched_sumcheck_verifier.rs +++ b/crates/jolt-sumcheck/fuzz/fuzz_targets/batched_sumcheck_verifier.rs @@ -11,7 +11,7 @@ use jolt_field::{Fr, ReducingBytes}; use jolt_poly::UnivariatePoly; use jolt_sumcheck::{BatchedSumcheckVerifier, BooleanHypercube, SumcheckClaim}; -use jolt_transcript::{Blake2bTranscript, Transcript}; +use jolt_transcript::{prover_transcript, Blake2b512}; use libfuzzer_sys::fuzz_target; /// Bytes per BN254 scalar. @@ -75,7 +75,8 @@ fuzz_target!(|data: &[u8]| { // The verifier must terminate without panicking on any input — including // `n_claims = 0`, which must surface as `SumcheckError::EmptyClaims`. - let mut transcript = Blake2bTranscript::new(b"jolt-sumcheck-batched-fuzz"); + let mut transcript = + prover_transcript(b"jolt-sumcheck-batched-fuzz", [0u8; 32], Blake2b512::default()); let _ = BatchedSumcheckVerifier::verify::, _>( &claims, &round_proofs, diff --git a/crates/jolt-sumcheck/fuzz/fuzz_targets/sumcheck_verifier.rs b/crates/jolt-sumcheck/fuzz/fuzz_targets/sumcheck_verifier.rs index 8e843d862a..5a248fe23d 100644 --- a/crates/jolt-sumcheck/fuzz/fuzz_targets/sumcheck_verifier.rs +++ b/crates/jolt-sumcheck/fuzz/fuzz_targets/sumcheck_verifier.rs @@ -11,7 +11,7 @@ use jolt_field::{Fr, ReducingBytes}; use jolt_poly::UnivariatePoly; use jolt_sumcheck::{BooleanHypercube, SumcheckClaim, SumcheckVerifier}; -use jolt_transcript::{Blake2bTranscript, Transcript}; +use jolt_transcript::{prover_transcript, Blake2b512}; use libfuzzer_sys::fuzz_target; /// Bytes per BN254 scalar. @@ -60,7 +60,7 @@ fuzz_target!(|data: &[u8]| { } // The verifier must terminate without panicking on any input. - let mut transcript = Blake2bTranscript::new(b"jolt-sumcheck-fuzz"); + let mut transcript = prover_transcript(b"jolt-sumcheck-fuzz", [0u8; 32], Blake2b512::default()); let _ = SumcheckVerifier::verify::, _>( &claim, &round_proofs, diff --git a/crates/jolt-sumcheck/fuzz/fuzz_targets/valid_prefix_proof.rs b/crates/jolt-sumcheck/fuzz/fuzz_targets/valid_prefix_proof.rs index 9532b07085..b53b9c6060 100644 --- a/crates/jolt-sumcheck/fuzz/fuzz_targets/valid_prefix_proof.rs +++ b/crates/jolt-sumcheck/fuzz/fuzz_targets/valid_prefix_proof.rs @@ -25,7 +25,7 @@ use jolt_field::{Fr, ReducingBytes}; use jolt_poly::UnivariatePoly; use jolt_sumcheck::{BooleanHypercube, SumcheckClaim, SumcheckVerifier}; -use jolt_transcript::{AppendToTranscript, Blake2bTranscript, Transcript}; +use jolt_transcript::{prover_transcript, Blake2b512, FsAbsorb, FsChallenge}; use libfuzzer_sys::fuzz_target; const SCALAR_BYTES: usize = 32; @@ -50,7 +50,7 @@ fuzz_target!(|data: &[u8]| { // `valid_rounds` rounds, so the proof is valid by construction up to // round `valid_rounds - 1` and the verifier's running sum after // ingesting those rounds matches what we compute below. - let mut prover_transcript = Blake2bTranscript::new(b"jolt-sumcheck-valid-fuzz"); + let mut pt = prover_transcript(b"jolt-sumcheck-valid-fuzz", [0u8; 32], Blake2b512::default()); let mut running_sum = claimed_sum; let mut round_proofs: Vec> = Vec::with_capacity(num_vars); @@ -82,11 +82,17 @@ fuzz_target!(|data: &[u8]| { coeffs.extend_from_slice(&c_high); let poly = UnivariatePoly::new(coeffs); - // Mirror the verifier's transcript / challenge / evaluate sequence. - for c in poly.coefficients() { - c.append_to_transcript(&mut prover_transcript); - } - let r: Fr = prover_transcript.challenge(); + // Mirror the verifier's transcript / challenge / evaluate sequence + // EXACTLY. The verifier absorbs the round polynomial as a single + // `absorb_field_slice(coefficients())` message (see + // `RoundMessage for UnivariatePoly`), so the prover side must too — + // absorbing coefficients one-by-one is a different sponge input and + // yields a different challenge. `pt` is a concrete `ProverState`, + // which carries spongefish's deprecated inherent `challenge`; call + // the `FsChallenge` trait method explicitly so we get the migrated + // challenge, not the inherent one. + pt.absorb_field_slice(poly.coefficients()); + let r: Fr = FsChallenge::::challenge(&mut pt); running_sum = poly.evaluate(r); round_proofs.push(poly); } else { @@ -113,12 +119,12 @@ fuzz_target!(|data: &[u8]| { } } - let mut verifier_transcript = Blake2bTranscript::new(b"jolt-sumcheck-valid-fuzz"); + let mut vt = prover_transcript(b"jolt-sumcheck-valid-fuzz", [0u8; 32], Blake2b512::default()); let result = SumcheckVerifier::verify::, _>( &claim, &round_proofs, BooleanHypercube, - &mut verifier_transcript, + &mut vt, ); if valid_rounds == num_vars { diff --git a/crates/jolt-sumcheck/src/batched_verifier.rs b/crates/jolt-sumcheck/src/batched_verifier.rs index 70be11600f..985196b88f 100644 --- a/crates/jolt-sumcheck/src/batched_verifier.rs +++ b/crates/jolt-sumcheck/src/batched_verifier.rs @@ -7,17 +7,17 @@ //! earlier rounds. Each claim is scaled by $2^{N - n_i}$ where $N$ is the //! maximum `num_vars` across all claims. +use ark_serialize::CanonicalSerialize; use jolt_field::Field; -use jolt_transcript::{AppendToTranscript, Transcript}; +use jolt_transcript::FsTranscript; +use crate::append_sumcheck_claim; use crate::claim::{EvaluationClaim, SumcheckClaim, SumcheckStatement}; use crate::committed::{CommittedSumcheckConsistency, CommittedSumcheckProof}; use crate::domain::{BooleanHypercube, SumcheckDomain}; use crate::error::SumcheckError; use crate::proof::{ClearProof, CompressedSumcheckProof, SumcheckProof}; use crate::round_proof::ClearRound; -use crate::scalar::SumcheckScalar; -use crate::{append_sumcheck_claim, SUMCHECK_ROUND_TRANSCRIPT_LABEL}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct BatchedEvaluationClaim { @@ -183,8 +183,8 @@ impl BatchedSumcheckVerifier { transcript: &mut T, ) -> Result, SumcheckError> where - F: SumcheckScalar, - T: Transcript, + F: Field, + T: FsTranscript, P: ClearRound, D: SumcheckDomain, { @@ -196,7 +196,7 @@ impl BatchedSumcheckVerifier { // Fiat-Shamir: absorb claimed sums (must match prover). for claim in claims { - claim.claimed_sum.append_to_transcript(transcript); + transcript.absorb_field(&claim.claimed_sum); } let alpha: F = transcript.challenge(); @@ -227,7 +227,7 @@ impl BatchedSumcheckVerifier { ) -> Result, SumcheckError> where F: Field, - T: Transcript, + T: FsTranscript, { let statement = Self::batch_claim_statement(claims)?; let max_num_vars = statement.num_vars; @@ -255,7 +255,6 @@ impl BatchedSumcheckVerifier { &combined_claim, proof, BooleanHypercube, - SUMCHECK_ROUND_TRANSCRIPT_LABEL, transcript, )?; @@ -275,8 +274,8 @@ impl BatchedSumcheckVerifier { ) -> Result, SumcheckError> where F: Field, - C: Clone + AppendToTranscript, - T: Transcript, + C: Clone + CanonicalSerialize, + T: FsTranscript, { match proof { SumcheckProof::Clear(ClearProof::Compressed(proof)) => { @@ -308,8 +307,8 @@ impl BatchedSumcheckVerifier { ) -> Result, SumcheckError> where F: Field, - C: Clone + AppendToTranscript, - T: Transcript, + C: Clone + CanonicalSerialize, + T: FsTranscript, { match proof { SumcheckProof::Committed(proof) => { @@ -335,8 +334,8 @@ impl BatchedSumcheckVerifier { ) -> Result, SumcheckError> where F: Field, - C: Clone + AppendToTranscript, - T: Transcript, + C: Clone + CanonicalSerialize, + T: FsTranscript, { let statement = Self::batch_statement(statements)?; let batching_coefficients = Self::batching_coefficients(statements.len(), transcript); @@ -377,7 +376,7 @@ impl BatchedSumcheckVerifier { fn batching_coefficients(count: usize, transcript: &mut T) -> Vec where F: Field, - T: Transcript, + T: FsTranscript, { (0..count) .map(|_| transcript.challenge_scalar()) diff --git a/crates/jolt-sumcheck/src/committed.rs b/crates/jolt-sumcheck/src/committed.rs index 956a0a44f5..7421c3d71d 100644 --- a/crates/jolt-sumcheck/src/committed.rs +++ b/crates/jolt-sumcheck/src/committed.rs @@ -1,15 +1,13 @@ //! Committed sumcheck round messages. +use ark_serialize::CanonicalSerialize; use jolt_crypto::VectorCommitment; use jolt_field::Field; -use jolt_transcript::{AppendToTranscript, Label, LabelWithCount, Transcript}; +use jolt_transcript::{FsAbsorb, FsTranscript}; use serde::{Deserialize, Serialize}; use crate::error::SumcheckError; -use crate::round_proof::RoundMessage; - -const SUMCHECK_COMMITMENT_LABEL: &[u8] = b"sumcheck_commitment"; -const OUTPUT_CLAIMS_LABEL: &[u8] = b"output_claims_coms"; +use crate::round_proof::{RoundDegree, RoundMessage}; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct CommittedRound { @@ -17,14 +15,15 @@ pub struct CommittedRound { pub degree: usize, } -impl RoundMessage for CommittedRound { +impl RoundDegree for CommittedRound { fn degree(&self) -> usize { self.degree } +} - fn append_to_transcript(&self, transcript: &mut T) { - transcript.append(&Label(SUMCHECK_COMMITMENT_LABEL)); - self.commitment.append_to_transcript(transcript); +impl RoundMessage for CommittedRound { + fn append_to_transcript>(&self, transcript: &mut T) { + transcript.absorb(&self.commitment); } } @@ -33,15 +32,11 @@ pub struct CommittedOutputClaims { pub commitments: Vec, } -impl AppendToTranscript for CommittedOutputClaims { - fn append_to_transcript(&self, transcript: &mut T) { - transcript.append(&LabelWithCount( - OUTPUT_CLAIMS_LABEL, - self.commitments.len() as u64, - )); - for commitment in &self.commitments { - commitment.append_to_transcript(transcript); - } +impl CommittedOutputClaims { + /// Absorbs the committed output-claim commitments into `transcript` as a single + /// message, like jolt-core (`absorb(&output_claims_commitments)`). + pub fn append_to_transcript(&self, transcript: &mut T) { + transcript.absorb(&self.commitments); } } diff --git a/crates/jolt-sumcheck/src/domain.rs b/crates/jolt-sumcheck/src/domain.rs index 340df285ac..aba423bc27 100644 --- a/crates/jolt-sumcheck/src/domain.rs +++ b/crates/jolt-sumcheck/src/domain.rs @@ -3,6 +3,7 @@ use crate::error::SumcheckError; use crate::round_proof::ClearRound; use crate::scalar::SumcheckScalar; +use jolt_field::Field; use jolt_poly::lagrange::{centered_domain_start, centered_power_sums, CenteredIntegerDomainError}; pub trait SumcheckDomain { @@ -36,6 +37,7 @@ pub trait SumcheckDomain { round: &R, ) -> Result<(), SumcheckError> where + F: Field, R: ClearRound, { round.check_round_well_formed(round_index)?; diff --git a/crates/jolt-sumcheck/src/lib.rs b/crates/jolt-sumcheck/src/lib.rs index 601ce234f7..6381a3ae79 100644 --- a/crates/jolt-sumcheck/src/lib.rs +++ b/crates/jolt-sumcheck/src/lib.rs @@ -50,10 +50,11 @@ //! via front-loaded padding. //! //! ## Per-round proof types -//! - [`RoundMessage`] — degree bound and transcript absorption. +//! - [`RoundDegree`] — field-agnostic degree bound. +//! - [`RoundMessage`] — transcript absorption (over a challenge field `F`). //! - [`ClearRound`] — clear round polynomial evaluation and well-formedness. -//! - [`UnivariatePoly`](jolt_poly::UnivariatePoly) — raw, unlabelled absorb. -//! - [`LabeledRoundPoly`] — borrowed wrapper adding a `LabelWithCount` prefix. +//! - [`UnivariatePoly`](jolt_poly::UnivariatePoly) — raw round-coefficient absorb. +//! - [`LabeledRoundPoly`] — borrowed round-polynomial wrapper. //! - [`CompressedLabeledRoundPoly`] — borrowed wrapper using the compressed //! wire format (omits the linear coefficient). //! @@ -82,20 +83,15 @@ pub mod verifier; #[cfg(test)] mod tests; -/// Transcript label used for ordinary sumcheck round polynomials. -pub const SUMCHECK_ROUND_TRANSCRIPT_LABEL: &[u8] = b"sumcheck_poly"; -/// Transcript label used for univariate-skip round polynomials. -pub const UNISKIP_ROUND_TRANSCRIPT_LABEL: &[u8] = b"uniskip_poly"; -/// Transcript label used when a sumcheck claim scalar is absorbed before batching. -pub const SUMCHECK_CLAIM_TRANSCRIPT_LABEL: &[u8] = b"sumcheck_claim"; - -/// Absorbs a sumcheck claim scalar using Jolt's canonical transcript label. -pub fn append_sumcheck_claim(transcript: &mut T, claim: &A) +/// Absorbs a sumcheck claim scalar. Like jolt-core, no domain-separation label +/// is absorbed — claims are separated positionally and by the transcript's +/// one-time `DomainSeparator`/instance. +pub fn append_sumcheck_claim(transcript: &mut T, claim: &F) where - A: jolt_transcript::AppendToTranscript, - T: jolt_transcript::Transcript, + F: jolt_field::Field, + T: jolt_transcript::FsTranscript, { - transcript.append_labeled(SUMCHECK_CLAIM_TRANSCRIPT_LABEL, claim); + transcript.absorb_field(claim); } pub use batched_verifier::{ @@ -115,6 +111,8 @@ pub use r1cs::{ append_sumcheck_r1cs_constraints_for_domain, SumcheckR1csError, SumcheckR1csLayout, SumcheckR1csRound, SumcheckR1csRoundLayout, }; -pub use round_proof::{ClearRound, CompressedLabeledRoundPoly, LabeledRoundPoly, RoundMessage}; +pub use round_proof::{ + ClearRound, CompressedLabeledRoundPoly, LabeledRoundPoly, RoundDegree, RoundMessage, +}; pub use scalar::SumcheckScalar; pub use verifier::SumcheckVerifier; diff --git a/crates/jolt-sumcheck/src/proof.rs b/crates/jolt-sumcheck/src/proof.rs index 249f94e004..c2736a15bf 100644 --- a/crates/jolt-sumcheck/src/proof.rs +++ b/crates/jolt-sumcheck/src/proof.rs @@ -7,10 +7,10 @@ use crate::{ error::SumcheckError, round_proof::LabeledRoundPoly, verifier::SumcheckVerifier, - SUMCHECK_ROUND_TRANSCRIPT_LABEL, }; +use ark_serialize::CanonicalSerialize; use jolt_poly::{CompressedPoly, UnivariatePoly}; -use jolt_transcript::{AppendToTranscript, Transcript}; +use jolt_transcript::FsTranscript; use serde::{Deserialize, Serialize}; /// A sumcheck proof consisting of one univariate round polynomial per variable. @@ -85,20 +85,19 @@ impl SumcheckProof { &self, claim: &SumcheckClaim, domain: D, - round_label: &'static [u8], transcript: &mut T, ) -> Result, SumcheckError> where - T: Transcript, + T: FsTranscript, D: SumcheckDomain, - C: Clone + AppendToTranscript, + C: Clone + CanonicalSerialize, { match self { Self::Clear(ClearProof::Full(proof)) => { let rounds = proof .round_polynomials .iter() - .map(|poly| LabeledRoundPoly::new(poly, round_label)) + .map(|poly| LabeledRoundPoly::new(poly)) .collect::>(); SumcheckVerifier::verify(claim, &rounds, domain, transcript) } @@ -120,17 +119,13 @@ impl SumcheckProof { transcript: &mut T, ) -> Result, SumcheckError> where - T: Transcript, - C: Clone + AppendToTranscript, + T: FsTranscript, + C: Clone + CanonicalSerialize, { match self { - Self::Clear(ClearProof::Compressed(proof)) => SumcheckVerifier::verify_compressed( - claim, - proof, - BooleanHypercube, - SUMCHECK_ROUND_TRANSCRIPT_LABEL, - transcript, - ), + Self::Clear(ClearProof::Compressed(proof)) => { + SumcheckVerifier::verify_compressed(claim, proof, BooleanHypercube, transcript) + } Self::Clear(ClearProof::Full(_)) => Err(SumcheckError::WrongProofEncoding { expected: "compressed clear", got: "full clear", @@ -154,8 +149,8 @@ impl SumcheckProof { transcript: &mut T, ) -> Result, SumcheckError> where - T: Transcript, - C: Clone + AppendToTranscript, + T: FsTranscript, + C: Clone + CanonicalSerialize, { match self { Self::Committed(proof) => proof.verify_committed_consistency(statement, transcript), diff --git a/crates/jolt-sumcheck/src/round_proof.rs b/crates/jolt-sumcheck/src/round_proof.rs index 9d9ecef3a2..1d5f9dfc75 100644 --- a/crates/jolt-sumcheck/src/round_proof.rs +++ b/crates/jolt-sumcheck/src/round_proof.rs @@ -2,21 +2,28 @@ use jolt_field::Field; use jolt_poly::{UnivariatePoly, UnivariatePolynomial}; -use jolt_transcript::{AppendToTranscript, LabelWithCount, Transcript}; +use jolt_transcript::FsTranscript; use crate::error::SumcheckError; -use crate::scalar::SumcheckScalar; -use crate::{SUMCHECK_ROUND_TRANSCRIPT_LABEL, UNISKIP_ROUND_TRANSCRIPT_LABEL}; -/// Common interface for one sumcheck round message. -pub trait RoundMessage { +/// Degree of a sumcheck round message. +/// +/// Field-agnostic supertrait of [`RoundMessage`]: commitment-backed round +/// messages report a degree without pinning a challenge field, which keeps +/// `degree()` unambiguous when the message type implements `RoundMessage` +/// for more than one `F`. +pub trait RoundDegree { fn degree(&self) -> usize; +} - fn append_to_transcript(&self, transcript: &mut T); +/// Common interface for one sumcheck round message that absorbs into a +/// Fiat-Shamir transcript over challenge field `F`. +pub trait RoundMessage: RoundDegree { + fn append_to_transcript>(&self, transcript: &mut T); } /// A round message whose polynomial is available to the verifier. -pub trait ClearRound: RoundMessage { +pub trait ClearRound: RoundMessage { fn evaluate(&self, challenge: F) -> F; fn coefficient_linear_combination(&self, coefficients: &[F]) -> F; @@ -26,15 +33,15 @@ pub trait ClearRound: RoundMessage { } } -impl RoundMessage for UnivariatePoly { +impl RoundDegree for UnivariatePoly { fn degree(&self) -> usize { UnivariatePolynomial::degree(self) } +} - fn append_to_transcript(&self, transcript: &mut T) { - for coeff in self.coefficients() { - coeff.append_to_transcript(transcript); - } +impl RoundMessage for UnivariatePoly { + fn append_to_transcript>(&self, transcript: &mut T) { + transcript.absorb_field_slice(self.coefficients()); } } @@ -52,37 +59,28 @@ impl ClearRound for UnivariatePoly { } } -/// Round polynomial paired with a Fiat-Shamir domain-separation label. +/// Borrowed round-polynomial wrapper. Rounds are domain-separated positionally +/// (and by the transcript's one-time `DomainSeparator`/instance), matching +/// jolt-core — no per-round label is absorbed. pub struct LabeledRoundPoly<'a, F: Field> { poly: &'a UnivariatePoly, - label: &'static [u8], } impl<'a, F: Field> LabeledRoundPoly<'a, F> { - pub fn new(poly: &'a UnivariatePoly, label: &'static [u8]) -> Self { - Self { poly, label } - } - - pub fn sumcheck(poly: &'a UnivariatePoly) -> Self { - Self::new(poly, SUMCHECK_ROUND_TRANSCRIPT_LABEL) - } - - pub fn uniskip(poly: &'a UnivariatePoly) -> Self { - Self::new(poly, UNISKIP_ROUND_TRANSCRIPT_LABEL) + pub fn new(poly: &'a UnivariatePoly) -> Self { + Self { poly } } } -impl RoundMessage for LabeledRoundPoly<'_, F> { +impl RoundDegree for LabeledRoundPoly<'_, F> { fn degree(&self) -> usize { - as RoundMessage>::degree(self.poly) + as RoundDegree>::degree(self.poly) } +} - fn append_to_transcript(&self, transcript: &mut T) { - let coeffs = self.poly.coefficients(); - transcript.append(&LabelWithCount(self.label, coeffs.len() as u64)); - for coeff in coeffs { - coeff.append_to_transcript(transcript); - } +impl RoundMessage for LabeledRoundPoly<'_, F> { + fn append_to_transcript>(&self, transcript: &mut T) { + transcript.absorb_field_slice(self.poly.coefficients()); } } @@ -104,35 +102,29 @@ impl ClearRound for LabeledRoundPoly<'_, F> { /// `running_sum = s(0) + s(1) = 2·c_0 + c_1 + c_2 + … + c_d`. pub struct CompressedLabeledRoundPoly<'a, F: Field> { poly: &'a UnivariatePoly, - label: &'static [u8], } impl<'a, F: Field> CompressedLabeledRoundPoly<'a, F> { - pub fn new(poly: &'a UnivariatePoly, label: &'static [u8]) -> Self { - Self { poly, label } - } - - pub fn sumcheck(poly: &'a UnivariatePoly) -> Self { - Self::new(poly, SUMCHECK_ROUND_TRANSCRIPT_LABEL) - } - - pub fn uniskip(poly: &'a UnivariatePoly) -> Self { - Self::new(poly, UNISKIP_ROUND_TRANSCRIPT_LABEL) + pub fn new(poly: &'a UnivariatePoly) -> Self { + Self { poly } } } -impl RoundMessage for CompressedLabeledRoundPoly<'_, F> { +impl RoundDegree for CompressedLabeledRoundPoly<'_, F> { fn degree(&self) -> usize { - as RoundMessage>::degree(self.poly) + as RoundDegree>::degree(self.poly) } +} - fn append_to_transcript(&self, transcript: &mut T) { +impl RoundMessage for CompressedLabeledRoundPoly<'_, F> { + fn append_to_transcript>(&self, transcript: &mut T) { + // Absorb the compressed coefficients (linear term c1 omitted) as ONE message, + // matching the verifier's `absorb_field_slice(coeffs_except_linear_term)`. let coeffs = self.poly.coefficients(); - transcript.append(&LabelWithCount(self.label, (coeffs.len() - 1) as u64)); - coeffs[0].append_to_transcript(transcript); - for c in coeffs.iter().skip(2) { - c.append_to_transcript(transcript); - } + let mut compressed = Vec::with_capacity(coeffs.len().saturating_sub(1)); + compressed.push(coeffs[0]); + compressed.extend_from_slice(&coeffs[2..]); + transcript.absorb_field_slice(&compressed); } } diff --git a/crates/jolt-sumcheck/src/tests.rs b/crates/jolt-sumcheck/src/tests.rs index b776c0c77e..12713e90c7 100644 --- a/crates/jolt-sumcheck/src/tests.rs +++ b/crates/jolt-sumcheck/src/tests.rs @@ -9,7 +9,9 @@ use jolt_crypto::{Bn254, Bn254G1, JoltGroup, Pedersen, PedersenSetup, VectorCommitment}; use jolt_field::{Fr, FromPrimitiveInt}; use jolt_poly::{CompressedPoly, UnivariatePoly}; -use jolt_transcript::{AppendToTranscript, Blake2bTranscript, Label, LabelWithCount, Transcript}; +use jolt_transcript::{ + prover_transcript, verifier_transcript, Blake2b512, FsAbsorb, FsChallenge, FsTranscript, +}; use crate::claim::{EvaluationClaim, SumcheckClaim, SumcheckStatement}; use crate::committed::{ @@ -17,25 +19,27 @@ use crate::committed::{ }; use crate::error::SumcheckError; use crate::proof::{ClearProof, ClearSumcheckProof, CompressedSumcheckProof, SumcheckProof}; -use crate::round_proof::{ClearRound, CompressedLabeledRoundPoly, LabeledRoundPoly, RoundMessage}; +use crate::round_proof::{ClearRound, CompressedLabeledRoundPoly, RoundDegree, RoundMessage}; use crate::verifier::SumcheckVerifier; use crate::{ append_sumcheck_claim, BatchedSumcheckVerifier, BooleanHypercube, CenteredIntegerDomain, - SumcheckDomain, SUMCHECK_ROUND_TRANSCRIPT_LABEL, + SumcheckDomain, }; type F = Fr; +const INSTANCE: [u8; 32] = [0u8; 32]; + /// Build an honest sumcheck proof for a multilinear polynomial given /// as evaluations over {0,1}^n. /// /// This is a minimal reference prover: in each round it computes the /// round polynomial by partial evaluation, absorbs it into the /// transcript, squeezes a challenge, and binds. -fn honest_prove( +fn honest_prove>( evals: &[F], num_vars: usize, - transcript: &mut Blake2bTranscript, + transcript: &mut T, ) -> ClearSumcheckProof { let mut buf = evals.to_vec(); let mut round_polys = Vec::with_capacity(num_vars); @@ -58,7 +62,7 @@ fn honest_prove( let round_poly = UnivariatePoly::new(vec![c0, c1]); // Absorb through the same path the unlabelled verifier uses. - as RoundMessage>::append_to_transcript(&round_poly, transcript); + as RoundMessage>::append_to_transcript(&round_poly, transcript); let r: F = transcript.challenge(); round_polys.push(round_poly); @@ -75,11 +79,10 @@ fn honest_prove( } } -fn honest_prove_compressed_labeled( +fn honest_prove_compressed_labeled>( evals: &[F], num_vars: usize, - label: &'static [u8], - transcript: &mut Blake2bTranscript, + transcript: &mut T, ) -> (ClearSumcheckProof, CompressedSumcheckProof) { let mut buf = evals.to_vec(); let mut round_polys = Vec::with_capacity(num_vars); @@ -96,8 +99,8 @@ fn honest_prove_compressed_labeled( } let round_poly = UnivariatePoly::new(vec![eval_0, eval_1 - eval_0]); - let compressed = CompressedLabeledRoundPoly::new(&round_poly, label); - as RoundMessage>::append_to_transcript( + let compressed = CompressedLabeledRoundPoly::new(&round_poly); + as RoundMessage>::append_to_transcript( &compressed, transcript, ); @@ -136,7 +139,8 @@ fn verify_valid_degree1_proof() { let sum = compute_sum(&evals); let num_vars = 3; - let mut prover_transcript = Blake2bTranscript::new(b"sumcheck-test"); + let mut prover_transcript = + prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); let proof = honest_prove(&evals, num_vars, &mut prover_transcript); let claim = SumcheckClaim { @@ -145,7 +149,8 @@ fn verify_valid_degree1_proof() { claimed_sum: sum, }; - let mut verifier_transcript = Blake2bTranscript::new(b"sumcheck-test"); + let mut verifier_transcript = + verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify( &claim, &proof.round_polynomials, @@ -173,7 +178,7 @@ fn verify_single_variable() { let evals = vec![F::from_u64(3), F::from_u64(10)]; let sum = compute_sum(&evals); // 13 - let mut pt = Blake2bTranscript::new(b"sumcheck-test"); + let mut pt = prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); let proof = honest_prove(&evals, 1, &mut pt); let claim = SumcheckClaim { @@ -182,7 +187,7 @@ fn verify_single_variable() { claimed_sum: sum, }; - let mut vt = Blake2bTranscript::new(b"sumcheck-test"); + let mut vt = verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); let EvaluationClaim { point: challenges, value: final_eval, @@ -203,12 +208,17 @@ fn centered_integer_domain_verifies_round_sum() { claimed_sum: F::from_u64(16), }; - let mut transcript = Blake2bTranscript::new(b"sumcheck-integer-domain-test"); + let mut t = verifier_transcript( + b"sumcheck-integer-domain-test", + INSTANCE, + Blake2b512::default(), + &[], + ); let result = SumcheckVerifier::verify( &claim, std::slice::from_ref(&round_poly), CenteredIntegerDomain::new(3), - &mut transcript, + &mut t, ) .unwrap(); @@ -225,13 +235,14 @@ fn centered_integer_domain_uses_core_even_window_convention() { claimed_sum: F::from_i64(2), }; - let mut transcript = Blake2bTranscript::new(b"sumcheck-integer-domain-test"); - let result = SumcheckVerifier::verify( - &claim, - &[round_poly], - CenteredIntegerDomain::new(4), - &mut transcript, + let mut t = verifier_transcript( + b"sumcheck-integer-domain-test", + INSTANCE, + Blake2b512::default(), + &[], ); + let result = + SumcheckVerifier::verify(&claim, &[round_poly], CenteredIntegerDomain::new(4), &mut t); assert!(result.is_ok(), "verification failed: {:?}", result.err()); } @@ -245,13 +256,14 @@ fn centered_integer_domain_rejects_wrong_sum() { claimed_sum: F::from_u64(3), }; - let mut transcript = Blake2bTranscript::new(b"sumcheck-integer-domain-test"); - let result = SumcheckVerifier::verify( - &claim, - &[round_poly], - CenteredIntegerDomain::new(4), - &mut transcript, + let mut t = verifier_transcript( + b"sumcheck-integer-domain-test", + INSTANCE, + Blake2b512::default(), + &[], ); + let result = + SumcheckVerifier::verify(&claim, &[round_poly], CenteredIntegerDomain::new(4), &mut t); assert!(matches!( result, @@ -272,13 +284,14 @@ fn centered_integer_domain_rejects_empty_domain() { claimed_sum: F::from_u64(0), }; - let mut transcript = Blake2bTranscript::new(b"sumcheck-integer-domain-test"); - let result = SumcheckVerifier::verify( - &claim, - &[round_poly], - CenteredIntegerDomain::new(0), - &mut transcript, + let mut t = verifier_transcript( + b"sumcheck-integer-domain-test", + INSTANCE, + Blake2b512::default(), + &[], ); + let result = + SumcheckVerifier::verify(&claim, &[round_poly], CenteredIntegerDomain::new(0), &mut t); assert!(matches!( result, @@ -308,7 +321,7 @@ fn verify_round_check_failure() { let evals: Vec = (1..=8).map(F::from_u64).collect(); let sum = compute_sum(&evals); - let mut pt = Blake2bTranscript::new(b"sumcheck-test"); + let mut pt = prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); let mut proof = honest_prove(&evals, 3, &mut pt); // Corrupt the first round polynomial @@ -321,7 +334,7 @@ fn verify_round_check_failure() { claimed_sum: sum, }; - let mut vt = Blake2bTranscript::new(b"sumcheck-test"); + let mut vt = verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt); assert!(result.is_err()); @@ -336,7 +349,7 @@ fn verify_wrong_num_rounds() { let evals: Vec = (1..=8).map(F::from_u64).collect(); let sum = compute_sum(&evals); - let mut pt = Blake2bTranscript::new(b"sumcheck-test"); + let mut pt = prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); let mut proof = honest_prove(&evals, 3, &mut pt); // Remove the last round @@ -348,7 +361,7 @@ fn verify_wrong_num_rounds() { claimed_sum: sum, }; - let mut vt = Blake2bTranscript::new(b"sumcheck-test"); + let mut vt = verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt); match result.unwrap_err() { @@ -365,7 +378,7 @@ fn verify_degree_exceeded() { let evals: Vec = (1..=4).map(F::from_u64).collect(); let sum = compute_sum(&evals); - let mut pt = Blake2bTranscript::new(b"sumcheck-test"); + let mut pt = prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); let mut proof = honest_prove(&evals, 2, &mut pt); // Replace first round poly with a degree-3 polynomial (4 coefficients) @@ -378,7 +391,7 @@ fn verify_degree_exceeded() { claimed_sum: sum, }; - let mut vt = Blake2bTranscript::new(b"sumcheck-test"); + let mut vt = verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt); match result.unwrap_err() { @@ -395,7 +408,7 @@ fn verify_wrong_claimed_sum() { let evals: Vec = (1..=4).map(F::from_u64).collect(); let real_sum = compute_sum(&evals); - let mut pt = Blake2bTranscript::new(b"sumcheck-test"); + let mut pt = prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); let proof = honest_prove(&evals, 2, &mut pt); // Claim a different sum @@ -405,7 +418,7 @@ fn verify_wrong_claimed_sum() { claimed_sum: real_sum + F::from_u64(1), }; - let mut vt = Blake2bTranscript::new(b"sumcheck-test"); + let mut vt = verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt); assert!(result.is_err()); @@ -415,42 +428,18 @@ fn verify_wrong_claimed_sum() { )); } -#[test] -fn clear_round_verifier_with_label_absorbs_label() { - // poly(0) = 5, poly(1) = 5 + 3 = 8, sum = 13 - let poly = UnivariatePoly::new(vec![F::from_u64(5), F::from_u64(3)]); - let label: &[u8; 10] = b"test_label"; - let labeled = LabeledRoundPoly::new(&poly, label); - - let mut t1 = Blake2bTranscript::::new(b"sumcheck-test"); - as RoundMessage>::append_to_transcript(&labeled, &mut t1); - let c1: F = t1.challenge(); - - // Absorb manually (should match) - let mut t2 = Blake2bTranscript::new(b"sumcheck-test"); - t2.append(&LabelWithCount(label, 2)); - for coeff in poly.coefficients() { - coeff.append_to_transcript(&mut t2); - } - let c2: F = t2.challenge(); - - assert_eq!(c1, c2, "labeled absorption must match manual absorption"); -} - #[test] fn clear_round_verifier_no_label() { let poly = UnivariatePoly::new(vec![F::from_u64(5), F::from_u64(3)]); - let mut t1 = Blake2bTranscript::::new(b"sumcheck-test"); - as RoundMessage>::append_to_transcript(&poly, &mut t1); - let c1: F = t1.challenge(); + let mut t1 = prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); + as RoundMessage>::append_to_transcript(&poly, &mut t1); + let c1: F = FsChallenge::::challenge(&mut t1); - // Manual: just coefficients, no label - let mut t2 = Blake2bTranscript::new(b"sumcheck-test"); - for coeff in poly.coefficients() { - coeff.append_to_transcript(&mut t2); - } - let c2: F = t2.challenge(); + // Manual: the whole coefficient vector as one message, no label. + let mut t2 = prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); + t2.absorb_field_slice(poly.coefficients()); + let c2: F = FsChallenge::::challenge(&mut t2); assert_eq!(c1, c2, "unlabeled absorption must match manual absorption"); } @@ -460,22 +449,22 @@ fn clear_round_verifier_compressed_matches_manual_absorption() { // s(X) = 2 + 3*X + 5*X^2 ⇒ s(0) = 2, s(1) = 10, running_sum = 12. // Sanity: 2*c0 + c1 + c2 = 2*2 + 3 + 5 = 12. let poly = UnivariatePoly::new(vec![F::from_u64(2), F::from_u64(3), F::from_u64(5)]); - let label: &[u8; 15] = b"compressed_test"; - let compressed = CompressedLabeledRoundPoly::new(&poly, label); + let compressed = CompressedLabeledRoundPoly::new(&poly); - let mut t1 = Blake2bTranscript::::new(b"sumcheck-test"); - as RoundMessage>::append_to_transcript(&compressed, &mut t1); - let ch1: F = t1.challenge(); + let mut t1 = prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); + as RoundMessage>::append_to_transcript( + &compressed, + &mut t1, + ); + let ch1: F = FsChallenge::::challenge(&mut t1); - // Manual absorb matching the compressed wire format: label_with_count(d), c0, c2..cd. - let mut t2 = Blake2bTranscript::new(b"sumcheck-test"); + // Manual absorb matching the compressed wire format: [c0, c2..cd] as one message. + let mut t2 = prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); let coeffs = poly.coefficients(); - t2.append(&LabelWithCount(label, (coeffs.len() - 1) as u64)); - coeffs[0].append_to_transcript(&mut t2); - for c in coeffs.iter().skip(2) { - c.append_to_transcript(&mut t2); - } - let ch2: F = t2.challenge(); + let mut compressed = vec![coeffs[0]]; + compressed.extend_from_slice(&coeffs[2..]); + t2.absorb_field_slice(&compressed); + let ch2: F = FsChallenge::::challenge(&mut t2); assert_eq!( ch1, ch2, @@ -489,7 +478,7 @@ fn clear_round_verifier_compressed_rejects_wrong_running_sum() { // (s(0) + s(1) == running_sum) still binds every coefficient. let poly = UnivariatePoly::new(vec![F::from_u64(2), F::from_u64(3), F::from_u64(5)]); let wrong_running_sum = F::from_u64(999); - let compressed = CompressedLabeledRoundPoly::new(&poly, b"compressed_test"); + let compressed = CompressedLabeledRoundPoly::new(&poly); let result = BooleanHypercube.check_round_sum(0, wrong_running_sum, &compressed); assert!( @@ -506,7 +495,7 @@ fn clear_round_verifier_compressed_rejects_short_polynomial() { // Compressed encoding omits the linear term; a polynomial with fewer than // two coefficients has no linear term to recover and is malformed. let degree_zero = UnivariatePoly::new(vec![F::from_u64(7)]); - let compressed = CompressedLabeledRoundPoly::new(°ree_zero, b"compressed_test"); + let compressed = CompressedLabeledRoundPoly::new(°ree_zero); let result = BooleanHypercube.check_round_sum(3, F::from_u64(14), &compressed); assert!( @@ -523,11 +512,11 @@ fn owned_compressed_verify_matches_borrowed_compressed_rounds() { let evals: Vec = (1..=8).map(F::from_u64).collect(); let num_vars = 3; let sum = compute_sum(&evals); - let label = SUMCHECK_ROUND_TRANSCRIPT_LABEL; - let mut prover_transcript = Blake2bTranscript::new(b"sumcheck-test"); + let mut prover_transcript = + prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); let (clear_proof, compressed_proof) = - honest_prove_compressed_labeled(&evals, num_vars, label, &mut prover_transcript); + honest_prove_compressed_labeled(&evals, num_vars, &mut prover_transcript); let claim = SumcheckClaim { num_vars, degree: 1, @@ -537,20 +526,26 @@ fn owned_compressed_verify_matches_borrowed_compressed_rounds() { let wrapped = clear_proof .round_polynomials .iter() - .map(|poly| CompressedLabeledRoundPoly::new(poly, label)) + .map(CompressedLabeledRoundPoly::new) .collect::>(); - let mut borrowed_transcript = Blake2bTranscript::new(b"sumcheck-test"); + let mut borrowed_transcript = + verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); let borrowed = SumcheckVerifier::verify(&claim, &wrapped, BooleanHypercube, &mut borrowed_transcript) .unwrap(); - let mut owned_transcript = Blake2bTranscript::new(b"sumcheck-test"); + let mut owned_transcript = + verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); let owned = compressed_proof - .verify(&claim, BooleanHypercube, label, &mut owned_transcript) + .verify(&claim, BooleanHypercube, &mut owned_transcript) .unwrap(); assert_eq!(owned, borrowed); - assert_eq!(owned_transcript.state(), borrowed_transcript.state()); + // Both transcripts consumed identical messages; the next squeezed challenge + // must agree (the spongefish `state()` accessor the facade exposed is gone). + let owned_next: F = FsChallenge::::challenge(&mut owned_transcript); + let borrowed_next: F = FsChallenge::::challenge(&mut borrowed_transcript); + assert_eq!(owned_next, borrowed_next); let poly = jolt_poly::Polynomial::new(evals); assert_eq!(owned.value, poly.evaluate_and_consume(&owned.point)); @@ -567,13 +562,8 @@ fn owned_compressed_verify_rejects_wrong_round_count() { claimed_sum: F::from_u64(0), }; - let mut transcript = Blake2bTranscript::::new(b"sumcheck-test"); - let result = proof.verify( - &claim, - BooleanHypercube, - SUMCHECK_ROUND_TRANSCRIPT_LABEL, - &mut transcript, - ); + let mut t = verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); + let result = proof.verify(&claim, BooleanHypercube, &mut t); assert!(matches!( result, @@ -595,13 +585,8 @@ fn owned_compressed_verify_rejects_degree_bound_exceeded() { claimed_sum: F::from_u64(3), }; - let mut transcript = Blake2bTranscript::::new(b"sumcheck-test"); - let result = proof.verify( - &claim, - BooleanHypercube, - SUMCHECK_ROUND_TRANSCRIPT_LABEL, - &mut transcript, - ); + let mut t = verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); + let result = proof.verify(&claim, BooleanHypercube, &mut t); assert!(matches!( result, @@ -620,13 +605,8 @@ fn owned_compressed_verify_rejects_empty_round_polynomial() { claimed_sum: F::from_u64(0), }; - let mut transcript = Blake2bTranscript::::new(b"sumcheck-test"); - let result = proof.verify( - &claim, - BooleanHypercube, - SUMCHECK_ROUND_TRANSCRIPT_LABEL, - &mut transcript, - ); + let mut t = verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); + let result = proof.verify(&claim, BooleanHypercube, &mut t); assert!(matches!( result, @@ -641,15 +621,13 @@ fn sumcheck_proof_verify_dispatches_full_clear() { })); let claim = SumcheckClaim::new(1, 1, F::from_u64(7)); - let mut transcript = Blake2bTranscript::::new(b"sumcheck-proof-dispatch"); - let reduction = proof - .verify( - &claim, - BooleanHypercube, - SUMCHECK_ROUND_TRANSCRIPT_LABEL, - &mut transcript, - ) - .unwrap(); + let mut t = verifier_transcript( + b"sumcheck-proof-dispatch", + INSTANCE, + Blake2b512::default(), + &[], + ); + let reduction = proof.verify(&claim, BooleanHypercube, &mut t).unwrap(); assert_eq!(reduction.point.len(), 1); } @@ -661,13 +639,13 @@ fn sumcheck_proof_verify_rejects_wrong_clear_encoding() { })); let claim = SumcheckClaim::new(1, 1, F::from_u64(0)); - let mut transcript = Blake2bTranscript::::new(b"sumcheck-proof-dispatch"); - let result = proof.verify( - &claim, - BooleanHypercube, - SUMCHECK_ROUND_TRANSCRIPT_LABEL, - &mut transcript, + let mut t = verifier_transcript( + b"sumcheck-proof-dispatch", + INSTANCE, + Blake2b512::default(), + &[], ); + let result = proof.verify(&claim, BooleanHypercube, &mut t); assert!(matches!( result, @@ -691,13 +669,13 @@ fn sumcheck_proof_verify_rejects_committed_encoding() { }); let claim = SumcheckClaim::new(1, 1, F::from_u64(0)); - let mut transcript = Blake2bTranscript::::new(b"sumcheck-proof-dispatch"); - let result = proof.verify( - &claim, - BooleanHypercube, - SUMCHECK_ROUND_TRANSCRIPT_LABEL, - &mut transcript, + let mut t = verifier_transcript( + b"sumcheck-proof-dispatch", + INSTANCE, + Blake2b512::default(), + &[], ); + let result = proof.verify(&claim, BooleanHypercube, &mut t); assert!(matches!( result, @@ -716,12 +694,14 @@ fn batched_verify_same_size() { let sum_a = compute_sum(&evals_a); let sum_b = compute_sum(&evals_b); - // Prove: absorb claims, squeeze alpha, combine, prove combined - let mut pt = Blake2bTranscript::new(b"sumcheck-test"); + // Prove: absorb claims, squeeze alpha, combine, prove combined. + // The batched verifier absorbs claimed sums via `absorb_field` and squeezes + // the optimized `alpha`. + let mut pt = prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); - sum_a.append_to_transcript(&mut pt); - sum_b.append_to_transcript(&mut pt); - let alpha: F = pt.challenge(); + pt.absorb_field(&sum_a); + pt.absorb_field(&sum_b); + let alpha: F = FsChallenge::::challenge(&mut pt); // Combined polynomial: evals_a[i] + alpha * evals_b[i] let combined: Vec = evals_a @@ -744,7 +724,7 @@ fn batched_verify_same_size() { }, ]; - let mut vt = Blake2bTranscript::new(b"sumcheck-test"); + let mut vt = verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); let result = BatchedSumcheckVerifier::verify( &claims, &proof.round_polynomials, @@ -767,11 +747,11 @@ fn batched_verify_different_sizes() { let max_vars = 3; - let mut pt = Blake2bTranscript::new(b"sumcheck-test"); + let mut pt = prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); - sum_a.append_to_transcript(&mut pt); - sum_b.append_to_transcript(&mut pt); - let alpha: F = pt.challenge(); + pt.absorb_field(&sum_a); + pt.absorb_field(&sum_b); + let alpha: F = FsChallenge::::challenge(&mut pt); // B is scaled by 2^(3-2) = 2 for front-loaded padding. // Combined over 2^3 = 8 points: @@ -803,7 +783,7 @@ fn batched_verify_different_sizes() { }, ]; - let mut vt = Blake2bTranscript::new(b"sumcheck-test"); + let mut vt = verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); let result = BatchedSumcheckVerifier::verify( &claims, &proof.round_polynomials, @@ -830,16 +810,16 @@ fn batched_verify_uses_domain_padding_scale() { }, ]; - let mut pt = Blake2bTranscript::new(b"sumcheck-test"); - sum_a.append_to_transcript(&mut pt); - sum_b.append_to_transcript(&mut pt); - let alpha: F = pt.challenge(); + let mut pt = prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); + pt.absorb_field(&sum_a); + pt.absorb_field(&sum_b); + let alpha: F = FsChallenge::::challenge(&mut pt); let proof = ClearSumcheckProof { round_polynomials: vec![UnivariatePoly::new(vec![alpha])], }; - let mut vt = Blake2bTranscript::new(b"sumcheck-test"); + let mut vt = verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); let result = BatchedSumcheckVerifier::verify( &claims, &proof.round_polynomials, @@ -866,15 +846,15 @@ fn batched_single_claim_matches_single_verify() { // The batched verifier absorbs the claim and squeezes alpha even for a // single claim, so the transcript diverges from the single verifier. // But internally it should still produce a valid verification. - let mut pt = Blake2bTranscript::new(b"sumcheck-test"); + let mut pt = prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); - sum.append_to_transcript(&mut pt); - let _alpha: F = pt.challenge(); + pt.absorb_field(&sum); + let _alpha: F = FsChallenge::::challenge(&mut pt); // alpha^0 = 1, so combined polynomial = evals (single claim) let proof = honest_prove(&evals, 3, &mut pt); - let mut vt = Blake2bTranscript::new(b"sumcheck-test"); + let mut vt = verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); let result = BatchedSumcheckVerifier::verify( &[claim], &proof.round_polynomials, @@ -892,7 +872,7 @@ fn batched_single_claim_matches_single_verify() { fn batched_empty_claims_returns_error() { let claims: &[SumcheckClaim] = &[]; let round_proofs: &[UnivariatePoly] = &[]; - let mut vt = Blake2bTranscript::new(b"sumcheck-test"); + let mut vt = verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); let result = BatchedSumcheckVerifier::verify(claims, round_proofs, BooleanHypercube, &mut vt); assert!(matches!(result, Err(SumcheckError::EmptyClaims))); } @@ -917,12 +897,13 @@ fn batched_compressed_verify_uses_core_batching_statement() { }, ]; - let mut prover_transcript = Blake2bTranscript::new(b"sumcheck-test"); + let mut prover_transcript = + prover_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default()); for claim in &claims { append_sumcheck_claim(&mut prover_transcript, &claim.claimed_sum); } let batching_coefficients = (0..claims.len()) - .map(|_| prover_transcript.challenge_scalar()) + .map(|_| FsChallenge::::challenge_scalar(&mut prover_transcript)) .collect::>(); let evals_b_extended: Vec = evals_b.iter().flat_map(|&value| [value, value]).collect(); @@ -931,14 +912,11 @@ fn batched_compressed_verify_uses_core_batching_statement() { .zip(&evals_b_extended) .map(|(&a, &b)| batching_coefficients[0] * a + batching_coefficients[1] * b) .collect(); - let (_full_proof, compressed_proof) = honest_prove_compressed_labeled( - &combined, - 3, - SUMCHECK_ROUND_TRANSCRIPT_LABEL, - &mut prover_transcript, - ); + let (_full_proof, compressed_proof) = + honest_prove_compressed_labeled(&combined, 3, &mut prover_transcript); - let mut verifier_transcript = Blake2bTranscript::new(b"sumcheck-test"); + let mut verifier_transcript = + verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); let result = BatchedSumcheckVerifier::verify_compressed( &claims, &compressed_proof, @@ -983,18 +961,20 @@ fn batched_sumcheck_proof_verify_dispatches_compressed_clear() { let evals: Vec = (1..=4).map(F::from_u64).collect(); let claim = SumcheckClaim::new(2, 1, compute_sum(&evals)); - let mut prover_transcript = Blake2bTranscript::new(b"batched-proof-dispatch"); + let mut prover_transcript = + prover_transcript(b"batched-proof-dispatch", INSTANCE, Blake2b512::default()); append_sumcheck_claim(&mut prover_transcript, &claim.claimed_sum); - let _batching_coefficient = prover_transcript.challenge_scalar(); - let (_full_proof, compressed_proof) = honest_prove_compressed_labeled( - &evals, - 2, - SUMCHECK_ROUND_TRANSCRIPT_LABEL, - &mut prover_transcript, - ); + let _batching_coefficient: F = FsChallenge::::challenge_scalar(&mut prover_transcript); + let (_full_proof, compressed_proof) = + honest_prove_compressed_labeled(&evals, 2, &mut prover_transcript); let proof = SumcheckProof::::Clear(ClearProof::Compressed(compressed_proof)); - let mut verifier_transcript = Blake2bTranscript::new(b"batched-proof-dispatch"); + let mut verifier_transcript = verifier_transcript( + b"batched-proof-dispatch", + INSTANCE, + Blake2b512::default(), + &[], + ); let reduction = BatchedSumcheckVerifier::verify_compressed_boolean( &[claim], &proof, @@ -1014,9 +994,13 @@ fn batched_sumcheck_proof_verify_rejects_full_clear_encoding() { })); let claim = SumcheckClaim::new(1, 1, F::from_u64(0)); - let mut transcript = Blake2bTranscript::::new(b"batched-proof-dispatch"); - let result = - BatchedSumcheckVerifier::verify_compressed_boolean(&[claim], &proof, &mut transcript); + let mut t = verifier_transcript( + b"batched-proof-dispatch", + INSTANCE, + Blake2b512::default(), + &[], + ); + let result = BatchedSumcheckVerifier::verify_compressed_boolean(&[claim], &proof, &mut t); assert!(matches!( result, @@ -1050,25 +1034,26 @@ fn batched_committed_consistency_uses_statements_without_clear_claims() { }, }); - let mut manual = Blake2bTranscript::::new(b"batched-proof-dispatch"); + let mut manual = prover_transcript(b"batched-proof-dispatch", INSTANCE, Blake2b512::default()); let batching_coefficients = (0..statements.len()) - .map(|_| manual.challenge_scalar()) + .map(|_| FsChallenge::::challenge_scalar(&mut manual)) .collect::>(); let mut expected_challenges = Vec::new(); let SumcheckProof::Committed(committed_proof) = &proof else { panic!("proof must be committed"); }; for round in &committed_proof.rounds { - manual.append(&Label(b"sumcheck_commitment")); - round.commitment.append_to_transcript(&mut manual); - expected_challenges.push(manual.challenge()); - } - manual.append(&LabelWithCount(b"output_claims_coms", 2)); - for commitment in &committed_proof.output_claims.commitments { - commitment.append_to_transcript(&mut manual); + manual.absorb(&round.commitment); + expected_challenges.push(FsChallenge::::challenge(&mut manual)); } + manual.absorb(&committed_proof.output_claims.commitments); - let mut verifier = Blake2bTranscript::::new(b"batched-proof-dispatch"); + let mut verifier = verifier_transcript( + b"batched-proof-dispatch", + INSTANCE, + Blake2b512::default(), + &[], + ); let consistency = BatchedSumcheckVerifier::verify_committed_consistency(&statements, &proof, &mut verifier) .unwrap(); @@ -1101,7 +1086,11 @@ fn batched_committed_consistency_uses_statements_without_clear_claims() { num_vars: 1 }) )); - assert_eq!(verifier.state(), manual.state()); + // Both transcripts consumed identical messages; the next squeezed challenge + // must agree. + let verifier_next: F = FsChallenge::::challenge(&mut verifier); + let manual_next: F = FsChallenge::::challenge(&mut manual); + assert_eq!(verifier_next, manual_next); } #[test] @@ -1115,9 +1104,13 @@ fn batched_claim_verifier_rejects_committed_encoding() { output_claims: CommittedOutputClaims::default(), }); - let mut transcript = Blake2bTranscript::::new(b"batched-proof-dispatch"); - let result = - BatchedSumcheckVerifier::verify_compressed_boolean(&[claim], &proof, &mut transcript); + let mut t = verifier_transcript( + b"batched-proof-dispatch", + INSTANCE, + Blake2b512::default(), + &[], + ); + let result = BatchedSumcheckVerifier::verify_compressed_boolean(&[claim], &proof, &mut t); assert!(matches!( result, @@ -1145,16 +1138,16 @@ fn committed_rounds_check_transcript_and_return_public_data() { }, ]; - let mut manual = Blake2bTranscript::::new(b"committed-sumcheck"); + let mut manual = prover_transcript(b"committed-sumcheck", INSTANCE, Blake2b512::default()); let mut expected_challenges = Vec::new(); for round in &rounds { - manual.append(&Label(b"sumcheck_commitment")); - round.commitment.append_to_transcript(&mut manual); - expected_challenges.push(manual.challenge()); + manual.absorb(&round.commitment); + expected_challenges.push(FsChallenge::::challenge(&mut manual)); } - let mut verifier = Blake2bTranscript::::new(b"committed-sumcheck"); - let consistency = SumcheckVerifier::verify_committed_round_consistency( + let mut verifier = + verifier_transcript(b"committed-sumcheck", INSTANCE, Blake2b512::default(), &[]); + let consistency = SumcheckVerifier::verify_committed_round_consistency::( SumcheckStatement::new(3, 2), &rounds, &mut verifier, @@ -1170,7 +1163,9 @@ fn committed_rounds_check_transcript_and_return_public_data() { .map(|round| round.commitment) .collect::>() ); - assert_eq!(verifier.state(), manual.state()); + let verifier_next: F = FsChallenge::::challenge(&mut verifier); + let manual_next: F = FsChallenge::::challenge(&mut manual); + assert_eq!(verifier_next, manual_next); } #[test] @@ -1179,12 +1174,12 @@ fn committed_rounds_reject_wrong_round_count() { commitment: F::from_u64(11), degree: 1, }]; - let mut transcript = Blake2bTranscript::::new(b"committed-sumcheck"); + let mut t = verifier_transcript(b"committed-sumcheck", INSTANCE, Blake2b512::default(), &[]); - let result = SumcheckVerifier::verify_committed_round_consistency( + let result = SumcheckVerifier::verify_committed_round_consistency::( SumcheckStatement::new(2, 1), &rounds, - &mut transcript, + &mut t, ); assert!(matches!( @@ -1202,20 +1197,26 @@ fn committed_rounds_reject_degree_bound_before_absorbing() { commitment: F::from_u64(11), degree: 3, }]; - let mut transcript = Blake2bTranscript::::new(b"committed-sumcheck"); - let before = transcript.state(); + let mut t = verifier_transcript(b"committed-sumcheck", INSTANCE, Blake2b512::default(), &[]); + // Capture the challenge that would be squeezed if nothing had been absorbed. + let mut probe = + verifier_transcript(b"committed-sumcheck", INSTANCE, Blake2b512::default(), &[]); + let before: F = FsChallenge::::challenge(&mut probe); - let result = SumcheckVerifier::verify_committed_round_consistency( + let result = SumcheckVerifier::verify_committed_round_consistency::( SumcheckStatement::new(1, 2), &rounds, - &mut transcript, + &mut t, ); assert!(matches!( result, Err(SumcheckError::DegreeBoundExceeded { got: 3, max: 2 }) )); - assert_eq!(transcript.state(), before); + // The degree check rejects before absorbing, so the transcript is untouched + // and still squeezes the pristine challenge. + let after: F = FsChallenge::::challenge(&mut t); + assert_eq!(after, before); } #[test] @@ -1224,17 +1225,15 @@ fn committed_output_claims_absorb_length_and_order() { commitments: vec![F::from_u64(3), F::from_u64(5), F::from_u64(8)], }; - let mut actual = Blake2bTranscript::::new(b"committed-output"); + let mut actual = prover_transcript(b"committed-output", INSTANCE, Blake2b512::default()); output_claims.append_to_transcript(&mut actual); - let mut expected = Blake2bTranscript::::new(b"committed-output"); - expected.append(&LabelWithCount(b"output_claims_coms", 3)); - for commitment in &output_claims.commitments { - commitment.append_to_transcript(&mut expected); - } + let mut expected = prover_transcript(b"committed-output", INSTANCE, Blake2b512::default()); + expected.absorb(&output_claims.commitments); - assert_eq!(actual.state(), expected.state()); - assert_eq!(actual.challenge(), expected.challenge()); + let actual_next: F = FsChallenge::::challenge(&mut actual); + let expected_next: F = FsChallenge::::challenge(&mut expected); + assert_eq!(actual_next, expected_next); } #[test] @@ -1255,26 +1254,25 @@ fn committed_proof_checks_rounds_then_output_claims() { }, }; - let mut manual = Blake2bTranscript::::new(b"committed-proof"); + let mut manual = prover_transcript(b"committed-proof", INSTANCE, Blake2b512::default()); let mut expected_challenges = Vec::new(); for round in &proof.rounds { - manual.append(&Label(b"sumcheck_commitment")); - round.commitment.append_to_transcript(&mut manual); - expected_challenges.push(manual.challenge()); - } - manual.append(&LabelWithCount(b"output_claims_coms", 2)); - for commitment in &proof.output_claims.commitments { - commitment.append_to_transcript(&mut manual); + manual.absorb(&round.commitment); + expected_challenges.push(FsChallenge::::challenge(&mut manual)); } + manual.absorb(&proof.output_claims.commitments); - let mut verifier = Blake2bTranscript::::new(b"committed-proof"); + let mut verifier = + verifier_transcript(b"committed-proof", INSTANCE, Blake2b512::default(), &[]); let consistency = proof - .verify_committed_consistency(SumcheckStatement::new(2, 2), &mut verifier) + .verify_committed_consistency::(SumcheckStatement::new(2, 2), &mut verifier) .unwrap(); assert_eq!(consistency.challenges(), expected_challenges); assert_eq!(consistency.round_degrees(), vec![1, 2]); - assert_eq!(verifier.state(), manual.state()); + let verifier_next: F = FsChallenge::::challenge(&mut verifier); + let manual_next: F = FsChallenge::::challenge(&mut manual); + assert_eq!(verifier_next, manual_next); } #[test] @@ -1288,16 +1286,18 @@ fn committed_proof_rejects_bad_round_before_output_claims() { commitments: vec![F::from_u64(21)], }, }; - let mut transcript = Blake2bTranscript::::new(b"committed-proof"); - let before = transcript.state(); + let mut t = verifier_transcript(b"committed-proof", INSTANCE, Blake2b512::default(), &[]); + let mut probe = verifier_transcript(b"committed-proof", INSTANCE, Blake2b512::default(), &[]); + let before: F = FsChallenge::::challenge(&mut probe); - let result = proof.verify_committed_consistency(SumcheckStatement::new(1, 2), &mut transcript); + let result = proof.verify_committed_consistency::(SumcheckStatement::new(1, 2), &mut t); assert!(matches!( result, Err(SumcheckError::DegreeBoundExceeded { got: 3, max: 2 }) )); - assert_eq!(transcript.state(), before); + let after: F = FsChallenge::::challenge(&mut t); + assert_eq!(after, before); } #[test] @@ -1357,13 +1357,15 @@ struct MockClearRound { fixed_sum: F, } -impl RoundMessage for MockClearRound { +impl RoundDegree for MockClearRound { fn degree(&self) -> usize { 0 } +} - fn append_to_transcript(&self, transcript: &mut T) { - F::from_u64(42).append_to_transcript(transcript); +impl RoundMessage for MockClearRound { + fn append_to_transcript>(&self, transcript: &mut T) { + transcript.absorb_field(&F::from_u64(42)); } } @@ -1392,7 +1394,7 @@ fn verify_accepts_custom_clear_round_messages() { claimed_sum: F::from_u64(0), }; - let mut vt = Blake2bTranscript::new(b"sumcheck-test"); + let mut vt = verifier_transcript(b"sumcheck-test", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &round_proofs, BooleanHypercube, &mut vt); assert!( result.is_ok(), diff --git a/crates/jolt-sumcheck/src/verifier.rs b/crates/jolt-sumcheck/src/verifier.rs index c2e340546c..fa22ba3991 100644 --- a/crates/jolt-sumcheck/src/verifier.rs +++ b/crates/jolt-sumcheck/src/verifier.rs @@ -1,8 +1,9 @@ //! Sumcheck verifier: checks round polynomials against the claimed sum. +use ark_serialize::CanonicalSerialize; use jolt_field::Field; use jolt_poly::UnivariatePolynomial; -use jolt_transcript::{AppendToTranscript, LabelWithCount, Transcript}; +use jolt_transcript::FsTranscript; use crate::claim::{EvaluationClaim, SumcheckClaim, SumcheckStatement}; use crate::committed::{ @@ -11,8 +12,7 @@ use crate::committed::{ use crate::domain::{BooleanHypercube, SumcheckDomain}; use crate::error::SumcheckError; use crate::proof::CompressedSumcheckProof; -use crate::round_proof::{ClearRound, RoundMessage}; -use crate::scalar::SumcheckScalar; +use crate::round_proof::{ClearRound, RoundDegree, RoundMessage}; /// Stateless sumcheck verifier engine. pub struct SumcheckVerifier; @@ -52,8 +52,8 @@ impl SumcheckVerifier { transcript: &mut T, ) -> Result, SumcheckError> where - F: SumcheckScalar, - T: Transcript, + F: Field, + T: FsTranscript, R: ClearRound, D: SumcheckDomain, { @@ -89,12 +89,11 @@ impl SumcheckVerifier { claim: &SumcheckClaim, proof: &CompressedSumcheckProof, domain: BooleanHypercube, - round_label: &'static [u8], transcript: &mut T, ) -> Result, SumcheckError> where F: Field, - T: Transcript, + T: FsTranscript, { if proof.round_polynomials.len() != claim.num_vars { return Err(SumcheckError::WrongNumberOfRounds { @@ -119,10 +118,7 @@ impl SumcheckVerifier { return Err(SumcheckError::CompressedPolynomialTooShort { round, got: 0 }); } - transcript.append(&LabelWithCount(round_label, coeffs.len() as u64)); - for coeff in coeffs { - coeff.append_to_transcript(transcript); - } + transcript.absorb_field_slice(coeffs); let r: F = transcript.challenge(); running_sum = round_proof.evaluate_with_hint(running_sum, r); challenges.push(r); @@ -142,9 +138,9 @@ impl SumcheckVerifier { transcript: &mut T, ) -> Result, SumcheckError> where - F: SumcheckScalar, - T: Transcript, - C: Clone + AppendToTranscript, + F: Field, + T: FsTranscript, + C: Clone + CanonicalSerialize, { if round_proofs.len() != statement.num_vars { return Err(SumcheckError::WrongNumberOfRounds { @@ -182,13 +178,12 @@ where &self, claim: &SumcheckClaim, domain: BooleanHypercube, - round_label: &'static [u8], transcript: &mut T, ) -> Result, SumcheckError> where - T: Transcript, + T: FsTranscript, { - SumcheckVerifier::verify_compressed(claim, self, domain, round_label, transcript) + SumcheckVerifier::verify_compressed(claim, self, domain, transcript) } } @@ -203,9 +198,9 @@ impl CommittedSumcheckProof { transcript: &mut T, ) -> Result, SumcheckError> where - F: SumcheckScalar, - T: Transcript, - C: Clone + AppendToTranscript, + F: Field, + T: FsTranscript, + C: Clone + CanonicalSerialize, { let consistency = SumcheckVerifier::verify_committed_round_consistency( statement, diff --git a/crates/jolt-sumcheck/tests/committed.rs b/crates/jolt-sumcheck/tests/committed.rs index af24a1e167..0dbe348743 100644 --- a/crates/jolt-sumcheck/tests/committed.rs +++ b/crates/jolt-sumcheck/tests/committed.rs @@ -7,11 +7,13 @@ use jolt_sumcheck::{ CommittedOutputClaims, CommittedRound, CommittedRoundWitness, SumcheckError, SumcheckStatement, SumcheckVerifier, }; -use jolt_transcript::{AppendToTranscript, Blake2bTranscript, LabelWithCount, Transcript}; +use jolt_transcript::{prover_transcript, verifier_transcript, Blake2b512, FsAbsorb, FsChallenge}; type F = Fr; type VC = Pedersen; +const INSTANCE: [u8; 32] = [0u8; 32]; + fn pedersen_setup(capacity: usize) -> PedersenSetup { let generator = Bn254::g1_generator(); let message_generators = (1..=capacity) @@ -50,15 +52,17 @@ fn committed_rounds_complete_with_pedersen_commitments() { ], ); - let mut prover_transcript = Blake2bTranscript::::new(b"committed-roundtrip"); + let mut prover_transcript = + prover_transcript(b"committed-roundtrip", INSTANCE, Blake2b512::default()); let mut expected_challenges = Vec::new(); for round in &rounds { - round.append_to_transcript(&mut prover_transcript); - expected_challenges.push(prover_transcript.challenge()); + RoundMessage::::append_to_transcript(round, &mut prover_transcript); + expected_challenges.push(FsChallenge::::challenge(&mut prover_transcript)); } - let mut verifier_transcript = Blake2bTranscript::::new(b"committed-roundtrip"); - let consistency = SumcheckVerifier::verify_committed_round_consistency( + let mut verifier_transcript = + verifier_transcript(b"committed-roundtrip", INSTANCE, Blake2b512::default(), &[]); + let consistency = SumcheckVerifier::verify_committed_round_consistency::( SumcheckStatement::new(rounds.len(), 2), &rounds, &mut verifier_transcript, @@ -67,7 +71,11 @@ fn committed_rounds_complete_with_pedersen_commitments() { assert_eq!(consistency.challenges(), expected_challenges); assert_eq!(consistency.round_degrees(), vec![2, 1, 2]); - assert_eq!(verifier_transcript.state(), prover_transcript.state()); + // Both transcripts consumed identical messages; the next squeezed challenge + // must agree (the spongefish `state()` accessor the facade exposed is gone). + let verifier_next: F = FsChallenge::::challenge(&mut verifier_transcript); + let prover_next: F = FsChallenge::::challenge(&mut prover_transcript); + assert_eq!(verifier_next, prover_next); } #[test] @@ -81,8 +89,9 @@ fn committed_rounds_reject_wrong_count_and_degree() { ], ); - let mut wrong_count_transcript = Blake2bTranscript::::new(b"committed-roundtrip"); - let wrong_count = SumcheckVerifier::verify_committed_round_consistency( + let mut wrong_count_transcript = + prover_transcript(b"committed-roundtrip", INSTANCE, Blake2b512::default()); + let wrong_count = SumcheckVerifier::verify_committed_round_consistency::( SumcheckStatement::new(3, 2), &rounds, &mut wrong_count_transcript, @@ -95,8 +104,9 @@ fn committed_rounds_reject_wrong_count_and_degree() { }) )); - let mut degree_transcript = Blake2bTranscript::::new(b"committed-roundtrip"); - let degree = SumcheckVerifier::verify_committed_round_consistency( + let mut degree_transcript = + prover_transcript(b"committed-roundtrip", INSTANCE, Blake2b512::default()); + let degree = SumcheckVerifier::verify_committed_round_consistency::( SumcheckStatement::new(2, 1), &rounds, &mut degree_transcript, @@ -124,16 +134,18 @@ fn tampered_committed_round_changes_challenges() { ) .remove(0); - let mut original_transcript = Blake2bTranscript::::new(b"committed-roundtrip"); - let original = SumcheckVerifier::verify_committed_round_consistency( + let mut original_transcript = + prover_transcript(b"committed-roundtrip", INSTANCE, Blake2b512::default()); + let original = SumcheckVerifier::verify_committed_round_consistency::( SumcheckStatement::new(2, 2), &rounds, &mut original_transcript, ) .unwrap(); - let mut tampered_transcript = Blake2bTranscript::::new(b"committed-roundtrip"); - let tampered = SumcheckVerifier::verify_committed_round_consistency( + let mut tampered_transcript = + prover_transcript(b"committed-roundtrip", INSTANCE, Blake2b512::default()); + let tampered = SumcheckVerifier::verify_committed_round_consistency::( SumcheckStatement::new(2, 2), &tampered, &mut tampered_transcript, @@ -141,7 +153,11 @@ fn tampered_committed_round_changes_challenges() { .unwrap(); assert_ne!(tampered.challenges(), original.challenges()); - assert_ne!(tampered_transcript.state(), original_transcript.state()); + // A tampered commitment desyncs the sponge; the next squeezed challenge + // from each transcript must differ. + let tampered_next: F = FsChallenge::::challenge(&mut tampered_transcript); + let original_next: F = FsChallenge::::challenge(&mut original_transcript); + assert_ne!(tampered_next, original_next); } #[test] @@ -161,14 +177,13 @@ fn committed_output_claims_keep_length_and_order() { .collect::>(), }; - let mut actual = Blake2bTranscript::::new(b"committed-output"); + let mut actual = prover_transcript(b"committed-output", INSTANCE, Blake2b512::default()); output_claims.append_to_transcript(&mut actual); - let mut expected = Blake2bTranscript::::new(b"committed-output"); - expected.append(&LabelWithCount(b"output_claims_coms", 2)); - for commitment in &output_claims.commitments { - commitment.append_to_transcript(&mut expected); - } + let mut expected = prover_transcript(b"committed-output", INSTANCE, Blake2b512::default()); + expected.absorb(&output_claims.commitments); - assert_eq!(actual.state(), expected.state()); + let actual_next: F = FsChallenge::::challenge(&mut actual); + let expected_next: F = FsChallenge::::challenge(&mut expected); + assert_eq!(actual_next, expected_next); } diff --git a/crates/jolt-sumcheck/tests/mersenne61_compat.rs b/crates/jolt-sumcheck/tests/mersenne61_compat.rs deleted file mode 100644 index 5926c27574..0000000000 --- a/crates/jolt-sumcheck/tests/mersenne61_compat.rs +++ /dev/null @@ -1,371 +0,0 @@ -#![expect(clippy::unwrap_used, reason = "tests may panic on assertion failures")] - -//! Compatibility test for non-BN254 sumcheck verifier plumbing. -//! -//! `Mersenne61` is intentionally small and exists here only to prove that the -//! transcript and verifier APIs no longer depend on BN254-specific helper -//! surface. It is not a production proving field. Real sumcheck soundness with -//! this base field would require an adequately large extension field. - -use std::{ - fmt::{Debug, Display}, - hash::Hash, - iter::{Product, Sum}, - ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub, SubAssign}, -}; - -use jolt_field::{ - AdditiveGroup, CanonicalBitLength, CanonicalBytes, CanonicalU64, FieldCore, FixedByteSize, - FixedBytes, FromPrimitiveInt, Invertible, MulPow2, MulPrimitiveInt, NaiveAccumulator, - RandomSampling, ReducingBytes, RingCore, TranscriptChallenge, WithAccumulator, -}; -use jolt_sumcheck::{ - BooleanHypercube, ClearRound, EvaluationClaim, RoundMessage, SumcheckClaim, SumcheckVerifier, -}; -use jolt_transcript::{AppendToTranscript, Blake2bTranscript, KeccakTranscript, Transcript}; -use num_traits::{One, Zero}; - -const MODULUS: u64 = (1u64 << 61) - 1; - -#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)] -struct Mersenne61(u64); - -impl Mersenne61 { - fn reduce_u128(x: u128) -> Self { - let p = MODULUS as u128; - let mut y = (x & p) + (x >> 61); - y = (y & p) + (y >> 61); - if y >= p { - y -= p; - } - Self(y as u64) - } - - fn pow(self, mut exp: u64) -> Self { - let mut base = self; - let mut acc = Self::one(); - while exp > 0 { - if exp & 1 == 1 { - acc *= base; - } - base *= base; - exp >>= 1; - } - acc - } -} - -impl Debug for Mersenne61 { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Debug::fmt(&self.0, f) - } -} - -impl Display for Mersenne61 { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - Display::fmt(&self.0, f) - } -} - -impl Zero for Mersenne61 { - fn zero() -> Self { - Self(0) - } - - fn is_zero(&self) -> bool { - self.0 == 0 - } -} - -impl One for Mersenne61 { - fn one() -> Self { - Self(1) - } - - fn is_one(&self) -> bool { - self.0 == 1 - } -} - -impl Add for Mersenne61 { - type Output = Self; - - fn add(self, rhs: Self) -> Self::Output { - let mut sum = self.0 + rhs.0; - if sum >= MODULUS { - sum -= MODULUS; - } - Self(sum) - } -} - -impl Add<&Self> for Mersenne61 { - type Output = Self; - - fn add(self, rhs: &Self) -> Self::Output { - self + *rhs - } -} - -impl AddAssign for Mersenne61 { - fn add_assign(&mut self, rhs: Self) { - *self = *self + rhs; - } -} - -impl Sub for Mersenne61 { - type Output = Self; - - fn sub(self, rhs: Self) -> Self::Output { - if self.0 >= rhs.0 { - Self(self.0 - rhs.0) - } else { - Self(MODULUS - (rhs.0 - self.0)) - } - } -} - -impl Sub<&Self> for Mersenne61 { - type Output = Self; - - fn sub(self, rhs: &Self) -> Self::Output { - self - *rhs - } -} - -impl SubAssign for Mersenne61 { - fn sub_assign(&mut self, rhs: Self) { - *self = *self - rhs; - } -} - -impl Neg for Mersenne61 { - type Output = Self; - - fn neg(self) -> Self::Output { - if self.is_zero() { - self - } else { - Self(MODULUS - self.0) - } - } -} - -impl Mul for Mersenne61 { - type Output = Self; - - fn mul(self, rhs: Self) -> Self::Output { - Self::reduce_u128(self.0 as u128 * rhs.0 as u128) - } -} - -impl Mul<&Self> for Mersenne61 { - type Output = Self; - - fn mul(self, rhs: &Self) -> Self::Output { - self * *rhs - } -} - -impl MulAssign for Mersenne61 { - fn mul_assign(&mut self, rhs: Self) { - *self = *self * rhs; - } -} - -impl Sum for Mersenne61 { - fn sum>(iter: I) -> Self { - iter.fold(Self::zero(), |acc, x| acc + x) - } -} - -impl<'a> Sum<&'a Mersenne61> for Mersenne61 { - fn sum>(iter: I) -> Self { - iter.copied().sum() - } -} - -impl Product for Mersenne61 { - fn product>(iter: I) -> Self { - iter.fold(Self::one(), |acc, x| acc * x) - } -} - -impl<'a> Product<&'a Mersenne61> for Mersenne61 { - fn product>(iter: I) -> Self { - iter.copied().product() - } -} - -impl AdditiveGroup for Mersenne61 {} -impl RingCore for Mersenne61 {} - -impl Invertible for Mersenne61 { - fn inverse(&self) -> Option { - if self.is_zero() { - None - } else { - Some(self.pow(MODULUS - 2)) - } - } -} - -impl FieldCore for Mersenne61 {} - -impl FromPrimitiveInt for Mersenne61 { - fn from_u64(v: u64) -> Self { - Self::reduce_u128(v as u128) - } - - fn from_i64(v: i64) -> Self { - if v >= 0 { - Self::from_u64(v as u64) - } else { - -Self::from_u64(v.unsigned_abs()) - } - } - - fn from_u128(v: u128) -> Self { - Self::reduce_u128(v) - } - - fn from_i128(v: i128) -> Self { - if v >= 0 { - Self::from_u128(v as u128) - } else { - -Self::from_u128(v.unsigned_abs()) - } - } -} - -impl RandomSampling for Mersenne61 { - fn random(rng: &mut R) -> Self { - Self::from_u64(rng.next_u64()) - } -} - -impl CanonicalBytes for Mersenne61 { - fn to_bytes_le(&self, out: &mut [u8]) { - assert_eq!(out.len(), 8); - out.copy_from_slice(&self.0.to_le_bytes()); - } -} - -impl ReducingBytes for Mersenne61 { - fn from_le_bytes_mod_order(bytes: &[u8]) -> Self { - let mut buf = [0u8; 16]; - let len = bytes.len().min(16); - buf[..len].copy_from_slice(&bytes[..len]); - Self::from_u128(u128::from_le_bytes(buf)) - } -} - -impl TranscriptChallenge for Mersenne61 { - fn from_challenge_bytes(bytes: &[u8]) -> Self { - Self::from_le_bytes_mod_order(bytes) - } -} - -impl FixedByteSize for Mersenne61 { - const NUM_BYTES: usize = 8; -} - -impl FixedBytes<8> for Mersenne61 {} - -impl CanonicalBitLength for Mersenne61 { - fn num_bits(&self) -> u32 { - u64::BITS - self.0.leading_zeros() - } -} - -impl CanonicalU64 for Mersenne61 { - fn to_canonical_u64_checked(&self) -> Option { - Some(self.0) - } -} - -impl WithAccumulator for Mersenne61 { - type Accumulator = NaiveAccumulator; -} - -impl MulPow2 for Mersenne61 {} -impl MulPrimitiveInt for Mersenne61 {} - -#[derive(Clone, Debug)] -struct LinearRound { - coeffs: [Mersenne61; 2], -} - -impl RoundMessage for LinearRound { - fn degree(&self) -> usize { - 1 - } - - fn append_to_transcript(&self, transcript: &mut T) { - self.coeffs[0].append_to_transcript(transcript); - self.coeffs[1].append_to_transcript(transcript); - } -} - -impl ClearRound for LinearRound { - fn evaluate(&self, challenge: Mersenne61) -> Mersenne61 { - self.coeffs[0] + self.coeffs[1] * challenge - } - - fn coefficient_linear_combination(&self, coefficients: &[Mersenne61]) -> Mersenne61 { - self.coeffs - .iter() - .zip(coefficients) - .map(|(&coefficient, &scale)| coefficient * scale) - .sum() - } -} - -fn build_rounds() -> ( - SumcheckClaim, - Vec, - EvaluationClaim, -) { - let mut transcript = Blake2bTranscript::::new(b"mersenne61"); - let mut running_sum = Mersenne61::from_u64(10); - let mut point = Vec::new(); - let mut rounds = Vec::new(); - - for _ in 0..4 { - let c0 = running_sum * Mersenne61::from_u64(3); - let c1 = running_sum - c0 - c0; - let round = LinearRound { coeffs: [c0, c1] }; - round.append_to_transcript(&mut transcript); - let r = transcript.challenge(); - running_sum = round.evaluate(r); - point.push(r); - rounds.push(round); - } - - ( - SumcheckClaim::new(4, 1, Mersenne61::from_u64(10)), - rounds, - EvaluationClaim::new(point, running_sum), - ) -} - -#[test] -fn hash_transcripts_accept_mersenne61_without_bn254_field_surface() { - let mut blake = Blake2bTranscript::::new(b"compat"); - let mut keccak = KeccakTranscript::::new(b"compat"); - Mersenne61::from_u64(42).append_to_transcript(&mut blake); - Mersenne61::from_u64(42).append_to_transcript(&mut keccak); - - let _: Mersenne61 = blake.challenge(); - let _: Mersenne61 = keccak.challenge(); -} - -#[test] -fn sumcheck_verifier_accepts_mersenne61_round_proof() { - let (claim, rounds, expected) = build_rounds(); - let mut verifier_transcript = Blake2bTranscript::::new(b"mersenne61"); - let actual = - SumcheckVerifier::verify(&claim, &rounds, BooleanHypercube, &mut verifier_transcript) - .unwrap(); - assert_eq!(actual, expected); -} diff --git a/crates/jolt-sumcheck/tests/roundtrip.rs b/crates/jolt-sumcheck/tests/roundtrip.rs index 19f95f069d..d421ccf9cf 100644 --- a/crates/jolt-sumcheck/tests/roundtrip.rs +++ b/crates/jolt-sumcheck/tests/roundtrip.rs @@ -11,13 +11,15 @@ use jolt_poly::{Polynomial, UnivariatePoly}; use jolt_sumcheck::claim::{EvaluationClaim, SumcheckClaim}; use jolt_sumcheck::proof::ClearSumcheckProof; use jolt_sumcheck::round_proof::{CompressedLabeledRoundPoly, LabeledRoundPoly, RoundMessage}; -use jolt_sumcheck::{ - BatchedSumcheckVerifier, BooleanHypercube, SumcheckVerifier, SUMCHECK_ROUND_TRANSCRIPT_LABEL, +use jolt_sumcheck::{BatchedSumcheckVerifier, BooleanHypercube, SumcheckVerifier}; +use jolt_transcript::{ + prover_transcript, verifier_transcript, Blake2b512, FsAbsorb, FsChallenge, FsTranscript, }; -use jolt_transcript::{AppendToTranscript, Blake2bTranscript, Transcript}; type F = Fr; +const INSTANCE: [u8; 32] = [0u8; 32]; + /// Prove a sumcheck for the product of `polys` multilinear polynomials. /// /// Given d multilinear polynomials over n variables, proves the claim @@ -25,10 +27,10 @@ type F = Fr; /// round i is degree d, requiring d+1 evaluation points. /// /// Returns (proof, claimed_sum). -fn prove_product( +fn prove_product>( polys: &[Vec], num_vars: usize, - transcript: &mut Blake2bTranscript, + transcript: &mut T, ) -> (ClearSumcheckProof, F) { let degree = polys.len(); let n = 1 << num_vars; @@ -75,7 +77,7 @@ fn prove_product( let round_poly = UnivariatePoly::interpolate(&points); // Absorb through the same path the unlabelled verifier uses. - as RoundMessage>::append_to_transcript(&round_poly, transcript); + as RoundMessage>::append_to_transcript(&round_poly, transcript); let r: F = transcript.challenge(); round_polys.push(round_poly); @@ -106,7 +108,7 @@ fn degree2_product_roundtrip() { let f: Vec = (0..n).map(|i| F::from_u64(i as u64 + 1)).collect(); let g: Vec = (0..n).map(|i| F::from_u64((i * 3 + 7) as u64)).collect(); - let mut pt = Blake2bTranscript::new(b"sumcheck-roundtrip"); + let mut pt = prover_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default()); let (proof, claimed_sum) = prove_product(&[f, g], num_vars, &mut pt); let claim = SumcheckClaim { @@ -115,7 +117,7 @@ fn degree2_product_roundtrip() { claimed_sum, }; - let mut vt = Blake2bTranscript::new(b"sumcheck-roundtrip"); + let mut vt = verifier_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt); assert!(result.is_ok(), "degree-2 verify failed: {:?}", result.err()); @@ -131,7 +133,7 @@ fn degree3_product_roundtrip() { let g: Vec = (0..n).map(|i| F::from_u64((i * 2 + 3) as u64)).collect(); let h: Vec = (0..n).map(|i| F::from_u64((i + 10) as u64)).collect(); - let mut pt = Blake2bTranscript::new(b"sumcheck-roundtrip"); + let mut pt = prover_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default()); let (proof, claimed_sum) = prove_product(&[f, g, h], num_vars, &mut pt); let claim = SumcheckClaim { @@ -140,7 +142,7 @@ fn degree3_product_roundtrip() { claimed_sum, }; - let mut vt = Blake2bTranscript::new(b"sumcheck-roundtrip"); + let mut vt = verifier_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt); assert!(result.is_ok(), "degree-3 verify failed: {:?}", result.err()); @@ -156,7 +158,7 @@ fn degree3_final_eval_correct() { let g_evals: Vec = (0..n).map(|i| F::from_u64((i * 5 + 2) as u64)).collect(); let h_evals: Vec = (0..n).map(|i| F::from_u64((i + 7) as u64)).collect(); - let mut pt = Blake2bTranscript::new(b"sumcheck-roundtrip"); + let mut pt = prover_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default()); let (proof, claimed_sum) = prove_product( &[f_evals.clone(), g_evals.clone(), h_evals.clone()], num_vars, @@ -169,7 +171,7 @@ fn degree3_final_eval_correct() { claimed_sum, }; - let mut vt = Blake2bTranscript::new(b"sumcheck-roundtrip"); + let mut vt = verifier_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default(), &[]); let EvaluationClaim { point: challenges, value: final_eval, @@ -206,7 +208,7 @@ fn eq_weighted_sumcheck() { let claimed_sum: F = product.iter().copied().sum(); // Prove as degree-2 (eq * f, both multilinear) - let mut pt = Blake2bTranscript::new(b"sumcheck-roundtrip"); + let mut pt = prover_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default()); let (proof, sum) = prove_product(&[eq_evals, f], num_vars, &mut pt); assert_eq!(sum, claimed_sum); @@ -216,7 +218,7 @@ fn eq_weighted_sumcheck() { claimed_sum, }; - let mut vt = Blake2bTranscript::new(b"sumcheck-roundtrip"); + let mut vt = verifier_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt); assert!( @@ -250,10 +252,10 @@ fn batched_heterogeneous_degrees() { ]; // Build combined polynomial for honest proof - let mut pt = Blake2bTranscript::new(b"sumcheck-roundtrip"); - sum_fg.append_to_transcript(&mut pt); - sum_h.append_to_transcript(&mut pt); - let alpha: F = pt.challenge(); + let mut pt = prover_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default()); + pt.absorb_field(&sum_fg); + pt.absorb_field(&sum_h); + let alpha: F = FsChallenge::::challenge(&mut pt); let max_vars = 3; let n = 1 << max_vars; @@ -302,8 +304,8 @@ fn batched_heterogeneous_degrees() { .collect(); let round_poly = UnivariatePoly::interpolate(&points); - as RoundMessage>::append_to_transcript(&round_poly, &mut pt); - let r: F = pt.challenge(); + as RoundMessage>::append_to_transcript(&round_poly, &mut pt); + let r: F = FsChallenge::::challenge(&mut pt); round_polys.push(round_poly); for i in 0..half { @@ -316,7 +318,7 @@ fn batched_heterogeneous_degrees() { round_polynomials: round_polys, }; - let mut vt = Blake2bTranscript::new(b"sumcheck-roundtrip"); + let mut vt = verifier_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default(), &[]); let result = BatchedSumcheckVerifier::verify( &claims, &proof.round_polynomials, @@ -339,7 +341,7 @@ fn large_num_vars_roundtrip() { let f: Vec = (0..n).map(|i| F::from_u64(i as u64 + 1)).collect(); let g: Vec = (0..n).map(|i| F::from_u64((i * 7 + 3) as u64)).collect(); - let mut pt = Blake2bTranscript::new(b"sumcheck-roundtrip"); + let mut pt = prover_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default()); let (proof, claimed_sum) = prove_product(&[f, g], num_vars, &mut pt); let claim = SumcheckClaim { @@ -348,7 +350,7 @@ fn large_num_vars_roundtrip() { claimed_sum, }; - let mut vt = Blake2bTranscript::new(b"sumcheck-roundtrip"); + let mut vt = verifier_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt); assert!(result.is_ok(), "large roundtrip failed: {:?}", result.err()); @@ -361,13 +363,12 @@ fn compressed_round_verifier_roundtrip() { // single source of truth for the compressed wire format. let num_vars = 3; let n = 1 << num_vars; - let label = SUMCHECK_ROUND_TRANSCRIPT_LABEL; let degree = 2; let f: Vec = (0..n).map(|i| F::from_u64(i as u64 + 1)).collect(); let g: Vec = (0..n).map(|i| F::from_u64(i as u64 * 2 + 3)).collect(); - let mut pt = Blake2bTranscript::new(b"sumcheck-roundtrip"); + let mut pt = prover_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default()); let mut bufs = vec![f.clone(), g.clone()]; let claimed_sum: F = (0..n).map(|i| bufs[0][i] * bufs[1][i]).sum(); let mut round_polys = Vec::with_capacity(num_vars); @@ -398,13 +399,13 @@ fn compressed_round_verifier_roundtrip() { .collect(); let round_poly = UnivariatePoly::interpolate(&points); - let compressed = CompressedLabeledRoundPoly::new(&round_poly, label); - as RoundMessage>::append_to_transcript( + let compressed = CompressedLabeledRoundPoly::new(&round_poly); + as RoundMessage>::append_to_transcript( &compressed, &mut pt, ); - let r: F = pt.challenge(); + let r: F = FsChallenge::::challenge(&mut pt); round_polys.push(round_poly); for buf in &mut bufs { @@ -427,10 +428,10 @@ fn compressed_round_verifier_roundtrip() { let wrapped: Vec> = proof .round_polynomials .iter() - .map(|p| CompressedLabeledRoundPoly::new(p, label)) + .map(CompressedLabeledRoundPoly::new) .collect(); - let mut vt = Blake2bTranscript::new(b"sumcheck-roundtrip"); + let mut vt = verifier_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &wrapped, BooleanHypercube, &mut vt); assert!( result.is_ok(), @@ -448,10 +449,8 @@ fn labeled_round_verifier_roundtrip() { let f: Vec = (0..n).map(|i| F::from_u64(i as u64 + 1)).collect(); let g: Vec = (0..n).map(|i| F::from_u64((i + 5) as u64)).collect(); - let label = SUMCHECK_ROUND_TRANSCRIPT_LABEL; - // Prove with labeled absorption - let mut pt = Blake2bTranscript::new(b"sumcheck-roundtrip"); + let mut pt = prover_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default()); let degree = 2; let mut bufs = vec![f.clone(), g.clone()]; let claimed_sum: F = (0..n).map(|i| bufs[0][i] * bufs[1][i]).sum(); @@ -483,10 +482,10 @@ fn labeled_round_verifier_roundtrip() { .collect(); let round_poly = UnivariatePoly::interpolate(&points); - let labeled = LabeledRoundPoly::new(&round_poly, label); - as RoundMessage>::append_to_transcript(&labeled, &mut pt); + let labeled = LabeledRoundPoly::new(&round_poly); + as RoundMessage>::append_to_transcript(&labeled, &mut pt); - let r: F = pt.challenge(); + let r: F = FsChallenge::::challenge(&mut pt); round_polys.push(round_poly); for buf in &mut bufs { @@ -510,10 +509,10 @@ fn labeled_round_verifier_roundtrip() { let wrapped: Vec> = proof .round_polynomials .iter() - .map(|p| LabeledRoundPoly::new(p, label)) + .map(LabeledRoundPoly::new) .collect(); - let mut vt = Blake2bTranscript::new(b"sumcheck-roundtrip"); + let mut vt = verifier_transcript(b"sumcheck-roundtrip", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &wrapped, BooleanHypercube, &mut vt); assert!( result.is_ok(), diff --git a/crates/jolt-sumcheck/tests/soundness.rs b/crates/jolt-sumcheck/tests/soundness.rs index a81574e5e4..93cdd10468 100644 --- a/crates/jolt-sumcheck/tests/soundness.rs +++ b/crates/jolt-sumcheck/tests/soundness.rs @@ -13,10 +13,14 @@ use jolt_sumcheck::error::SumcheckError; use jolt_sumcheck::proof::ClearSumcheckProof; use jolt_sumcheck::round_proof::RoundMessage; use jolt_sumcheck::{BatchedSumcheckVerifier, BooleanHypercube, SumcheckVerifier}; -use jolt_transcript::{AppendToTranscript, Blake2bTranscript, Transcript}; +use jolt_transcript::{ + prover_transcript, verifier_transcript, Blake2b512, FsAbsorb, FsChallenge, FsTranscript, +}; type F = Fr; +const INSTANCE: [u8; 32] = [0u8; 32]; + #[derive(Debug)] enum OracleCheckError { #[expect( @@ -33,15 +37,11 @@ impl From> for OracleCheckError { } } -fn new_transcript() -> Blake2bTranscript { - Blake2bTranscript::new(b"soundness-test") -} - /// Honest degree-1 sumcheck prover. -fn honest_prove( +fn honest_prove>( evals: &[F], num_vars: usize, - transcript: &mut Blake2bTranscript, + transcript: &mut T, ) -> ClearSumcheckProof { let mut buf = evals.to_vec(); let mut round_polys = Vec::with_capacity(num_vars); @@ -55,7 +55,7 @@ fn honest_prove( eval_1 += buf[i + half]; } let round_poly = UnivariatePoly::new(vec![eval_0, eval_1 - eval_0]); - as RoundMessage>::append_to_transcript(&round_poly, transcript); + as RoundMessage>::append_to_transcript(&round_poly, transcript); let r: F = transcript.challenge(); round_polys.push(round_poly); for i in 0..half { @@ -83,7 +83,7 @@ fn verify_with_oracle_check( proof: &ClearSumcheckProof, intended_evals: &[F], ) -> Result, OracleCheckError> { - let mut transcript = new_transcript(); + let mut transcript = prover_transcript(b"soundness-test", INSTANCE, Blake2b512::default()); let EvaluationClaim { point: challenges, value: final_eval, @@ -107,7 +107,7 @@ fn honest_proof_passes_oracle_check() { let evals: Vec = (1..=8).map(F::from_u64).collect(); let sum = compute_sum(&evals); - let mut pt = new_transcript(); + let mut pt = prover_transcript(b"soundness-test", INSTANCE, Blake2b512::default()); let proof = honest_prove(&evals, 3, &mut pt); let claim = SumcheckClaim { @@ -134,7 +134,7 @@ fn wrong_polynomial_same_sum_fails_oracle_check() { assert_eq!(sum_f, sum_g, "precondition: f and g must have equal sums"); // Construct honest proof for g - let mut pt = new_transcript(); + let mut pt = prover_transcript(b"soundness-test", INSTANCE, Blake2b512::default()); let proof = honest_prove(&g_evals, 3, &mut pt); let claim = SumcheckClaim { @@ -144,7 +144,7 @@ fn wrong_polynomial_same_sum_fails_oracle_check() { }; // Round checks pass (proof is internally consistent for g) - let mut vt = new_transcript(); + let mut vt = verifier_transcript(b"soundness-test", INSTANCE, Blake2b512::default(), &[]); let round_result = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt); assert!( @@ -173,7 +173,7 @@ fn proof_for_different_polynomial_different_sum_fails_round_check() { let sum_g = compute_sum(&g_evals); assert_ne!(sum_f, sum_g); - let mut pt = new_transcript(); + let mut pt = prover_transcript(b"soundness-test", INSTANCE, Blake2b512::default()); let proof = honest_prove(&g_evals, 3, &mut pt); // Claim sum(f) but provide proof for g @@ -183,7 +183,7 @@ fn proof_for_different_polynomial_different_sum_fails_round_check() { claimed_sum: sum_f, }; - let mut vt = new_transcript(); + let mut vt = verifier_transcript(b"soundness-test", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt); assert!(matches!( @@ -197,7 +197,7 @@ fn corrupted_middle_round_detected() { let evals: Vec = (1..=16).map(F::from_u64).collect(); let sum = compute_sum(&evals); - let mut pt = new_transcript(); + let mut pt = prover_transcript(b"soundness-test", INSTANCE, Blake2b512::default()); let mut proof = honest_prove(&evals, 4, &mut pt); // Corrupt round 2 (middle round): replace with arbitrary polynomial @@ -210,7 +210,7 @@ fn corrupted_middle_round_detected() { claimed_sum: sum, }; - let mut vt = new_transcript(); + let mut vt = verifier_transcript(b"soundness-test", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt); @@ -224,7 +224,7 @@ fn corrupted_last_round_detected() { let evals: Vec = (1..=8).map(F::from_u64).collect(); let sum = compute_sum(&evals); - let mut pt = new_transcript(); + let mut pt = prover_transcript(b"soundness-test", INSTANCE, Blake2b512::default()); let mut proof = honest_prove(&evals, 3, &mut pt); // Corrupt only the last round polynomial @@ -236,7 +236,7 @@ fn corrupted_last_round_detected() { claimed_sum: sum, }; - let mut vt = new_transcript(); + let mut vt = verifier_transcript(b"soundness-test", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt); assert!(result.is_err(), "corrupted last round must be rejected"); @@ -250,7 +250,7 @@ fn swapped_round_order_rejected() { let evals: Vec = (1..=8).map(F::from_u64).collect(); let sum = compute_sum(&evals); - let mut pt = new_transcript(); + let mut pt = prover_transcript(b"soundness-test", INSTANCE, Blake2b512::default()); let mut proof = honest_prove(&evals, 3, &mut pt); proof.round_polynomials.swap(0, 1); @@ -261,7 +261,7 @@ fn swapped_round_order_rejected() { claimed_sum: sum, }; - let mut vt = new_transcript(); + let mut vt = verifier_transcript(b"soundness-test", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt); @@ -278,7 +278,7 @@ fn replayed_round_polynomial_rejected() { let evals: Vec = (1..=8).map(F::from_u64).collect(); let sum = compute_sum(&evals); - let mut pt = new_transcript(); + let mut pt = prover_transcript(b"soundness-test", INSTANCE, Blake2b512::default()); let proof = honest_prove(&evals, 3, &mut pt); let replayed = ClearSumcheckProof { @@ -291,7 +291,7 @@ fn replayed_round_polynomial_rejected() { claimed_sum: sum, }; - let mut vt = new_transcript(); + let mut vt = verifier_transcript(b"soundness-test", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify( &claim, &replayed.round_polynomials, @@ -320,7 +320,7 @@ fn all_zero_round_polynomials_rejected_for_nonzero_sum() { claimed_sum: sum, }; - let mut vt = new_transcript(); + let mut vt = verifier_transcript(b"soundness-test", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt); assert!(matches!( @@ -336,7 +336,7 @@ fn all_zero_polynomial_honest_proof_for_zero_sum() { let num_vars = 3; let evals = vec![F::from_u64(0); 1 << num_vars]; - let mut pt = new_transcript(); + let mut pt = prover_transcript(b"soundness-test", INSTANCE, Blake2b512::default()); let proof = honest_prove(&evals, num_vars, &mut pt); let claim = SumcheckClaim { @@ -361,7 +361,7 @@ fn verifier_transcript_desync_rejected() { let evals: Vec = (1..=8).map(F::from_u64).collect(); let sum = compute_sum(&evals); - let mut pt = new_transcript(); + let mut pt = prover_transcript(b"soundness-test", INSTANCE, Blake2b512::default()); let proof = honest_prove(&evals, 3, &mut pt); let claim = SumcheckClaim { @@ -371,8 +371,8 @@ fn verifier_transcript_desync_rejected() { }; // Poison the verifier transcript with extra data - let mut vt = new_transcript(); - F::from_u64(0xdead).append_to_transcript(&mut vt); + let mut vt = verifier_transcript(b"soundness-test", INSTANCE, Blake2b512::default(), &[]); + vt.absorb_field(&F::from_u64(0xdead)); let result = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt); @@ -396,7 +396,7 @@ fn num_vars_zero_accepts_any_claimed_sum() { }; let round_proofs: &[UnivariatePoly] = &[]; - let mut vt = new_transcript(); + let mut vt = verifier_transcript(b"soundness-test", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, round_proofs, BooleanHypercube, &mut vt); assert!(result.is_ok()); @@ -421,7 +421,7 @@ fn num_vars_zero_no_oracle_check_possible() { }; let round_proofs: &[UnivariatePoly] = &[]; - let mut vt = new_transcript(); + let mut vt = verifier_transcript(b"soundness-test", INSTANCE, Blake2b512::default(), &[]); let result = SumcheckVerifier::verify(&claim, round_proofs, BooleanHypercube, &mut vt); // Passes — the verifier has nothing to check! @@ -437,7 +437,7 @@ fn constant_polynomial_all_same_evals() { let evals = vec![F::from_u64(7); 1 << num_vars]; let sum = compute_sum(&evals); - let mut pt = new_transcript(); + let mut pt = prover_transcript(b"soundness-test", INSTANCE, Blake2b512::default()); let proof = honest_prove(&evals, num_vars, &mut pt); let claim = SumcheckClaim { @@ -451,7 +451,7 @@ fn constant_polynomial_all_same_evals() { // The final eval should be 7 regardless of the challenge point, // since f is constant. - let mut vt = new_transcript(); + let mut vt = verifier_transcript(b"soundness-test", INSTANCE, Blake2b512::default(), &[]); let final_eval = SumcheckVerifier::verify(&claim, &proof.round_polynomials, BooleanHypercube, &mut vt) .unwrap() @@ -469,10 +469,10 @@ fn batched_one_dishonest_claim_rejected() { let sum_b = compute_sum(&evals_b); // Build combined proof honestly - let mut pt = new_transcript(); - sum_a.append_to_transcript(&mut pt); - sum_b.append_to_transcript(&mut pt); - let alpha: F = pt.challenge(); + let mut pt = prover_transcript(b"soundness-test", INSTANCE, Blake2b512::default()); + pt.absorb_field(&sum_a); + pt.absorb_field(&sum_b); + let alpha: F = FsChallenge::::challenge(&mut pt); let combined: Vec = evals_a .iter() @@ -495,7 +495,7 @@ fn batched_one_dishonest_claim_rejected() { }, ]; - let mut vt = new_transcript(); + let mut vt = verifier_transcript(b"soundness-test", INSTANCE, Blake2b512::default(), &[]); let result = BatchedSumcheckVerifier::verify( &claims, &proof.round_polynomials, @@ -516,10 +516,10 @@ fn batched_swapped_claim_order_rejected() { let sum_b = compute_sum(&evals_b); // Prove with order [a, b] - let mut pt = new_transcript(); - sum_a.append_to_transcript(&mut pt); - sum_b.append_to_transcript(&mut pt); - let alpha: F = pt.challenge(); + let mut pt = prover_transcript(b"soundness-test", INSTANCE, Blake2b512::default()); + pt.absorb_field(&sum_a); + pt.absorb_field(&sum_b); + let alpha: F = FsChallenge::::challenge(&mut pt); let combined: Vec = evals_a .iter() @@ -542,7 +542,7 @@ fn batched_swapped_claim_order_rejected() { }, ]; - let mut vt = new_transcript(); + let mut vt = verifier_transcript(b"soundness-test", INSTANCE, Blake2b512::default(), &[]); let result = BatchedSumcheckVerifier::verify( &claims_swapped, &proof.round_polynomials, diff --git a/crates/jolt-transcript/Cargo.toml b/crates/jolt-transcript/Cargo.toml index 6e84889d6e..74eb656342 100644 --- a/crates/jolt-transcript/Cargo.toml +++ b/crates/jolt-transcript/Cargo.toml @@ -20,15 +20,8 @@ transcript-poseidon = ["dep:light-poseidon"] [dependencies] ark-bn254.workspace = true ark-ff.workspace = true +ark-serialize.workspace = true light-poseidon = { workspace = true, optional = true } -spongefish = { workspace = true, features = ["ark-ff"] } +spongefish = { workspace = true, features = ["ark-ff", "ark-ec", "derive"] } jolt-field = { path = "../jolt-field", features = ["bn254"] } rand.workspace = true - -[dev-dependencies] -num-traits = { workspace = true } -criterion = { workspace = true } - -[[bench]] -name = "transcript_ops" -harness = false diff --git a/crates/jolt-transcript/benches/transcript_ops.rs b/crates/jolt-transcript/benches/transcript_ops.rs deleted file mode 100644 index 2cc82cbd65..0000000000 --- a/crates/jolt-transcript/benches/transcript_ops.rs +++ /dev/null @@ -1,92 +0,0 @@ -#![expect(unused_results)] - -use std::hint::black_box; - -use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; -use jolt_field::Fr; -use jolt_transcript::{Blake2bTranscript, KeccakTranscript, PoseidonTranscript, Transcript}; - -fn bench_append_bytes(c: &mut Criterion) { - let mut group = c.benchmark_group("append_bytes"); - let data_32 = [0xABu8; 32]; - let data_256 = [0xCDu8; 256]; - - for (label, data) in [("32B", &data_32[..]), ("256B", &data_256[..])] { - group.bench_with_input(BenchmarkId::new("Blake2b", label), data, |bench, data| { - bench.iter_batched( - || Blake2bTranscript::::new(b"bench"), - |mut t| { - t.append_bytes(black_box(data)); - t - }, - criterion::BatchSize::SmallInput, - ); - }); - group.bench_with_input(BenchmarkId::new("Keccak", label), data, |bench, data| { - bench.iter_batched( - || KeccakTranscript::::new(b"bench"), - |mut t| { - t.append_bytes(black_box(data)); - t - }, - criterion::BatchSize::SmallInput, - ); - }); - group.bench_with_input(BenchmarkId::new("Poseidon", label), data, |bench, data| { - bench.iter_batched( - || PoseidonTranscript::::new(b"bench"), - |mut t| { - t.append_bytes(black_box(data)); - t - }, - criterion::BatchSize::SmallInput, - ); - }); - } - group.finish(); -} - -fn bench_challenge(c: &mut Criterion) { - let mut group = c.benchmark_group("challenge"); - - group.bench_function("Blake2b", |bench| { - bench.iter_batched( - || { - let mut t = Blake2bTranscript::::new(b"bench"); - t.append_bytes(&[42u8; 32]); - t - }, - |mut t| t.challenge(), - criterion::BatchSize::SmallInput, - ); - }); - - group.bench_function("Keccak", |bench| { - bench.iter_batched( - || { - let mut t = KeccakTranscript::::new(b"bench"); - t.append_bytes(&[42u8; 32]); - t - }, - |mut t| t.challenge(), - criterion::BatchSize::SmallInput, - ); - }); - - group.bench_function("Poseidon", |bench| { - bench.iter_batched( - || { - let mut t = PoseidonTranscript::::new(b"bench"); - t.append_bytes(&[42u8; 32]); - t - }, - |mut t| t.challenge(), - criterion::BatchSize::SmallInput, - ); - }); - - group.finish(); -} - -criterion_group!(benches, bench_append_bytes, bench_challenge); -criterion_main!(benches); diff --git a/crates/jolt-transcript/fuzz/Cargo.toml b/crates/jolt-transcript/fuzz/Cargo.toml deleted file mode 100644 index 4c5897b2f1..0000000000 --- a/crates/jolt-transcript/fuzz/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[workspace] - -# The fuzz sub-workspace does not inherit the root workspace's `[patch.crates-io]`. -# `light-poseidon` pulls unpatched `ark-ff` from crates.io; jolt-transcript's types -# are built against the patched fork. Apply the same patches here so both sides -# see the same `ark-ff`. -[patch.crates-io] -ark-bn254 = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" } -ark-ec = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" } -ark-ff = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" } -ark-serialize = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" } - -[package] -name = "jolt-transcript-fuzz" -version = "0.0.0" -publish = false -edition = "2021" - -[package.metadata] -cargo-fuzz = true - -[dependencies] -libfuzzer-sys = "0.4" -jolt-transcript = { path = ".." } - -[[bin]] -name = "transcript_no_panic" -path = "fuzz_targets/transcript_no_panic.rs" -doc = false diff --git a/crates/jolt-transcript/fuzz/fuzz_targets/transcript_no_panic.rs b/crates/jolt-transcript/fuzz/fuzz_targets/transcript_no_panic.rs deleted file mode 100644 index f4c09e1a46..0000000000 --- a/crates/jolt-transcript/fuzz/fuzz_targets/transcript_no_panic.rs +++ /dev/null @@ -1,26 +0,0 @@ -#![no_main] -use jolt_transcript::{Blake2bTranscript, KeccakTranscript, PoseidonTranscript, Transcript}; -use libfuzzer_sys::fuzz_target; - -fuzz_target!(|data: &[u8]| { - // Blake2b: append arbitrary bytes + squeeze challenge — must never panic - let mut blake = ::default(); - blake.append_bytes(data); - let _ = blake.challenge(); - blake.append_bytes(data); - let _ = blake.challenge(); - - // Keccak: same exercise - let mut keccak = ::default(); - keccak.append_bytes(data); - let _ = keccak.challenge(); - keccak.append_bytes(data); - let _ = keccak.challenge(); - - // Poseidon: most complex impl — field arithmetic + multi-chunk chaining - let mut poseidon = ::default(); - poseidon.append_bytes(data); - let _ = poseidon.challenge(); - poseidon.append_bytes(data); - let _ = poseidon.challenge(); -}); diff --git a/crates/jolt-transcript/fuzz/rust-toolchain.toml b/crates/jolt-transcript/fuzz/rust-toolchain.toml deleted file mode 100644 index 5d56faf9ae..0000000000 --- a/crates/jolt-transcript/fuzz/rust-toolchain.toml +++ /dev/null @@ -1,2 +0,0 @@ -[toolchain] -channel = "nightly" diff --git a/crates/jolt-transcript/src/codec.rs b/crates/jolt-transcript/src/codec.rs index e6e2ee8c69..56a8681e30 100644 --- a/crates/jolt-transcript/src/codec.rs +++ b/crates/jolt-transcript/src/codec.rs @@ -1,17 +1,60 @@ -//! Local codecs for absorbing / decoding Jolt-native messages over a -//! byte-oriented spongefish sponge. +//! Local codecs for absorbing / decoding Jolt-native messages. //! -//! Field-element codecs come from spongefish's `ark-ff` feature -//! (`spongefish::Encoding<[u8]>` is implemented for every `ark_ff::Fp` -//! using big-endian canonical encoding per RFC8017). 128-bit-truncated -//! challenges decode through spongefish's built-in `u128` codec directly; -//! see [`crate::prover::OptimizedChallenge`]. +//! **Byte sponges** (`U = u8`): field-element codecs come from spongefish's +//! `ark-ff` feature (`spongefish::Encoding<[u8]>` is implemented for every +//! `ark_ff::Fp` using big-endian canonical encoding per RFC8017). +//! 128-bit-truncated challenges decode through spongefish's built-in `u128` +//! codec directly; see [`crate::prover::OptimizedChallenge`]. [`BytesMsg`] is +//! the length-prefixed byte-string framing spongefish 0.6 does not provide. +//! All `[u8]`-domain codecs here are untouched by the field-aligned redesign. //! -//! This module keeps only `BytesMsg`, the length-prefixed byte string -//! framing that spongefish 0.6 does not provide. +//! **Field-unit Poseidon sponge** (`U = Fr`, `transcript-poseidon`): spongefish +//! ships no `Encoding<[Fr]>`/`Decoding<[Fr]>` codecs (and the orphan rule +//! blocks implementing them on foreign types like `Fr` itself), so this module +//! defines the typed message vocabulary of +//! `specs/transpiler-optimization-spec.md` §4.2–4.3: +//! +//! - [`RawBytesMsg`] — byte-rule message: `L` bytes ↦ `[Fr(2L), ceil(L/31)` +//! 31-byte-LE chunk units`]` (each chunk < 2²⁴⁸ < r, so injective). +//! - [`FieldFrameMsg`] — count-led field frame: `k` elements ↦ +//! `[Fr(2k+1), e₁, …, e_k]` native units. +//! - [`CommitmentsMsg`] — a NARG frame of `k` commitments absorbed as a +//! leading **frame count unit** `Fr(2k+1)` followed by `k` +//! **per-commitment byte-rule groups** (one Dory GT = 384 canonical bytes ↦ +//! `[Fr(768), 13 chunks]` = 14 units); an empty frame is the `Fr(1)` +//! count-led case, distinct from the empty byte message `[Fr(0)]`, so the +//! data-dependent advice presence frame stays count-led. +//! - [`NativeChallenge`] — one native `Fr` squeeze (`Decoding<[Fr]>` with a +//! one-unit `Repr`, identity decode) = exactly one permute. +//! +//! Every encoding zero-pads each tagged group to an even unit count, so each +//! message occupies whole permute pairs and message boundaries bind. The +//! `2L` (even) / `2k+1` (odd) leading-tag split type-separates byte messages +//! from field frames at zero extra permutes. use spongefish::{Encoding, NargDeserialize, VerificationError, VerificationResult}; +/// The one authoritative parser for the crate's NARG framing (8-byte LE length +/// ‖ body), shared by [`BytesMsg`] and every `Fr`-domain message type so the +/// accept/reject behavior cannot drift. Returns the body as a borrowed +/// subslice (no copy); `buf` advances past the frame on success and is +/// untouched on error. +/// +/// The length is converted with `usize::try_from`, not `as`: on 32-bit +/// targets an `as` cast truncates a > `usize::MAX` length, making the same +/// NARG accepted on one platform and rejected on another — any overflow is a +/// deserialization error instead. +pub fn read_length_prefixed_body<'a>(buf: &mut &'a [u8]) -> VerificationResult<&'a [u8]> { + let (len_bytes, rest) = buf.split_first_chunk::<8>().ok_or(VerificationError)?; + let len = usize::try_from(u64::from_le_bytes(*len_bytes)).map_err(|_| VerificationError)?; + if rest.len() < len { + return Err(VerificationError); + } + let (body, tail) = rest.split_at(len); + *buf = tail; + Ok(body) +} + /// Length-prefixed byte string. 8-byte LE length keeps `BytesMsg(a) ; BytesMsg(b)` /// distinguishable from `BytesMsg(a||b)`. #[derive(Clone, Debug, PartialEq, Eq)] @@ -41,22 +84,284 @@ impl Encoding<[u8]> for BytesMsg { impl NargDeserialize for BytesMsg { fn deserialize_from_narg(buf: &mut &[u8]) -> VerificationResult { - if buf.len() < 8 { - return Err(VerificationError); + read_length_prefixed_body(buf).map(|body| BytesMsg(body.to_vec())) + } +} + +#[cfg(feature = "transcript-poseidon")] +mod fr_domain { + use ark_bn254::Fr; + use ark_ff::{PrimeField, Zero}; + use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; + use spongefish::{ + Decoding, Encoding, NargDeserialize, NargSerialize, VerificationError, VerificationResult, + }; + + use super::read_length_prefixed_body; + + /// Bytes per 31-byte little-endian chunk unit (248 bits < BN254 modulus, + /// so chunk ↦ `Fr` is injective). + pub const BYTE_RULE_CHUNK: usize = 31; + + /// Append one complete byte-rule message for `bytes` to `units`: + /// `[Fr(2L), 31-byte-LE chunks…]`, zero-padded to an even unit count + /// *relative to the start of this message* so each message occupies whole + /// permute pairs even when concatenated (e.g. per-GT groups inside + /// [`CommitmentsMsg`]). + pub fn push_byte_rule_units(units: &mut Vec, bytes: &[u8]) { + let start = units.len(); + units.push(Fr::from(2 * bytes.len() as u64)); + for chunk in bytes.chunks(BYTE_RULE_CHUNK) { + units.push(Fr::from_le_bytes_mod_order(chunk)); + } + if (units.len() - start) % 2 == 1 { + units.push(Fr::zero()); + } + } + + /// The byte-rule CHUNK values of `bytes` (no leading length tag, no even + /// padding): `bytes.chunks(31) ↦ Fr::from_le_bytes_mod_order`, exactly the + /// payload [`push_byte_rule_units`] pushes between the tag and pad. The + /// single source of the commitment/byte-string chunking the transpiler's + /// symbolic mirror re-uses (`commitment_to_field_chunks`, the + /// `FieldAlignedLayout` differential), kept here next to the native + /// encoder so the chunking logic cannot drift from the byte-rule encoding. + pub fn commitment_to_chunks(bytes: &[u8]) -> Vec { + bytes + .chunks(BYTE_RULE_CHUNK) + .map(Fr::from_le_bytes_mod_order) + .collect() + } + + /// Append one complete count-led field frame for `elems` to `units`: + /// `[Fr(2k+1), e₁, …, e_k]`, zero-padded to an even unit count relative to + /// the start of this message (see [`push_byte_rule_units`]). Exported so + /// the transpiler's symbolic sponge mirror imports the encoding instead of + /// re-hardcoding it. + pub fn push_field_frame_units(units: &mut Vec, elems: &[Fr]) { + let start = units.len(); + units.push(Fr::from(2 * elems.len() as u64 + 1)); + units.extend_from_slice(elems); + if (units.len() - start) % 2 == 1 { + units.push(Fr::zero()); + } + } + + /// Append the leading frame-count units of a [`CommitmentsMsg`] of `count` + /// commitments: the count unit `Fr(2·count+1)` padded with a zero unit to a + /// whole permute pair, so every per-commitment byte-rule group that follows + /// stays pair-aligned (review fix F1). The per-commitment groups themselves + /// are appended with [`push_byte_rule_units`] over each commitment's + /// canonical compressed bytes. Exported for the same anti-drift reason as + /// [`push_field_frame_units`]. + pub fn push_commitments_frame_header(units: &mut Vec, count: usize) { + units.push(Fr::from(2 * count as u64 + 1)); + units.push(Fr::zero()); + } + + fn write_length_prefixed_body( + dst: &mut Vec, + body_len: usize, + write: impl FnOnce(&mut Vec), + ) { + dst.extend_from_slice(&(body_len as u64).to_le_bytes()); + let before = dst.len(); + write(dst); + debug_assert_eq!(dst.len() - before, body_len, "NARG body length mismatch"); + } + + /// Raw bytes under the byte rule for the `Fr`-unit sponge. + /// + /// Sponge encoding: `[Fr(2L), 31-byte-LE chunks…]` (+ even padding). NARG + /// transport (when used as a prover message): byte-identical to + /// [`BytesMsg`](super::BytesMsg) — 8-byte LE length ‖ body — so switching + /// a frame's *absorption* to field units never changes the proof bytes. + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct RawBytesMsg(pub Vec); + + impl Encoding<[Fr]> for RawBytesMsg { + fn encode(&self) -> impl AsRef<[Fr]> { + let mut units = Vec::with_capacity(2 + self.0.len() / BYTE_RULE_CHUNK); + push_byte_rule_units(&mut units, &self.0); + units + } + } + + impl NargSerialize for RawBytesMsg { + fn serialize_into_narg(&self, dst: &mut Vec) { + write_length_prefixed_body(dst, self.0.len(), |dst| dst.extend_from_slice(&self.0)); } - let mut len_bytes = [0u8; 8]; - len_bytes.copy_from_slice(&buf[..8]); - let len = u64::from_le_bytes(len_bytes) as usize; - let total = 8usize.checked_add(len).ok_or(VerificationError)?; - if buf.len() < total { - return Err(VerificationError); + } + + impl NargDeserialize for RawBytesMsg { + fn deserialize_from_narg(buf: &mut &[u8]) -> VerificationResult { + read_length_prefixed_body(buf).map(|body| RawBytesMsg(body.to_vec())) + } + } + + /// A frame of `k` field elements absorbed as the count-led field frame + /// `[Fr(2k+1), e₁, …, e_k]` (+ even padding). + /// + /// NARG transport: 8-byte LE byte-length ‖ 32-byte-LE canonical elements — + /// byte-identical to `BytesMsg(serialize_slice(elems))`. Deserialization + /// rejects bodies that are not a multiple of 32 bytes and non-canonical + /// (≥ r) elements, mirroring the native `read_all` strictness. + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct FieldFrameMsg(pub Vec); + + impl Encoding<[Fr]> for FieldFrameMsg { + fn encode(&self) -> impl AsRef<[Fr]> { + let mut units = Vec::with_capacity(2 + self.0.len()); + push_field_frame_units(&mut units, &self.0); + units + } + } + + impl NargSerialize for FieldFrameMsg { + #[expect( + clippy::expect_used, + reason = "CanonicalSerialize into a Vec is infallible" + )] + fn serialize_into_narg(&self, dst: &mut Vec) { + write_length_prefixed_body(dst, self.0.len() * 32, |dst| { + for e in &self.0 { + e.serialize_compressed(&mut *dst) + .expect("CanonicalSerialize into a Vec is infallible"); + } + }); + } + } + + impl NargDeserialize for FieldFrameMsg { + fn deserialize_from_narg(buf: &mut &[u8]) -> VerificationResult { + // Stage the cursor so `buf` is untouched when body validation fails. + let mut staged = *buf; + let body = read_length_prefixed_body(&mut staged)?; + if !body.len().is_multiple_of(32) { + return Err(VerificationError); + } + let elems = body + .chunks_exact(32) + .map(|c| Fr::deserialize_compressed(c).map_err(|_| VerificationError)) + .collect::>>()?; + *buf = staged; + Ok(FieldFrameMsg(elems)) + } + } + + /// A NARG frame of `k` commitments absorbed as a leading **frame count + /// unit** `Fr(2k+1)` (padded to a whole permute pair) followed by `k` + /// per-commitment byte-rule groups (spec §4.2: one Dory GT = 384 + /// canonical bytes ↦ `[Fr(2·384), 13 chunks]` = 14 units = 7 permutes). + /// + /// WHY the frame-level count: each per-GT group is a self-delimiting, + /// even-length unit run, so without it adjacent commitment frames could + /// be re-partitioned (`[c1,c2]+[c3]` vs `[c1]+[c2,c3]`) with an + /// IDENTICAL absorbed unit stream — challenges unchanged under NARG + /// malleation. The count binds the partition. Its odd `2k+1` tag shares + /// a domain with [`FieldFrameMsg`]'s count; the two kinds are + /// disambiguated positionally by the fixed absorb/read schedule (each + /// slot is read with exactly one message type), the same way a scalar + /// frame is distinguished from the byte-rule absorb of the scalars' + /// serialization today. + /// + /// An **empty** frame is the `Fr(1)` count-led case — distinct from the + /// empty byte message `[Fr(0)]` — so the data-dependent untrusted-advice + /// presence frame stays count-led: absent ↦ leading unit `Fr(1)`, + /// present ↦ `Fr(3)`. + /// + /// NARG transport: 8-byte LE total length ‖ concatenated compressed + /// serializations — byte-identical to `BytesMsg(serialize_slice(values))`. + /// Deserialization parses elements sequentially from the self-delimiting + /// body (allocation bounded by the actual frame bytes). + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct CommitmentsMsg(pub Vec); + + impl Encoding<[Fr]> for CommitmentsMsg { + #[expect( + clippy::expect_used, + reason = "CanonicalSerialize into a Vec is infallible" + )] + fn encode(&self) -> impl AsRef<[Fr]> { + // 2 units per frame (count + pad) + 14 per Dory GT. + let mut units = Vec::with_capacity(2 + 14 * self.0.len()); + // Count unit padded to a whole permute pair so every per-GT group + // stays pair-aligned (the per-GT witness alignment spec §4.2 + // pays extra permutes for). + push_commitments_frame_header(&mut units, self.0.len()); + let mut bytes = Vec::new(); + for value in &self.0 { + bytes.clear(); + value + .serialize_compressed(&mut bytes) + .expect("CanonicalSerialize into a Vec is infallible"); + push_byte_rule_units(&mut units, &bytes); + } + units + } + } + + impl NargSerialize for CommitmentsMsg { + #[expect( + clippy::expect_used, + reason = "CanonicalSerialize into a Vec is infallible" + )] + fn serialize_into_narg(&self, dst: &mut Vec) { + let body_len: usize = self.0.iter().map(CanonicalSerialize::compressed_size).sum(); + write_length_prefixed_body(dst, body_len, |dst| { + for value in &self.0 { + value + .serialize_compressed(&mut *dst) + .expect("CanonicalSerialize into a Vec is infallible"); + } + }); + } + } + + impl NargDeserialize for CommitmentsMsg { + fn deserialize_from_narg(buf: &mut &[u8]) -> VerificationResult { + // Stage the cursor so `buf` is untouched when body parsing fails. + let mut staged = *buf; + let mut cursor = read_length_prefixed_body(&mut staged)?; + let mut values = Vec::new(); + while !cursor.is_empty() { + let before = cursor.len(); + values.push(T::deserialize_compressed(&mut cursor).map_err(|_| VerificationError)?); + // A `T` whose deserialization consumes zero bytes (e.g. a + // unit-like type) would loop forever; require progress. + if cursor.len() >= before { + return Err(VerificationError); + } + } + *buf = staged; + Ok(CommitmentsMsg(values)) + } + } + + /// One native `Fr` challenge squeeze = exactly one permute. + /// + /// The orphan rule blocks `Decoding<[Fr]>` on `Fr` itself, so this local + /// newtype carries the identity decode with the one-unit `Repr` + /// `[Fr; 1]` (which already satisfies `Default + AsMut<[Fr]>`). + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct NativeChallenge(pub Fr); + + impl Decoding<[Fr]> for NativeChallenge { + type Repr = [Fr; 1]; + + fn decode(buf: Self::Repr) -> Self { + NativeChallenge(buf[0]) } - let body = buf[8..total].to_vec(); - *buf = &buf[total..]; - Ok(BytesMsg(body)) } } +#[cfg(feature = "transcript-poseidon")] +pub use fr_domain::{ + commitment_to_chunks, push_byte_rule_units, push_commitments_frame_header, + push_field_frame_units, CommitmentsMsg, FieldFrameMsg, NativeChallenge, RawBytesMsg, + BYTE_RULE_CHUNK, +}; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/jolt-transcript/src/legacy.rs b/crates/jolt-transcript/src/legacy.rs deleted file mode 100644 index 987012b4c1..0000000000 --- a/crates/jolt-transcript/src/legacy.rs +++ /dev/null @@ -1,287 +0,0 @@ -//! Source-compatible facade for `jolt-sumcheck`, `jolt-openings`, and -//! `jolt-crypto`. -//! -//! Wraps a duplex sponge over each of the three backends and re-exposes -//! the legacy `Transcript` / `AppendToTranscript` API. Removed once -//! jolt-core migrates to the split-trait surface. - -use std::marker::PhantomData; - -use jolt_field::{CanonicalBytes, Field, FromPrimitiveInt, TranscriptChallenge}; -use spongefish::{DuplexSpongeInterface, Encoding}; - -use crate::codec::BytesMsg; -use crate::setup::{EmptyInstance, PROTOCOL_ID}; - -/// Maximum label length in bytes accepted by [`Transcript::new`] and the -/// label helpers below. -pub const MAX_LABEL_LEN: usize = 32; - -/// Fiat-Shamir transcript for non-interactive proofs. -/// -/// A transcript absorbs data and produces deterministic challenges. Both -/// prover and verifier maintain identical transcripts to derive the same -/// challenges. -/// -/// # Security -/// -/// The label passed to [`new`](Transcript::new) is mapped to the -/// spongefish session value, so distinct labels carry distinct domain -/// barriers. -pub trait Transcript: Default + Sync + Send + 'static { - /// The challenge type produced by this transcript. - type Challenge: TranscriptChallenge; - - /// Creates a new transcript with the given domain separation label. - /// - /// # Panics - /// - /// Panics if `label.len() > MAX_LABEL_LEN`. - fn new(label: &'static [u8]) -> Self; - - /// Absorbs raw bytes. - fn append_bytes(&mut self, bytes: &[u8]); - - /// Absorbs a value via [`AppendToTranscript`]. - fn append(&mut self, value: &A) { - value.append_to_transcript(self); - } - - /// Absorbs a domain label followed by a value. - fn append_labeled(&mut self, label: &'static [u8], value: &A) { - self.append(&Label(label)); - self.append(value); - } - - /// Absorbs a domain label with a count followed by each value in order. - fn append_values(&mut self, label: &'static [u8], values: &[A]) { - self.append(&LabelWithCount(label, values.len() as u64)); - for value in values { - self.append(value); - } - } - - /// Squeezes a challenge. - #[must_use] - fn challenge(&mut self) -> Self::Challenge; - - /// Squeezes a non-optimized scalar challenge from the transcript. - #[must_use] - fn challenge_scalar(&mut self) -> Self::Challenge { - self.challenge() - } - - /// Squeezes `len` challenges. - #[must_use] - fn challenge_vector(&mut self, len: usize) -> Vec { - (0..len).map(|_| self.challenge()).collect() - } - - /// Squeezes one scalar challenge and returns its powers `[1, gamma, gamma^2, ...]`. - #[must_use] - fn challenge_scalar_powers(&mut self, len: usize) -> Vec - where - Self::Challenge: Field, - { - let gamma = self.challenge_scalar(); - let mut powers = vec![Self::Challenge::from_u64(1); len]; - for index in 1..len { - powers[index] = powers[index - 1] * gamma; - } - powers - } - - /// Current 256-bit transcript state. Peeked non-destructively by - /// squeezing 32 bytes from a sponge clone, so callers can read it - /// without advancing the real state. Useful for debug-only - /// cross-verifier comparison. - #[must_use] - fn state(&self) -> [u8; 32]; - - /// Enables transcript comparison for tests; mirrors upstream's signature. - /// Spongefish sponges have no replayable state history, so this is a - /// no-op on the legacy facade — call sites already only use it under - /// `#[cfg(test)]` for debugging digest-based transcripts. - #[cfg(test)] - fn compare_to(&mut self, _other: &Self) {} -} - -/// Implement on types that absorb themselves into a [`Transcript`]. -pub trait AppendToTranscript { - /// Absorbs this value into the transcript. - fn append_to_transcript(&self, transcript: &mut T); - - /// Byte length of the payload absorbed by [`append_to_transcript`], when - /// the type participates in jolt-core's variable-length labeled appends. - fn transcript_payload_len(&self) -> Option { - None - } -} - -/// Big-endian field element absorption (matches jolt-core's EVM-compatible -/// byte order). -impl AppendToTranscript for F { - fn append_to_transcript(&self, transcript: &mut T) { - let mut buf = vec![0u8; F::NUM_BYTES]; - self.to_bytes_le(&mut buf); - buf.reverse(); - transcript.append_bytes(&buf); - } -} - -/// 32-byte zero-padded label word (matches jolt-core's `raw_append_label`). -pub struct Label(pub &'static [u8]); - -impl AppendToTranscript for Label { - fn append_to_transcript(&self, transcript: &mut T) { - assert!( - self.0.len() <= 32, - "label {:?} exceeds 32 bytes", - core::str::from_utf8(self.0) - ); - let mut padded = [0u8; 32]; - padded[..self.0.len()].copy_from_slice(self.0); - transcript.append_bytes(&padded); - } -} - -/// Packed label (24 bytes) + count (8-byte big-endian) in one 32-byte word -/// (matches jolt-core's `raw_append_label_with_len`). -pub struct LabelWithCount(pub &'static [u8], pub u64); - -impl AppendToTranscript for LabelWithCount { - fn append_to_transcript(&self, transcript: &mut T) { - assert!( - self.0.len() <= 24, - "label {:?} exceeds 24 bytes", - core::str::from_utf8(self.0) - ); - let mut packed = [0u8; 32]; - packed[..self.0.len()].copy_from_slice(self.0); - packed[24..32].copy_from_slice(&self.1.to_be_bytes()); - transcript.append_bytes(&packed); - } -} - -/// EVM-compatible left-padded u64: 24 zero bytes + 8-byte BE value (matches -/// jolt-core's `raw_append_u64`). -pub struct U64Word(pub u64); - -impl AppendToTranscript for U64Word { - fn append_to_transcript(&self, transcript: &mut T) { - let mut packed = [0u8; 32]; - packed[24..].copy_from_slice(&self.0.to_be_bytes()); - transcript.append_bytes(&packed); - } -} - -/// Sponge-backed transcript driving a duplex sponge directly. -/// -/// The legacy facade does not produce or consume a NARG byte string — -/// existing modular consumers only call `append_bytes` / `challenge` / -/// `state`. New code should use [`crate::ProverTranscript`] / -/// [`crate::VerifierTranscript`] instead. -/// -/// Construction mirrors spongefish's `DomainSeparator` builder: -/// `protocol_id || session(label) || instance(())` are absorbed in order. -pub struct SpongeTranscript -where - H: DuplexSpongeInterface + Clone + Default + Send + Sync + 'static, - F: TranscriptChallenge, -{ - sponge: H, - _field: PhantomData, -} - -impl Default for SpongeTranscript -where - H: DuplexSpongeInterface + Clone + Default + Send + Sync + 'static, - F: TranscriptChallenge, -{ - fn default() -> Self { - Self::new(b"") - } -} - -fn absorb_encoded(sponge: &mut H, value: &T) -where - H: DuplexSpongeInterface, - T: Encoding<[u8]> + ?Sized, -{ - let _ = sponge.absorb(value.encode().as_ref()); -} - -/// Peeks 32 bytes from a clone of the sponge so the real state stays put. -fn peek_state + Clone>(sponge: &H) -> [u8; 32] { - let mut clone = sponge.clone(); - let mut buf = [0u8; 32]; - let _ = clone.squeeze(&mut buf); - buf -} - -impl Transcript for SpongeTranscript -where - H: DuplexSpongeInterface + Clone + Default + Send + Sync + 'static, - F: TranscriptChallenge, -{ - type Challenge = F; - - fn new(label: &'static [u8]) -> Self { - assert!( - label.len() <= MAX_LABEL_LEN, - "label must be at most {MAX_LABEL_LEN} bytes", - ); - let mut sponge = H::default(); - absorb_encoded(&mut sponge, &PROTOCOL_ID); - absorb_encoded(&mut sponge, &BytesMsg(label.to_vec())); - absorb_encoded(&mut sponge, &EmptyInstance); - Self { - sponge, - _field: PhantomData, - } - } - - fn append_bytes(&mut self, bytes: &[u8]) { - // 1-byte non-zero domain marker + 8-byte LE length + body. - // - // The marker sub-domain-separates legacy-facade `append_bytes` calls - // from spongefish-native `public_message` / `prover_message` calls on - // the same sponge type, so a future protocol that mixes both paths - // can't have a legacy append collide with a spongefish-native - // BytesMsg of the same body. The length prefix keeps - // `append_bytes(a) ; append_bytes(b)` distinct from - // `append_bytes(a || b)`. - const APPEND_MARKER: u8 = 0x9B; - let mut buf = Vec::with_capacity(9 + bytes.len()); - buf.push(APPEND_MARKER); - buf.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); - buf.extend_from_slice(bytes); - let _ = self.sponge.absorb(&buf); - } - - fn challenge(&mut self) -> F { - // WARNING: this squeezes 16 bytes for every sponge — including - // Poseidon — even though the split-trait surface (`OptimizedChallenge`, - // see `prover.rs:53-55`) deliberately makes 128-bit challenges a - // compile error on Poseidon-backed states. The two surfaces - // disagree on purpose: the legacy facade preserves the legacy - // jolt-core challenge width for in-flight consumers (jolt-sumcheck, - // jolt-openings, jolt-crypto). Once those migrate to the split-trait - // surface this facade goes away and the inconsistency with it. - let mut buf = [0u8; 16]; - let _ = self.sponge.squeeze(&mut buf); - F::from_challenge_bytes(&buf) - } - - fn challenge_scalar(&mut self) -> F { - // Mirrors the digest transcript's scalar challenge: same 16-byte - // squeeze width as `challenge`, but the non-optimized decoding path. - let mut buf = [0u8; 16]; - let _ = self.sponge.squeeze(&mut buf); - F::from_scalar_challenge_bytes(&buf) - } - - fn state(&self) -> [u8; 32] { - peek_state(&self.sponge) - } -} diff --git a/crates/jolt-transcript/src/lib.rs b/crates/jolt-transcript/src/lib.rs index 4b43496a85..71b374b746 100644 --- a/crates/jolt-transcript/src/lib.rs +++ b/crates/jolt-transcript/src/lib.rs @@ -1,15 +1,10 @@ //! Fiat-Shamir transcripts for Jolt, backed by spongefish. //! -//! Two surfaces: -//! -//! - **Split spongefish-native traits** ([`ProverTranscript`], -//! [`VerifierTranscript`], [`OptimizedChallenge`]) — implemented directly -//! on `spongefish::ProverState` / `spongefish::VerifierState`. Use these -//! for new code. -//! - **Source-compatible facade** ([`Transcript`], [`AppendToTranscript`], -//! [`Blake2bTranscript`], [`KeccakTranscript`], [`PoseidonTranscript`]) — -//! preserved for `jolt-sumcheck`, `jolt-openings`, and `jolt-crypto`. Will -//! be retired once `jolt-core` migrates to the split-trait surface. +//! The single public surface is the **split spongefish-native traits** +//! ([`ProverTranscript`], [`VerifierTranscript`], [`OptimizedChallenge`]) — +//! implemented directly on `spongefish::ProverState` / `spongefish::VerifierState` — +//! plus the field-typed [`FsTranscript`] / [`FsAbsorb`] / [`FsChallenge`] +//! vocabulary the modular consumer crates bind against. //! //! Three sponges feature-gated: `transcript-blake2b` (spongefish //! `Blake2b512`), `transcript-keccak` (spongefish `Keccak`), @@ -18,41 +13,54 @@ #![deny(missing_docs)] mod codec; -mod legacy; +mod messages; #[cfg(feature = "transcript-poseidon")] mod poseidon; mod prover; mod setup; mod verifier; -pub use codec::BytesMsg; -pub use legacy::{ - AppendToTranscript, Label, LabelWithCount, SpongeTranscript, Transcript, U64Word, MAX_LABEL_LEN, +pub use codec::{read_length_prefixed_body, BytesMsg}; +pub use messages::{serialize_slice, FsAbsorb, FsChallenge, FsTranscript}; +pub use setup::{ + prover_transcript, transcript_builder, verifier_transcript, TranscriptInit, PROTOCOL_ID, }; -pub use setup::{prover_transcript, transcript_builder, verifier_transcript, PROTOCOL_ID}; - -/// Source-compatible re-exports of legacy label / count / word helpers -/// under their `jolt_transcript::domain::*` path (matches the path used -/// by jolt-dory and earlier modular consumers). -pub mod domain { - pub use crate::legacy::{Label, LabelWithCount, U64Word}; -} +#[cfg(feature = "transcript-poseidon")] +pub use codec::{ + commitment_to_chunks, push_byte_rule_units, push_commitments_frame_header, + push_field_frame_units, CommitmentsMsg, FieldFrameMsg, NativeChallenge, RawBytesMsg, + BYTE_RULE_CHUNK, +}; #[cfg(feature = "transcript-poseidon")] pub use poseidon::PoseidonSponge; pub use prover::{OptimizedChallenge, ProverTranscript}; +#[cfg(feature = "transcript-poseidon")] +pub use setup::{ + poseidon_domain_separator_msgs, poseidon_prover_transcript, poseidon_verifier_transcript, +}; pub use verifier::VerifierTranscript; -/// Fiat-Shamir transcript backed by Blake2b-512 (spongefish duplex sponge). +// Re-export the spongefish state types + sponge interface the split traits are built on, +// so jolt-core and the modular consumers name the entire transcript surface through +// `jolt_transcript` without a direct `spongefish` dependency. +/// Blake2b-512 spongefish sponge instantiation. #[cfg(feature = "transcript-blake2b")] -pub type Blake2bTranscript = - SpongeTranscript; - -/// Fiat-Shamir transcript backed by Keccak-f1600 (spongefish duplex sponge). +pub use spongefish::instantiations::Blake2b512; +/// Keccak-f1600 spongefish sponge instantiation. #[cfg(feature = "transcript-keccak")] -pub type KeccakTranscript = - SpongeTranscript; - -/// Fiat-Shamir transcript backed by Circom-compatible BN254 Poseidon. -#[cfg(feature = "transcript-poseidon")] -pub type PoseidonTranscript = SpongeTranscript; +pub use spongefish::instantiations::Keccak; +/// Spongefish duplex-sponge interface — the `H` sponge parameter of the split traits. +pub use spongefish::DuplexSpongeInterface; +/// Spongefish prover state (`ProverState`) — the NARG-emitting transcript. +pub use spongefish::ProverState; +/// Spongefish verifier state (`VerifierState<'a, H>`) — the NARG-reading transcript. +pub use spongefish::VerifierState; +/// Spongefish message-codec traits + NARG (de)serialization + verification result +/// types, re-exported so jolt-core and the modular consumers can author their own +/// `Encoding`/`NargSerialize`/`NargDeserialize` bridges (e.g. for generic field +/// elements and `CanonicalSerialize` blobs) without a direct `spongefish` dependency. +pub use spongefish::{ + Codec, Decoding, Encoding, NargDeserialize, NargSerialize, VerificationError, + VerificationResult, +}; diff --git a/crates/jolt-transcript/src/messages.rs b/crates/jolt-transcript/src/messages.rs new file mode 100644 index 0000000000..cc33e3003f --- /dev/null +++ b/crates/jolt-transcript/src/messages.rs @@ -0,0 +1,471 @@ +//! Field-typed Fiat–Shamir vocabulary for the *verifier-only* modular crates +//! (jolt-crypto / jolt-openings / jolt-sumcheck / jolt-hyperkzg / jolt-dory / +//! jolt-blindfold / jolt-verifier). +//! +//! These crates are **symmetric** (the spec's "Option-A" shape): a value is +//! `absorb`'d on *both* the (test-)prover and the verifier side and never +//! written to a NARG. There is intentionally **no** `prover_message` / +//! `check_eof` here — the modular verify paths consume a *structured* proof +//! and re-absorb its values to derive challenges. The malleability guard lives +//! at the structured-proof deserialize boundary, outside this surface. +//! +//! This is the field-agnostic sibling of jolt-core's internal +//! [`transcript_msgs`](../../jolt-core/src/transcript_msgs.rs) vocabulary: it +//! is generic over `jolt_field::Field` (the modular crates' field newtype) +//! rather than jolt-core's `JoltField`, and it does NOT drive a NARG. It emits +//! the identical sponge messages jolt-core does +//! (`public_message(&BytesMsg(serialize_compressed(value)))`; a 16-byte +//! challenge squeeze), so a future jolt-core cross-verifier agrees by +//! construction. +//! +//! Two concerns, two traits: +//! - [`FsAbsorb`] — absorb shared values (spongefish `public_message`). +//! - [`FsChallenge`] — squeeze field challenges. +//! +//! [`FsTranscript`] combines them; it is the bound that replaces the legacy +//! `Transcript` facade at every modular callsite. +//! +//! ## Challenge embedding — the #1 silent Fiat–Shamir hazard +//! +//! [`challenge`](FsChallenge::challenge) / [`challenge_vector`](FsChallenge::challenge_vector) +//! use the **optimized** `MontU128` embedding ([`from_challenge_bytes`], the +//! field element `v · 2¹²⁸`), matching jolt-core's `challenge_optimized`. +//! [`challenge_scalar`](FsChallenge::challenge_scalar) / +//! [`challenge_scalar_powers`](FsChallenge::challenge_scalar_powers) use the +//! **plain** embedding ([`from_u128`], the field element `v`), matching +//! jolt-core's `challenge_field`. The legacy facade drew the *same* line +//! (`challenge` → `from_challenge_bytes`, `challenge_scalar` → +//! `from_scalar_challenge_bytes`), so a migrated callsite keeps whichever +//! method it already called and stays value-consistent. Collapsing the two to +//! one embedding stays internally consistent but silently diverges from +//! jolt-core at the optimized sites (sumcheck round challenges, uni-skip `r0`, +//! Spartan `tau`, opening-RLC `rho`, …). +//! +//! **Byte sponges only (Poseidon caveat).** This 128-bit `MontU128` vocabulary matches +//! jolt-core's `challenge_optimized` for the byte sponges (Blake2b/Keccak) — the only ones +//! modular consumers instantiate. [`FsChallenge`] is deliberately implemented per byte-sponge +//! type and NOT for `PoseidonSponge` (which uses full-field `challenge-254-bit`; maintainer +//! decision on #1586), so instantiating a modular verifier over a Poseidon-backed state is a +//! **compile error** rather than a latent runtime panic. A full-field model verifier for +//! Poseidon is the deferred gnark/on-chain follow-up. +//! +//! [`from_challenge_bytes`]: jolt_field::TranscriptChallenge::from_challenge_bytes +//! [`from_u128`]: jolt_field::FromPrimitiveInt::from_u128 + +use ark_serialize::CanonicalSerialize; +use jolt_field::Field; +use rand::{CryptoRng, RngCore}; +use spongefish::{DuplexSpongeInterface, ProverState, VerifierState}; + +use crate::codec::BytesMsg; +#[cfg(any(feature = "transcript-blake2b", feature = "transcript-keccak"))] +use crate::prover::OptimizedChallenge; + +#[expect(clippy::expect_used)] +fn serialize_one(value: &T) -> Vec { + let mut buf = Vec::with_capacity(value.compressed_size()); + value + .serialize_compressed(&mut buf) + .expect("CanonicalSerialize into a Vec is infallible"); + buf +} + +/// Compressed serialization of every element, concatenated with no length prefix. +/// +/// The single shared encoder for "a sequence of `CanonicalSerialize` values as one +/// frame": used here by [`FsAbsorb::absorb_slice`] and by jolt-core's NARG +/// `write_slice`, so both produce byte-identical frames and cannot drift. +#[expect(clippy::expect_used)] +pub fn serialize_slice(values: &[T]) -> Vec { + let mut buf = Vec::with_capacity(values.iter().map(CanonicalSerialize::compressed_size).sum()); + for v in values { + v.serialize_compressed(&mut buf) + .expect("CanonicalSerialize into a Vec is infallible"); + } + buf +} + +/// Absorb shared values into the sponge (spongefish `public_message`); emits +/// no NARG bytes. +/// +/// Shared by both transcript roles so symmetric modular code absorbs +/// identically on the prover and verifier sides. On byte sponges every absorb +/// is one length-prefixed [`BytesMsg`], so `absorb(a) ; absorb(b)` stays +/// distinct from `absorb(a ‖ b)`; on the `Fr`-unit Poseidon sponge every +/// absorb is one leading-tagged unit message (see [`crate::codec`]) with the +/// same length-binding property. +pub trait FsAbsorb { + /// Absorb one `CanonicalSerialize` value (e.g. a commitment) as a single + /// message, via its compressed serialization. + fn absorb(&mut self, value: &T); + + /// Absorb a slice of `CanonicalSerialize` values as a single message + /// (their compressed serializations concatenated). + fn absorb_slice(&mut self, values: &[T]); + + /// Absorb raw bytes as a single message. + fn absorb_bytes(&mut self, bytes: &[u8]); + + /// Absorb one field element as a single message. Uses `to_bytes_le`, which + /// equals `serialize_compressed` for BN254 `Fr`, so this matches + /// [`absorb`](Self::absorb) for the same value. + fn absorb_field(&mut self, value: &F) { + self.absorb_bytes(&value.to_bytes_le_vec()); + } + + /// Absorb a slice of field elements as a *single* message (their + /// little-endian bytes concatenated). + fn absorb_field_slice(&mut self, values: &[F]) { + let mut buf = Vec::with_capacity(values.len() * F::NUM_BYTES); + for v in values { + buf.extend_from_slice(&v.to_bytes_le_vec()); + } + self.absorb_bytes(&buf); + } + + // ── Typed vocabulary (spec §4.4) ──────────────────────────────────────── + // + // `absorb` is type-opaque (a `CanonicalSerialize` blob), so a field-unit + // sponge cannot recover the value's kind from it — and length-sniffing is + // unsound (12 field elements serialize to exactly one GT's 384 bytes). + // These typed methods carry the kind. Their DEFAULTS route through the + // required byte methods above, so byte sponges (and symbolic implementors + // like the transpiler's `SymbolicVerifierFs`) keep today's behavior + // bit-for-bit with zero impl changes; the `Fr`-unit Poseidon state + // overrides them with the spec §4.2 unit encodings. + + /// Absorb one **field element** as a single message. + /// + /// Default: identical to [`absorb`](Self::absorb). Poseidon override: the + /// count-led field frame `[Fr(3), value]`. + fn absorb_scalar(&mut self, value: &T) { + self.absorb(value); + } + + /// Absorb a slice of **field elements** as a single message. + /// + /// Default: identical to `absorb(&values.to_vec())` (ark's `Vec` + /// serialization: 8-byte LE count ‖ elements) — the byte form every + /// converted call site used. Poseidon override: the count-led field frame + /// `[Fr(2k+1), e₁, …, e_k]`. + fn absorb_scalars(&mut self, values: &[T]) { + self.absorb(&values.to_vec()); + } + + /// Absorb one **commitment / group element** (Dory GT, G1/G2 points) as a + /// single message. + /// + /// Default: identical to [`absorb`](Self::absorb). Poseidon override: the + /// byte rule over the compressed serialization (one GT = 384 bytes ↦ + /// `[Fr(768), 13 chunks]`); group coordinates (q > r) must NEVER be + /// absorbed as native field units. + fn absorb_commitment(&mut self, value: &T) { + self.absorb(value); + } + + /// [`absorb_commitment`](Self::absorb_commitment) for a group element + /// already given as its canonical compressed bytes (the Dory bridge's + /// `append_group`/`append_serde`, whose `DorySerialize` values are not + /// ark-`CanonicalSerialize`). + /// + /// Default: identical to `absorb(&bytes.to_vec())` (8-byte LE length ‖ + /// bytes inside the message) — today's byte form at those sites. Poseidon + /// override: the byte rule directly over `bytes`. + fn absorb_commitment_bytes(&mut self, bytes: &[u8]) { + self.absorb(&bytes.to_vec()); + } +} + +impl FsAbsorb for ProverState +where + H: DuplexSpongeInterface, + R: RngCore + CryptoRng, +{ + fn absorb(&mut self, value: &T) { + self.public_message(&BytesMsg(serialize_one(value))); + } + + fn absorb_slice(&mut self, values: &[T]) { + self.public_message(&BytesMsg(serialize_slice(values))); + } + + fn absorb_bytes(&mut self, bytes: &[u8]) { + self.public_message(&BytesMsg(bytes.to_vec())); + } +} + +impl FsAbsorb for VerifierState<'_, H> +where + H: DuplexSpongeInterface, +{ + fn absorb(&mut self, value: &T) { + self.public_message(&BytesMsg(serialize_one(value))); + } + + fn absorb_slice(&mut self, values: &[T]) { + self.public_message(&BytesMsg(serialize_slice(values))); + } + + fn absorb_bytes(&mut self, bytes: &[u8]) { + self.public_message(&BytesMsg(bytes.to_vec())); + } +} + +/// Poseidon (`U = Fr`) absorb path. The prover- and verifier-state impls are +/// emitted from one `macro_rules!` body (`impl_poseidon_fs_absorb`), so the +/// two roles cannot drift. Type-opaque absorbs and raw bytes go under the +/// byte rule ([`RawBytesMsg`](crate::RawBytesMsg)); the typed methods carry +/// the spec §4.2 unit encodings. +#[cfg(feature = "transcript-poseidon")] +mod poseidon_absorb { + use super::*; + use crate::codec::{FieldFrameMsg, RawBytesMsg}; + use crate::poseidon::PoseidonSponge; + use ark_bn254::Fr; + use ark_serialize::CanonicalDeserialize; + + /// Parse the concatenated 32-byte-LE canonical serializations of field + /// elements (e.g. jolt-core's `F: JoltField` scalars) into native `Fr` + /// units. Canonical inputs round-trip exactly; a non-32-multiple length + /// means a non-scalar reached a scalar-typed absorb — a call-site bug. + #[expect(clippy::expect_used, reason = "caller-contract violation, not data")] + fn parse_scalar_units(bytes: &[u8]) -> Vec { + assert!( + bytes.len().is_multiple_of(32), + "absorb_scalar(s): value is not a sequence of 32-byte field elements ({} bytes)", + bytes.len() + ); + bytes + .chunks_exact(32) + .map(|c| Fr::deserialize_compressed(c).expect("non-canonical scalar absorbed")) + .collect() + } + + /// Emits the full Poseidon `FsAbsorb` method set under the given impl + /// header. Invoked once per transcript role so both impls share one + /// token-for-token method body and cannot drift. + macro_rules! impl_poseidon_fs_absorb { + ($($header:tt)+) => { + $($header)+ { + fn absorb(&mut self, value: &T) { + self.public_message(&RawBytesMsg(serialize_one(value))); + } + + fn absorb_slice(&mut self, values: &[T]) { + self.public_message(&RawBytesMsg(serialize_slice(values))); + } + + fn absorb_bytes(&mut self, bytes: &[u8]) { + self.public_message(&RawBytesMsg(bytes.to_vec())); + } + + // The trait default routes `absorb_field`/`absorb_field_slice` through + // `absorb_bytes` (byte rule), which on Poseidon diverges from + // `absorb_scalar`/`absorb_scalars` (count-led field frame). Override here so a + // field element absorbed via `absorb_field` produces the SAME `FieldFrameMsg` + // it would via `absorb_scalar`: `Field::to_bytes_le_vec()` for BN254 `Fr` + // equals `serialize_compressed` (canonical 32-byte LE), so feeding it through + // `parse_scalar_units` yields the identical native `Fr` units. + fn absorb_field(&mut self, value: &F) { + self.public_message(&FieldFrameMsg(parse_scalar_units( + &value.to_bytes_le_vec(), + ))); + } + + fn absorb_field_slice(&mut self, values: &[F]) { + let mut buf = Vec::with_capacity(values.len() * F::NUM_BYTES); + for v in values { + buf.extend_from_slice(&v.to_bytes_le_vec()); + } + self.public_message(&FieldFrameMsg(parse_scalar_units(&buf))); + } + + fn absorb_scalar(&mut self, value: &T) { + self.public_message(&FieldFrameMsg(parse_scalar_units(&serialize_one(value)))); + } + + fn absorb_scalars(&mut self, values: &[T]) { + self.public_message(&FieldFrameMsg(parse_scalar_units(&serialize_slice( + values, + )))); + } + + fn absorb_commitment(&mut self, value: &T) { + // The byte rule over one compressed serialization — + // unit-identical to one per-GT group inside a + // `CommitmentsMsg` frame (the frame's leading `Fr(2k+1)` + // count unit binds the frame partition and is not emitted + // for a lone, schedule-fixed commitment absorb). + self.absorb_commitment_bytes(&serialize_one(value)); + } + + fn absorb_commitment_bytes(&mut self, bytes: &[u8]) { + self.public_message(&RawBytesMsg(bytes.to_vec())); + } + } + }; + } + + impl_poseidon_fs_absorb!( + impl FsAbsorb for ProverState + ); + impl_poseidon_fs_absorb!(impl FsAbsorb for VerifierState<'_, PoseidonSponge>); +} + +/// Squeeze field challenges. Implemented per byte-sponge type for +/// `ProverState` / `VerifierState` (Blake2b/Keccak), so prover and verifier +/// derive challenges identically. Deliberately NOT implemented for +/// `PoseidonSponge` — see the module docs. +/// +/// See the module docs for the optimized-vs-plain embedding distinction. +pub trait FsChallenge { + /// Squeeze an **optimized** (`MontU128`-embedded, `v · 2¹²⁸`) challenge. + fn challenge(&mut self) -> F; + + /// Squeeze a **plain** (`from_u128`, `v`) scalar challenge. + fn challenge_scalar(&mut self) -> F; + + /// `n` independent optimized challenges. + fn challenge_vector(&mut self, n: usize) -> Vec { + (0..n).map(|_| self.challenge()).collect() + } + + /// Powers `(1, γ, γ², …, γⁿ⁻¹)` from a single **plain** squeezed `γ`. + fn challenge_scalar_powers(&mut self, n: usize) -> Vec { + let gamma = self.challenge_scalar(); + let mut powers = vec![F::from_u64(1); n]; + for index in 1..n { + powers[index] = powers[index - 1] * gamma; + } + powers + } +} + +#[cfg(any(feature = "transcript-blake2b", feature = "transcript-keccak"))] +fn optimized_embed(v: u128) -> F { + F::from_challenge_bytes(&v.to_le_bytes()) +} + +#[cfg(feature = "transcript-blake2b")] +impl FsChallenge + for ProverState +{ + fn challenge(&mut self) -> F { + optimized_embed(self.challenge_u128()) + } + + fn challenge_scalar(&mut self) -> F { + F::from_u128(self.challenge_u128()) + } +} + +#[cfg(feature = "transcript-blake2b")] +impl FsChallenge for VerifierState<'_, spongefish::instantiations::Blake2b512> { + fn challenge(&mut self) -> F { + optimized_embed(self.challenge_u128()) + } + + fn challenge_scalar(&mut self) -> F { + F::from_u128(self.challenge_u128()) + } +} + +#[cfg(feature = "transcript-keccak")] +impl FsChallenge + for ProverState +{ + fn challenge(&mut self) -> F { + optimized_embed(self.challenge_u128()) + } + + fn challenge_scalar(&mut self) -> F { + F::from_u128(self.challenge_u128()) + } +} + +#[cfg(feature = "transcript-keccak")] +impl FsChallenge for VerifierState<'_, spongefish::instantiations::Keccak> { + fn challenge(&mut self) -> F { + optimized_embed(self.challenge_u128()) + } + + fn challenge_scalar(&mut self) -> F { + F::from_u128(self.challenge_u128()) + } +} + +/// The combined absorb + challenge surface that replaces the legacy +/// `Transcript` facade bound at modular callsites. +/// +/// Blanket-implemented for every state that is both [`FsAbsorb`] and +/// [`FsChallenge`] — i.e. every `ProverState` / `VerifierState` over a +/// supported sponge. +pub trait FsTranscript: FsAbsorb + FsChallenge {} + +impl> FsTranscript for T {} + +// Gated on `transcript-blake2b`: the suite fixes its sponge to `Blake2b512` +// (and exercises that sponge's `FsChallenge` impl), so it only compiles when +// that feature — the one pulling `spongefish/blake2` — is on. Without this +// gate `--no-default-features --features transcript-poseidon --all-targets` +// fails to build (no `Blake2b512` in `spongefish::instantiations`). +#[cfg(all(test, feature = "transcript-blake2b"))] +#[expect(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::{prover_transcript, verifier_transcript, VerifierTranscript}; + use jolt_field::{Fr, FromPrimitiveInt}; + use spongefish::instantiations::Blake2b512; + + const SESSION: &[u8] = b"jolt-transcript-messages-test/v1"; + type Bl = Blake2b512; + + /// A symmetric absorb sequence + challenge derivation agrees between a + /// (test-)prover and an independently-built verifier — the modular crates' + /// model. No NARG: every value is `absorb`'d on both sides. + #[test] + fn symmetric_absorb_and_challenges_agree() { + let scalars: Vec = (0..6).map(|i| Fr::from_u64(i * 7 + 3)).collect(); + let instance = [0x42; 32]; + + let mut p = prover_transcript(SESSION, instance, Bl::default()); + FsAbsorb::absorb_field_slice(&mut p, &scalars); + let p_opt = FsChallenge::::challenge(&mut p); + let p_plain = FsChallenge::::challenge_scalar(&mut p); + let p_vec = FsChallenge::::challenge_vector(&mut p, 3); + let p_pow = FsChallenge::::challenge_scalar_powers(&mut p, 4); + + // Symmetric: the verifier re-absorbs the SAME shared values (no NARG read). + let empty: &[u8] = &[]; + let mut v = verifier_transcript(SESSION, instance, Bl::default(), empty); + FsAbsorb::absorb_field_slice(&mut v, &scalars); + let v_opt = FsChallenge::::challenge(&mut v); + let v_plain = FsChallenge::::challenge_scalar(&mut v); + let v_vec = FsChallenge::::challenge_vector(&mut v, 3); + let v_pow = FsChallenge::::challenge_scalar_powers(&mut v, 4); + VerifierTranscript::::check_eof(v).unwrap(); + + assert_eq!(p_opt, v_opt, "optimized challenge diverged"); + assert_eq!(p_plain, v_plain, "plain scalar challenge diverged"); + assert_eq!(p_vec, v_vec, "challenge vector diverged"); + assert_eq!(p_pow, v_pow, "challenge powers diverged"); + } + + /// The optimized and plain embeddings differ (the DEV-41 distinction): + /// the same 128-bit squeeze must NOT produce the same field element. + #[test] + fn optimized_and_plain_embeddings_differ() { + let instance = [7u8; 32]; + let mut a = prover_transcript(SESSION, instance, Bl::default()); + let optimized = FsChallenge::::challenge(&mut a); + + let mut b = prover_transcript(SESSION, instance, Bl::default()); + let plain = FsChallenge::::challenge_scalar(&mut b); + + // Same transcript position, same 128-bit squeeze, different embedding. + assert_ne!( + optimized, plain, + "optimized (v·2^128) and plain (v) embeddings must differ" + ); + } +} diff --git a/crates/jolt-transcript/src/poseidon.rs b/crates/jolt-transcript/src/poseidon.rs index 71339c160c..590a7721a5 100644 --- a/crates/jolt-transcript/src/poseidon.rs +++ b/crates/jolt-transcript/src/poseidon.rs @@ -1,15 +1,23 @@ //! `PoseidonSponge` — Circom-compatible BN254 Poseidon adapter exposed -//! through `spongefish::DuplexSpongeInterface`. +//! through `spongefish::DuplexSpongeInterface` with **`U = Fr`** (the +//! field-aligned transcript of `specs/transpiler-optimization-spec.md` §4). //! -//! Sponge layout: one `Fr` capacity element (`self.state`) plus two `Fr` -//! rate inputs per `permute` call, fed through light-poseidon's width-4 -//! compression function (`Poseidon::new_circom(3)` — width minus one -//! inputs). Each call replaces capacity with the compression output. +//! Sponge layout (unchanged compression chain, spec decision D2): one `Fr` +//! capacity element (`self.state`) plus two `Fr` rate inputs per `permute` +//! call, fed through light-poseidon's width-4 compression function +//! (`Poseidon::new_circom(3)` — width minus one inputs). Each call replaces +//! capacity with the compression output. This is a hash chain, not a true +//! duplex sponge (no hidden capacity across squeezes); cost and +//! ideal-permutation uniformity are unaffected (spec §11.2). //! -//! Byte traffic is mapped to `Fr` via 31-byte little-endian chunks -//! (`Fr::from_le_bytes_mod_order` is injective on chunks ≤ 31 bytes since -//! 248 bits < BN254 modulus). Squeezed bytes come from -//! `into_bigint().to_bytes_le()` of the running state. +//! - `absorb(&[Fr])` feeds unit pairs: `permute(u0,u1)`, `permute(u2,u3)`, … +//! zero-padding an odd tail. Every absorb call starts a fresh permute pair, +//! so message boundaries bind exactly when each message is a complete +//! tagged group per spec §4.2 (the tagged-length encoding lives in the +//! message layer — see [`crate::codec`] — NOT here; the sponge just eats +//! unit slices). +//! - `squeeze(&mut [Fr])`: per output unit, `permute(0,0)` and emit the new +//! state — one permute per squeezed unit, no buffering. //! //! Round constants are built once per `PoseidonSponge` construction; the //! same `Poseidon` is reused for every `permute` call in this sponge's @@ -17,13 +25,10 @@ //! exit, so reuse is safe). use ark_bn254::Fr; -use ark_ff::{BigInteger, PrimeField, Zero}; +use ark_ff::Zero; use light_poseidon::{Poseidon, PoseidonHasher}; use spongefish::DuplexSpongeInterface; -const SQUEEZE_BYTES: usize = 32; -const ABSORB_CHUNK_BYTES: usize = 31; - #[expect( clippy::expect_used, reason = "width 4 (NR_INPUTS=3) is supported by light-poseidon's Circom params" @@ -32,12 +37,11 @@ fn fresh_hasher() -> Poseidon { Poseidon::::new_circom(3).expect("light-poseidon: width-4 init") } -/// Width-4 Poseidon duplex sponge over BN254 `Fr`, byte-driven. +/// Width-4 Poseidon compression-chain sponge over BN254 `Fr`, field-unit +/// driven (`U = Fr`). pub struct PoseidonSponge { hasher: Poseidon, state: Fr, - pending_squeeze: [u8; SQUEEZE_BYTES], - squeeze_offset: usize, } impl PoseidonSponge { @@ -46,8 +50,6 @@ impl PoseidonSponge { Self { hasher: fresh_hasher(), state: Fr::zero(), - pending_squeeze: [0u8; SQUEEZE_BYTES], - squeeze_offset: SQUEEZE_BYTES, } } @@ -64,23 +66,6 @@ impl PoseidonSponge { .expect("light-poseidon hash"); self.state = next; } - - fn refill_squeeze(&mut self) { - self.permute(Fr::zero(), Fr::zero()); - let bytes = self.state.into_bigint().to_bytes_le(); - self.pending_squeeze.fill(0); - let n = bytes.len().min(SQUEEZE_BYTES); - self.pending_squeeze[..n].copy_from_slice(&bytes[..n]); - self.squeeze_offset = 0; - } - - fn absorb_fr_pair(&mut self, a: Fr, b: Fr) { - // Any pending squeeze is invalidated by a new absorb; spongefish's - // DuplexSpongeInterface contract is associative within a phase and - // the squeeze cache is just a buffer over fresh permutations. - self.squeeze_offset = SQUEEZE_BYTES; - self.permute(a, b); - } } impl Default for PoseidonSponge { @@ -94,8 +79,6 @@ impl Clone for PoseidonSponge { Self { hasher: fresh_hasher(), state: self.state, - pending_squeeze: self.pending_squeeze, - squeeze_offset: self.squeeze_offset, } } } @@ -104,59 +87,32 @@ impl std::fmt::Debug for PoseidonSponge { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PoseidonSponge") .field("state", &self.state) - .field("squeeze_offset", &self.squeeze_offset) .finish_non_exhaustive() } } impl DuplexSpongeInterface for PoseidonSponge { - type U = u8; - - fn absorb(&mut self, input: &[u8]) -> &mut Self { - // Length-binding permutation up front: without it, absorb(&[]), - // absorb(&[0]), absorb(&[0; 31]) all collapse to Fr::zero() at the - // chunk level and would alias. - let len_fr = Fr::from(input.len() as u64); - self.absorb_fr_pair(len_fr, Fr::zero()); - - let mut iter = input.chunks(ABSORB_CHUNK_BYTES); - while let Some(first) = iter.next() { - let a = Fr::from_le_bytes_mod_order(first); - let b = iter - .next() - .map_or_else(Fr::zero, Fr::from_le_bytes_mod_order); - self.absorb_fr_pair(a, b); + type U = Fr; + + fn absorb(&mut self, input: &[Fr]) -> &mut Self { + for pair in input.chunks(2) { + let a = pair[0]; + let b = pair.get(1).copied().unwrap_or_else(Fr::zero); + self.permute(a, b); } self } - fn squeeze(&mut self, output: &mut [u8]) -> &mut Self { - let mut written = 0; - while written < output.len() { - if self.squeeze_offset >= SQUEEZE_BYTES { - self.refill_squeeze(); - } - let avail = SQUEEZE_BYTES - self.squeeze_offset; - let want = output.len() - written; - let take = avail.min(want); - output[written..written + take].copy_from_slice( - &self.pending_squeeze[self.squeeze_offset..self.squeeze_offset + take], - ); - self.squeeze_offset += take; - written += take; + fn squeeze(&mut self, output: &mut [Fr]) -> &mut Self { + for slot in output { + self.permute(Fr::zero(), Fr::zero()); + *slot = self.state; } self } fn ratchet(&mut self) -> &mut Self { - // Intentional double-permute on the `ratchet ; squeeze` path: the - // permutation here is the one-way ratchet, and the next `squeeze` - // call's `refill_squeeze` runs a second permutation to produce the - // first squeeze block. Matches spongefish's own `DuplexSponge::ratchet` - // semantics (one permute in `ratchet`, another in the first - // `squeeze`); do not collapse to a single call. self.permute(Fr::zero(), Fr::zero()); - self.squeeze_offset = SQUEEZE_BYTES; self } } @@ -169,42 +125,51 @@ impl DuplexSpongeInterface for PoseidonSponge { mod tests { use super::*; + fn squeeze1(s: &mut PoseidonSponge) -> Fr { + let mut out = [Fr::zero(); 1]; + s.squeeze(&mut out); + out[0] + } + #[test] fn deterministic() { let mut a = PoseidonSponge::new(); let mut b = PoseidonSponge::new(); - a.absorb(b"hello"); - b.absorb(b"hello"); - let mut x = [0u8; 64]; - let mut y = [0u8; 64]; - a.squeeze(&mut x); - b.squeeze(&mut y); - assert_eq!(x, y); + let units = [Fr::from(7u64), Fr::from(11u64), Fr::from(13u64)]; + a.absorb(&units); + b.absorb(&units); + assert_eq!(squeeze1(&mut a), squeeze1(&mut b)); } #[test] fn order_sensitive() { let mut a = PoseidonSponge::new(); let mut b = PoseidonSponge::new(); - a.absorb(b"x").absorb(b"y"); - b.absorb(b"y").absorb(b"x"); - let mut x = [0u8; 32]; - let mut y = [0u8; 32]; - a.squeeze(&mut x); - b.squeeze(&mut y); - assert_ne!(x, y); + a.absorb(&[Fr::from(1u64), Fr::from(2u64)]); + b.absorb(&[Fr::from(2u64), Fr::from(1u64)]); + assert_ne!(squeeze1(&mut a), squeeze1(&mut b)); + } + + /// One squeezed unit costs exactly one permute: two single-unit squeezes + /// equal one two-unit squeeze (associativity, no buffering). + #[test] + fn squeeze_is_associative_one_permute_per_unit() { + let mut a = PoseidonSponge::new(); + let mut b = PoseidonSponge::new(); + let x = squeeze1(&mut a); + let y = squeeze1(&mut a); + let mut out = [Fr::zero(); 2]; + b.squeeze(&mut out); + assert_eq!([x, y], out); } + /// Odd-length absorbs zero-pad the trailing pair. #[test] - fn empty_distinct_from_zero_absorb() { + fn odd_absorb_pads_with_zero_unit() { let mut a = PoseidonSponge::new(); let mut b = PoseidonSponge::new(); - a.absorb(&[]); - b.absorb(&[0u8]); - let mut x = [0u8; 32]; - let mut y = [0u8; 32]; - a.squeeze(&mut x); - b.squeeze(&mut y); - assert_ne!(x, y); + a.absorb(&[Fr::from(5u64)]); + b.absorb(&[Fr::from(5u64), Fr::zero()]); + assert_eq!(squeeze1(&mut a), squeeze1(&mut b)); } } diff --git a/crates/jolt-transcript/src/prover.rs b/crates/jolt-transcript/src/prover.rs index b9add707a3..2085b3c0b9 100644 --- a/crates/jolt-transcript/src/prover.rs +++ b/crates/jolt-transcript/src/prover.rs @@ -47,13 +47,30 @@ where } } -/// 128-bit-truncating challenge decoder. Implemented for sponges where the -/// optimization is sound (Blake2b, Keccak); deliberately not implemented -/// for [`PoseidonSponge`](crate::PoseidonSponge), so calling it on a -/// Poseidon-backed state is a compile error. +/// 128-bit challenge decoder. +/// +/// Blake2b/Keccak squeeze a 16-byte `u128` directly — sound because every byte +/// they emit is uniform — yielding a 128-bit value embedded in [`Fr`] for the +/// downstream fast-multiplication path. [`PoseidonSponge`](crate::PoseidonSponge) +/// deliberately does **not** implement a meaningful 128-bit challenge: its impl is +/// `unimplemented!()` because `transcript-poseidon` uses full-field +/// `challenge-254-bit` (truncating defeats Poseidon's recursion purpose). The +/// Poseidon impl exists only so generic-over-sponge bounds resolve. pub trait OptimizedChallenge { - /// Squeezes a 128-bit-truncated challenge as an [`Fr`]. - fn challenge_128(&mut self) -> Fr; + /// Squeezes a 128-bit verifier challenge as a raw `u128` — the uniform low + /// 128 bits of the sponge output. + /// + /// This is the primitive: callers that want the fast-multiply field + /// challenge wrapper (`JoltField::Challenge`, e.g. `MontU128Challenge`) + /// build it via `Challenge::from(u128)`, which preserves the 128-bit + /// fast-multiply path. [`challenge_128`](Self::challenge_128) is the + /// embedded-in-[`Fr`] convenience built on top of it. + fn challenge_u128(&mut self) -> u128; + + /// Squeezes the same 128-bit challenge embedded in an [`Fr`]. + fn challenge_128(&mut self) -> Fr { + Fr::from(self.challenge_u128()) + } } #[cfg(feature = "transcript-blake2b")] @@ -61,8 +78,8 @@ impl OptimizedChallenge for ProverState Fr { - Fr::from(ProverState::verifier_message::(self)) + fn challenge_u128(&mut self) -> u128 { + ProverState::verifier_message::(self) } } @@ -71,7 +88,30 @@ impl OptimizedChallenge for ProverState Fr { - Fr::from(ProverState::verifier_message::(self)) + fn challenge_u128(&mut self) -> u128 { + ProverState::verifier_message::(self) + } +} + +// Poseidon `OptimizedChallenge` — deliberately UNIMPLEMENTED (#1586 reviewer / D5b): the +// 128-bit truncation is costly for recursion and defeats Poseidon's purpose, so +// `transcript-poseidon` forces `challenge-254-bit` (genuine full-field challenges) and +// `challenge_u128` is `unimplemented!()` (legacy `challenge_scalar_128_bits`'s analogue). +// The impl is KEPT (not omitted) so generic-over-sponge `OptimizedChallenge` bounds still +// resolve for Poseidon. +#[cfg(feature = "transcript-poseidon")] +impl OptimizedChallenge for ProverState +where + R: RngCore + CryptoRng, +{ + #[expect( + clippy::unimplemented, + reason = "Poseidon uses full-field challenge-254-bit; 128-bit truncation is unsupported (#1586 reviewer)" + )] + fn challenge_u128(&mut self) -> u128 { + unimplemented!( + "128-bit optimized challenges are unsupported for the Poseidon sponge; \ + transcript-poseidon uses full-field challenge-254-bit" + ) } } diff --git a/crates/jolt-transcript/src/setup.rs b/crates/jolt-transcript/src/setup.rs index c6fb650b42..d120bb9ede 100644 --- a/crates/jolt-transcript/src/setup.rs +++ b/crates/jolt-transcript/src/setup.rs @@ -24,6 +24,12 @@ //! drop down to [`transcript_builder`], which returns spongefish's //! `DomainSeparator` with PROTOCOL_ID pre-bound — the full type-state //! builder is then available. +//! +//! The factories dispatch per sponge alphabet via [`TranscriptInit`]: byte +//! sponges (`U = u8`) go through spongefish's `DomainSeparator` unchanged; +//! the `Fr`-unit Poseidon sponge absorbs the same three domain-separator +//! byte strings under the byte rule via +//! [`poseidon_prover_transcript`]/[`poseidon_verifier_transcript`]. use rand::rngs::StdRng; use spongefish::{ @@ -49,17 +55,6 @@ const fn pad_id(src: &[u8]) -> [u8; 64] { buf } -/// Empty `instance` value used by the legacy facade. Encodes to zero -/// bytes. The native factory functions use [`InstanceDigest`] instead. -#[derive(Clone, Copy, Debug, Default)] -pub struct EmptyInstance; - -impl Encoding<[u8]> for EmptyInstance { - fn encode(&self) -> impl AsRef<[u8]> { - [0u8; 0] - } -} - /// 32-byte instance digest. Internal adapter — exposes `Encoding<[u8]>` /// over a fixed-size digest so it slots into spongefish's `.instance(...)` /// step. Public callers pass a plain `[u8; 32]` to [`prover_transcript`] / @@ -73,6 +68,135 @@ impl Encoding<[u8]> for InstanceDigest { } } +/// Per-sponge domain-separator dispatch behind [`prover_transcript`] / +/// [`verifier_transcript`] — the unit-generic seam that lets jolt-core stay +/// generic over `H` whether the sponge alphabet is bytes or `Fr`. +/// +/// - Byte sponges (`U = u8`) get a blanket impl that routes through +/// spongefish's `DomainSeparator` exactly as before — byte behavior is +/// untouched by construction. +/// - The `Fr`-unit [`PoseidonSponge`](crate::PoseidonSponge) cannot use +/// `DomainSeparator::to_prover`/`to_verifier` (`[u8; 64]: Encoding<[Fr]>` +/// is orphan-blocked), so its impl builds the states via the public +/// unit-generic constructors (`ProverState::from`, +/// `VerifierState::from_parts`) and absorbs the same domain-separator +/// content under the byte rule (see [`poseidon_prover_transcript`]). +pub trait TranscriptInit: DuplexSpongeInterface + Sized { + /// Builds the prover state and absorbs `PROTOCOL_ID ‖ session ‖ instance`. + fn init_prover(session: &[u8], instance: [u8; 32], sponge: Self) -> ProverState; + + /// Builds the verifier state over `narg` and absorbs the same + /// domain-separator content as [`init_prover`](Self::init_prover). + fn init_verifier<'a>( + session: &[u8], + instance: [u8; 32], + sponge: Self, + narg: &'a [u8], + ) -> VerifierState<'a, Self>; +} + +impl TranscriptInit for H +where + H: DuplexSpongeInterface, +{ + fn init_prover(session: &[u8], instance: [u8; 32], sponge: Self) -> ProverState { + DomainSeparator::new(PROTOCOL_ID) + .session(BytesMsg(session.to_vec())) + .instance(InstanceDigest(instance)) + .to_prover(sponge) + } + + fn init_verifier<'a>( + session: &[u8], + instance: [u8; 32], + sponge: Self, + narg: &'a [u8], + ) -> VerifierState<'a, Self> { + DomainSeparator::new(PROTOCOL_ID) + .session(BytesMsg(session.to_vec())) + .instance(InstanceDigest(instance)) + .to_verifier(sponge, narg) + } +} + +#[cfg(feature = "transcript-poseidon")] +impl TranscriptInit for crate::PoseidonSponge { + fn init_prover(session: &[u8], instance: [u8; 32], sponge: Self) -> ProverState { + poseidon_prover_transcript(session, instance, sponge) + } + + fn init_verifier<'a>( + session: &[u8], + instance: [u8; 32], + sponge: Self, + narg: &'a [u8], + ) -> VerifierState<'a, Self> { + poseidon_verifier_transcript(session, instance, sponge, narg) + } +} + +/// The domain-separator messages shared by the Poseidon prover/verifier +/// factories, absorbed under the byte rule in this order: +/// +/// 1. [`PROTOCOL_ID`] — 64 zero-padded bytes (what `DomainSeparator` absorbs +/// as its protocol step); +/// 2. the session as its `BytesMsg` encoding — 8-byte LE length ‖ session +/// (what the byte path's `.session(BytesMsg(..))` step absorbs); +/// 3. the 32-byte instance digest raw (what `InstanceDigest` encodes to). +/// +/// Public so the transpiler's symbolic sponge mirror seeds its in-circuit +/// state from the SAME three byte strings the native factories absorb, +/// instead of re-hardcoding them (a re-hardcoded copy is a drift channel). +#[cfg(feature = "transcript-poseidon")] +pub fn poseidon_domain_separator_msgs( + session: &[u8], + instance: [u8; 32], +) -> [crate::codec::RawBytesMsg; 3] { + use crate::codec::RawBytesMsg; + let session_bytes = BytesMsg(session.to_vec()).encode().as_ref().to_vec(); + [ + RawBytesMsg(PROTOCOL_ID.to_vec()), + RawBytesMsg(session_bytes), + RawBytesMsg(instance.to_vec()), + ] +} + +/// Poseidon-specific sibling of [`prover_transcript`]: builds the +/// `Fr`-unit prover state via the unit-generic `ProverState::from` and +/// absorbs the domain separator under the byte rule (spongefish's +/// `DomainSeparator::to_prover` is unusable for `U = Fr` because +/// `[u8; 64]: Encoding<[Fr]>` is orphan-blocked). +#[cfg(feature = "transcript-poseidon")] +#[must_use] +pub fn poseidon_prover_transcript( + session: &[u8], + instance: [u8; 32], + sponge: crate::PoseidonSponge, +) -> ProverState { + let mut state = ProverState::from(sponge); + for msg in &poseidon_domain_separator_msgs(session, instance) { + state.public_message(msg); + } + state +} + +/// Poseidon-specific sibling of [`verifier_transcript`] — see +/// [`poseidon_prover_transcript`]. +#[cfg(feature = "transcript-poseidon")] +#[must_use] +pub fn poseidon_verifier_transcript<'a>( + session: &[u8], + instance: [u8; 32], + sponge: crate::PoseidonSponge, + narg: &'a [u8], +) -> VerifierState<'a, crate::PoseidonSponge> { + let mut state = VerifierState::from_parts(sponge, narg); + for msg in &poseidon_domain_separator_msgs(session, instance) { + state.public_message(msg); + } + state +} + /// Build a prover transcript bound to `session` and `instance`. /// /// `session` is protocol-version bytes (e.g. `b"jolt-rv64imac/v1"`). @@ -80,6 +204,10 @@ impl Encoding<[u8]> for InstanceDigest { /// `Blake2b(CanonicalSerialize(public_state))`). Both are absorbed under /// spongefish's domain-separator steps after [`PROTOCOL_ID`]. /// +/// Dispatches per sponge via [`TranscriptInit`]: byte sponges go through +/// spongefish's `DomainSeparator` exactly as before; the `Fr`-unit Poseidon +/// sponge goes through [`poseidon_prover_transcript`]. +/// /// # Example /// /// ``` @@ -104,12 +232,9 @@ impl Encoding<[u8]> for InstanceDigest { #[must_use] pub fn prover_transcript(session: &[u8], instance: [u8; 32], sponge: H) -> ProverState where - H: DuplexSpongeInterface, + H: TranscriptInit, { - DomainSeparator::new(PROTOCOL_ID) - .session(BytesMsg(session.to_vec())) - .instance(InstanceDigest(instance)) - .to_prover(sponge) + H::init_prover(session, instance, sponge) } /// Build a verifier transcript bound to `session` and `instance` over @@ -123,12 +248,9 @@ pub fn verifier_transcript<'a, H>( narg: &'a [u8], ) -> VerifierState<'a, H> where - H: DuplexSpongeInterface, + H: TranscriptInit, { - DomainSeparator::new(PROTOCOL_ID) - .session(BytesMsg(session.to_vec())) - .instance(InstanceDigest(instance)) - .to_verifier(sponge, narg) + H::init_verifier(session, instance, sponge, narg) } /// Escape hatch returning spongefish's `DomainSeparator` with diff --git a/crates/jolt-transcript/src/verifier.rs b/crates/jolt-transcript/src/verifier.rs index 7be0d04302..c65a6968e5 100644 --- a/crates/jolt-transcript/src/verifier.rs +++ b/crates/jolt-transcript/src/verifier.rs @@ -1,6 +1,5 @@ //! Spongefish-native [`VerifierTranscript`] surface. -use jolt_field::Fr; use spongefish::{ Decoding, DuplexSpongeInterface, Encoding, NargDeserialize, VerificationResult, VerifierState, }; @@ -58,14 +57,30 @@ where #[cfg(feature = "transcript-blake2b")] impl OptimizedChallenge for VerifierState<'_, spongefish::instantiations::Blake2b512> { - fn challenge_128(&mut self) -> Fr { - Fr::from(VerifierState::verifier_message::(self)) + fn challenge_u128(&mut self) -> u128 { + VerifierState::verifier_message::(self) } } #[cfg(feature = "transcript-keccak")] impl OptimizedChallenge for VerifierState<'_, spongefish::instantiations::Keccak> { - fn challenge_128(&mut self) -> Fr { - Fr::from(VerifierState::verifier_message::(self)) + fn challenge_u128(&mut self) -> u128 { + VerifierState::verifier_message::(self) + } +} + +// Poseidon `OptimizedChallenge` (verifier side) — deliberately UNIMPLEMENTED, mirror of the +// prover impl (#1586 reviewer / D5b). See the fuller note in `prover.rs`. +#[cfg(feature = "transcript-poseidon")] +impl OptimizedChallenge for VerifierState<'_, crate::PoseidonSponge> { + #[expect( + clippy::unimplemented, + reason = "Poseidon uses full-field challenge-254-bit; 128-bit truncation is unsupported (#1586 reviewer)" + )] + fn challenge_u128(&mut self) -> u128 { + unimplemented!( + "128-bit optimized challenges are unsupported for the Poseidon sponge; \ + transcript-poseidon uses full-field challenge-254-bit" + ) } } diff --git a/crates/jolt-transcript/tests/blake2b_tests.rs b/crates/jolt-transcript/tests/blake2b_tests.rs deleted file mode 100644 index c6f97c6783..0000000000 --- a/crates/jolt-transcript/tests/blake2b_tests.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Tests for Blake2bTranscript implementation. - -mod common; - -use jolt_field::{Fr, FromPrimitiveInt}; -use jolt_transcript::Blake2bTranscript; - -type B2b = Blake2bTranscript; - -transcript_tests!(B2b); - -#[test] -fn test_blake2b_known_vector() { - use ark_ff::PrimeField; - use jolt_transcript::Transcript; - - let mut transcript = Blake2bTranscript::::new(b"Jolt"); - transcript.append_bytes(&12345u64.to_be_bytes()); - let challenge: Fr = transcript.challenge(); - - // Pinned wire-format check: any change to PROTOCOL_ID, the session - // encoding, the append_bytes layout, or the challenge decoder will - // flip these bytes. Update only with an audit trail. - // - // Audit trail: `from_challenge_bytes` now builds the field element from the - // squeezed bytes via the 125-bit Montgomery-friendly decode rather than a - // plain little-endian reduction, so the canonical challenge value changed. - let expected: ark_bn254::Fr = ark_bn254::Fr::from_le_bytes_mod_order(&[ - 0xAE, 0x28, 0x1C, 0xAE, 0x3E, 0x93, 0x36, 0xA9, 0xE2, 0x57, 0xE0, 0x30, 0x2F, 0xC0, 0x48, - 0x2E, 0xCF, 0xC9, 0x89, 0x1B, 0x7D, 0x65, 0x4E, 0x6A, 0xB5, 0xEF, 0x55, 0x46, 0x36, 0x71, - 0x8A, 0x29, - ]); - let got: ark_bn254::Fr = challenge.into(); - assert_eq!(got, expected, "Blake2b known-vector regression"); -} - -#[test] -fn test_field_zero_one_distinct_states() { - use jolt_field::Fr; - use jolt_transcript::{AppendToTranscript, Transcript}; - use num_traits::{One, Zero}; - - let mut t_zero = Blake2bTranscript::::new(b"field_test"); - Fr::zero().append_to_transcript(&mut t_zero); - let c_zero: Fr = t_zero.challenge(); - - let mut t_one = Blake2bTranscript::::new(b"field_test"); - Fr::one().append_to_transcript(&mut t_one); - let c_one: Fr = t_one.challenge(); - - assert_ne!( - c_zero, c_one, - "Fr::zero() and Fr::one() must produce distinct transcript states" - ); -} - -#[test] -fn test_field_element_ordering_sensitivity() { - use jolt_transcript::{AppendToTranscript, Transcript}; - - let a = Fr::from_u64(42); - let b = Fr::from_u64(99); - - let mut t1 = Blake2bTranscript::::new(b"order_test"); - a.append_to_transcript(&mut t1); - b.append_to_transcript(&mut t1); - let c1: Fr = t1.challenge(); - - let mut t2 = Blake2bTranscript::::new(b"order_test"); - b.append_to_transcript(&mut t2); - a.append_to_transcript(&mut t2); - let c2: Fr = t2.challenge(); - - assert_ne!( - c1, c2, - "append(a, b) and append(b, a) must produce different challenges" - ); -} diff --git a/crates/jolt-transcript/tests/common/mod.rs b/crates/jolt-transcript/tests/common/mod.rs deleted file mode 100644 index 3faaf9e181..0000000000 --- a/crates/jolt-transcript/tests/common/mod.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! Common test utilities and standardized test suite for transcript implementations. - -/// Standardized test suite macro for any `Transcript` implementation. -/// -/// All comparisons are done through `challenge()` outputs since the -/// underlying spongefish duplex sponges do not expose internal state. -#[macro_export] -macro_rules! transcript_tests { - ($transcript_type:ty) => { - use jolt_transcript::Transcript; - use std::collections::HashSet; - - // Helper: drive a transcript through a closure and squeeze a challenge. - fn challenge_after( - label: &'static [u8], - f: F, - ) -> <$transcript_type as Transcript>::Challenge { - let mut t = <$transcript_type>::new(label); - f(&mut t); - t.challenge() - } - - #[test] - fn test_determinism() { - let c1 = challenge_after(b"determinism_test", |t| { - t.append_bytes(&42u64.to_be_bytes()); - t.append_bytes(b"hello world"); - }); - let c2 = challenge_after(b"determinism_test", |t| { - t.append_bytes(&42u64.to_be_bytes()); - t.append_bytes(b"hello world"); - }); - assert_eq!( - c1, c2, - "Identical operations must yield identical challenges" - ); - } - - #[test] - fn test_domain_separation() { - let c1 = challenge_after(b"protocol_a", |_| {}); - let c2 = challenge_after(b"protocol_b", |_| {}); - assert_ne!(c1, c2, "Different labels must produce different challenges"); - } - - #[test] - fn test_challenge_uniqueness() { - let mut transcript = <$transcript_type>::new(b"uniqueness_test"); - let mut challenges = HashSet::new(); - - for i in 0..10_000 { - let c = transcript.challenge(); - assert!( - challenges.insert(c), - "Duplicate challenge found at iteration {i}" - ); - } - } - - #[test] - fn test_append_changes_state() { - let baseline = challenge_after(b"mutation_test", |_| {}); - let after_append = challenge_after(b"mutation_test", |t| { - t.append_bytes(&1u64.to_be_bytes()); - }); - assert_ne!( - baseline, after_append, - "append must change observable challenge" - ); - } - - #[test] - fn test_order_matters() { - let c1 = challenge_after(b"order_test", |t| { - t.append_bytes(&1u64.to_be_bytes()); - t.append_bytes(&2u64.to_be_bytes()); - }); - let c2 = challenge_after(b"order_test", |t| { - t.append_bytes(&2u64.to_be_bytes()); - t.append_bytes(&1u64.to_be_bytes()); - }); - assert_ne!(c1, c2, "Order of appends must affect challenge"); - } - - #[test] - fn test_data_sensitivity() { - let c1 = challenge_after(b"data_test", |t| { - t.append_bytes(&0u64.to_be_bytes()); - }); - let c2 = challenge_after(b"data_test", |t| { - t.append_bytes(&1u64.to_be_bytes()); - }); - assert_ne!(c1, c2, "Different data must produce different challenges"); - } - - #[test] - fn test_empty_bytes() { - let baseline = challenge_after(b"empty_test", |_| {}); - let with_empty = challenge_after(b"empty_test", |t| { - t.append_bytes(&[]); - }); - assert_ne!( - baseline, with_empty, - "append_bytes(&[]) must observably change challenge" - ); - // Determinism for empty appends. - let with_empty_again = challenge_after(b"empty_test", |t| { - t.append_bytes(&[]); - }); - assert_eq!(with_empty, with_empty_again); - } - - #[test] - fn test_large_data() { - let mut transcript = <$transcript_type>::new(b"large_data_test"); - let large_data = vec![0xABu8; 10_000]; - - transcript.append_bytes(&large_data); - let _ = transcript.challenge(); - } - - #[test] - fn test_prover_verifier_consistency() { - let mut prover = <$transcript_type>::new(b"protocol"); - prover.append_bytes(&42u64.to_be_bytes()); - prover.append_bytes(b"commitment"); - let prover_challenge = prover.challenge(); - - let mut verifier = <$transcript_type>::new(b"protocol"); - verifier.append_bytes(&42u64.to_be_bytes()); - verifier.append_bytes(b"commitment"); - let verifier_challenge = verifier.challenge(); - - assert_eq!( - prover_challenge, verifier_challenge, - "Prover and verifier must derive identical challenges" - ); - } - - #[test] - fn test_default_delegates_to_new() { - let mut default_transcript = <$transcript_type>::default(); - let mut new_transcript = <$transcript_type>::new(b""); - assert_eq!( - default_transcript.challenge(), - new_transcript.challenge(), - "Default must delegate to new(b\"\")" - ); - } - - #[test] - #[should_panic(expected = "label must be at most 32 bytes")] - fn test_label_too_long() { - let long_label: &[u8; 33] = &[b'x'; 33]; - let _ = <$transcript_type>::new(long_label); - } - - #[test] - fn test_max_valid_label() { - let max_label: &[u8; 32] = &[b'L'; 32]; - let mut t1 = <$transcript_type>::new(max_label); - let mut t2 = <$transcript_type>::new(max_label); - assert_eq!(t1.challenge(), t2.challenge()); - } - - #[test] - fn test_challenge_vector() { - let mut transcript = <$transcript_type>::new(b"vector_test"); - let challenges = transcript.challenge_vector(5); - - assert_eq!(challenges.len(), 5); - - let unique: HashSet<_> = challenges.iter().collect(); - assert_eq!(unique.len(), 5, "All challenges in vector should be unique"); - } - }; -} diff --git a/crates/jolt-transcript/tests/keccak_tests.rs b/crates/jolt-transcript/tests/keccak_tests.rs deleted file mode 100644 index ce68cc6dca..0000000000 --- a/crates/jolt-transcript/tests/keccak_tests.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! Tests for KeccakTranscript implementation. - -mod common; - -use jolt_field::Fr; -use jolt_transcript::KeccakTranscript; - -type Kec = KeccakTranscript; - -transcript_tests!(Kec); - -#[test] -fn test_keccak_known_vector() { - use ark_ff::PrimeField; - use jolt_transcript::Transcript; - - let mut transcript = KeccakTranscript::::new(b"Jolt"); - transcript.append_bytes(&12345u64.to_be_bytes()); - let challenge: Fr = transcript.challenge(); - - // Pinned wire-format check; see Blake2b counterpart for rationale. - let expected: ark_bn254::Fr = ark_bn254::Fr::from_le_bytes_mod_order(&[ - 0x2E, 0xDF, 0x34, 0x68, 0x85, 0xEE, 0x1C, 0x8B, 0xEC, 0xBD, 0x68, 0xA6, 0x3E, 0x23, 0x00, - 0x9F, 0x10, 0x00, 0xBC, 0xA3, 0xC4, 0xBA, 0x1C, 0xF4, 0x63, 0xDC, 0x84, 0x8D, 0x45, 0xD9, - 0xDD, 0x1E, - ]); - let got: ark_bn254::Fr = challenge.into(); - assert_eq!(got, expected, "Keccak known-vector regression"); -} diff --git a/crates/jolt-transcript/tests/native_traits_tests.rs b/crates/jolt-transcript/tests/native_traits_tests.rs index fe66a8f0ed..58e3201b2e 100644 --- a/crates/jolt-transcript/tests/native_traits_tests.rs +++ b/crates/jolt-transcript/tests/native_traits_tests.rs @@ -3,6 +3,8 @@ //! `transcript_tests!` macro exercises only the `legacy::Transcript` //! facade. +// Poseidon has no `OptimizedChallenge` (128-bit) tests — it uses full-field +// `challenge-254-bit` (#1586 reviewer) — so this file only covers the byte sponges. #![cfg(any(feature = "transcript-blake2b", feature = "transcript-keccak"))] #![expect(clippy::expect_used, reason = "tests")] @@ -96,3 +98,7 @@ mod keccak { verifier.check_eof().expect("eof"); } } + +// No Poseidon `OptimizedChallenge` tests: `challenge_128` is `unimplemented!()` for +// the Poseidon sponge (#1586 reviewer — `transcript-poseidon` uses full-field +// `challenge-254-bit`; 128-bit truncation defeats Poseidon's recursion purpose). diff --git a/crates/jolt-transcript/tests/poseidon_field_aligned.rs b/crates/jolt-transcript/tests/poseidon_field_aligned.rs new file mode 100644 index 0000000000..6784d3da25 --- /dev/null +++ b/crates/jolt-transcript/tests/poseidon_field_aligned.rs @@ -0,0 +1,338 @@ +//! Field-aligned Poseidon transcript tests (spec §4): encoding injectivity, +//! tag domain-split, GT chunking, and prover/verifier NARG roundtrip with +//! challenge agreement through the REAL `ProverState`/`VerifierState` +//! factories. + +#![cfg(feature = "transcript-poseidon")] +#![expect( + clippy::unwrap_used, + reason = "test code; failures should panic loudly" +)] + +use ark_bn254::Fr; +use ark_ff::Zero; +use jolt_field::Fr as JoltFr; +use jolt_transcript::{ + poseidon_prover_transcript, poseidon_verifier_transcript, prover_transcript, + push_byte_rule_units, serialize_slice, verifier_transcript, CommitmentsMsg, + DuplexSpongeInterface, Encoding, FieldFrameMsg, FsAbsorb, NativeChallenge, PoseidonSponge, + ProverTranscript, RawBytesMsg, VerifierTranscript, +}; + +/// A Dory-GT-shaped commitment stand-in: 48 × u64 = 384 canonical bytes, +/// no length prefix (ark serializes fixed-size arrays without one). +type FakeGt = [u64; 48]; + +const SESSION: &[u8] = b"jolt-poseidon-field-aligned-test/v1"; + +fn squeeze1(s: &mut PoseidonSponge) -> Fr { + let mut out = [Fr::zero(); 1]; + let _ = s.squeeze(&mut out); + out[0] +} + +fn absorb_msg>(s: &mut PoseidonSponge, msg: &T) { + let _ = s.absorb(msg.encode().as_ref()); +} + +/// `absorb([a, b])` must differ from `absorb([a]) ; absorb([b])` — the +/// count-led tag binds message boundaries. +#[test] +fn field_frame_message_boundaries_bind() { + let (a, b) = (Fr::from(123u64), Fr::from(456u64)); + let mut s1 = PoseidonSponge::new(); + absorb_msg(&mut s1, &FieldFrameMsg(vec![a, b])); + let mut s2 = PoseidonSponge::new(); + absorb_msg(&mut s2, &FieldFrameMsg(vec![a])); + absorb_msg(&mut s2, &FieldFrameMsg(vec![b])); + assert_ne!(squeeze1(&mut s1), squeeze1(&mut s2)); +} + +/// An empty field frame (`[Fr(1), 0]`) must evolve the state differently +/// than a squeeze refill (`permute(0,0)`). +#[test] +fn empty_field_frame_distinct_from_squeeze_refill() { + let mut s1 = PoseidonSponge::new(); + absorb_msg(&mut s1, &FieldFrameMsg(vec![])); + let after_empty_frame = squeeze1(&mut s1); + + let mut s2 = PoseidonSponge::new(); + let _ = squeeze1(&mut s2); // permute(0,0) — what an aliasing empty frame would be + let second_squeeze = squeeze1(&mut s2); + + assert_ne!(after_empty_frame, second_squeeze); +} + +/// Tag domain-split: a byte message and a field frame with IDENTICAL payload +/// units must diverge purely on the `2L` (even) vs `2k+1` (odd) leading tag. +#[test] +fn byte_message_and_field_frame_tag_split() { + // 62-byte payload = two 31-byte chunks decoding to units u1, u2. + let mut payload = vec![0u8; 62]; + payload[0] = 17; // chunk 1 ↦ Fr(17) + payload[31] = 99; // chunk 2 ↦ Fr(99) + let byte_units = RawBytesMsg(payload).encode().as_ref().to_vec(); + let frame_units = FieldFrameMsg(vec![Fr::from(17u64), Fr::from(99u64)]) + .encode() + .as_ref() + .to_vec(); + + // Both encode to 4 units (tag, u1, u2, pad); only the tags differ. + assert_eq!(byte_units.len(), 4); + assert_eq!(frame_units.len(), 4); + assert_eq!(byte_units[1..], frame_units[1..]); + assert_eq!(byte_units[0], Fr::from(124u64)); // 2 · 62 + assert_eq!(frame_units[0], Fr::from(5u64)); // 2 · 2 + 1 + + let mut s1 = PoseidonSponge::new(); + let _ = s1.absorb(&byte_units); + let mut s2 = PoseidonSponge::new(); + let _ = s2.absorb(&frame_units); + assert_ne!(squeeze1(&mut s1), squeeze1(&mut s2)); +} + +/// A GT-sized (384-byte) payload chunks to exactly 14 units: +/// `[Fr(2·384), 12 × 31-byte chunks, 1 × 12-byte chunk]` — already even, +/// no padding. +#[test] +fn gt_sized_payload_chunks_to_exactly_14_units() { + let mut units = Vec::new(); + push_byte_rule_units(&mut units, &[0xA5u8; 384]); + assert_eq!(units.len(), 14); + assert_eq!(units[0], Fr::from(768u64)); + + // A commitments frame of k GTs leads with the count pair + // `[Fr(2k+1), 0]`, then k pair-aligned 14-unit tag-led groups. + let gts: Vec = vec![[7u64; 48], [9u64; 48]]; + let frame_units = CommitmentsMsg(gts).encode().as_ref().to_vec(); + assert_eq!(frame_units.len(), 30); + assert_eq!(frame_units[0], Fr::from(5u64)); // 2 · 2 + 1 + assert_eq!(frame_units[1], Fr::zero()); // count padded to a permute pair + assert_eq!(frame_units[2], Fr::from(768u64)); + assert_eq!(frame_units[16], Fr::from(768u64)); + + // The empty commitments frame stays count-led: `[Fr(1), pad]`, distinct + // from the empty byte message's `Fr(0)` tag. + let empty_units = CommitmentsMsg::(vec![]).encode().as_ref().to_vec(); + assert_eq!(empty_units, vec![Fr::from(1u64), Fr::zero()]); +} + +/// The frame-level count unit binds the partition of commitments into +/// frames: `[c1,c2] + [c3]` and `[c1] + [c2,c3]` carry the same units in the +/// same order at the group level, so without the count they would alias and +/// a NARG malleation re-partitioning adjacent frames would leave every +/// challenge unchanged. +#[test] +fn commitment_frame_repartition_diverges_challenges() { + let instance = [0x33u8; 32]; + let (c1, c2, c3): (FakeGt, FakeGt, FakeGt) = ([1u64; 48], [2u64; 48], [3u64; 48]); + + let mut a = poseidon_prover_transcript(SESSION, instance, PoseidonSponge::default()); + ProverTranscript::::public_message(&mut a, &CommitmentsMsg(vec![c1, c2])); + ProverTranscript::::public_message(&mut a, &CommitmentsMsg(vec![c3])); + let ca: NativeChallenge = ProverTranscript::::verifier_message(&mut a); + + let mut b = poseidon_prover_transcript(SESSION, instance, PoseidonSponge::default()); + ProverTranscript::::public_message(&mut b, &CommitmentsMsg(vec![c1])); + ProverTranscript::::public_message(&mut b, &CommitmentsMsg(vec![c2, c3])); + let cb: NativeChallenge = ProverTranscript::::verifier_message(&mut b); + + assert_ne!(ca.0, cb.0, "re-partitioned commitment frames must diverge"); +} + +/// Mixed absorb/write/squeeze schedule through the REAL factories: the +/// verifier reads every frame back from the NARG, all challenges agree, the +/// NARG bytes are exactly the byte-sponge framing (8-byte LE length ‖ +/// compressed payload), and `check_eof` passes. +#[test] +fn narg_roundtrip_mixed_schedule_challenges_agree() { + let instance = [0x42u8; 32]; + let scalars: Vec = (1..=5).map(|i| Fr::from(i * 31 + 7u64)).collect(); + let gts: Vec = vec![[3u64; 48], [0xFFFF_FFFF_FFFF_FFFFu64; 48]]; + + let mut p = poseidon_prover_transcript(SESSION, instance, PoseidonSponge::default()); + FsAbsorb::absorb_scalar(&mut p, &Fr::from(2026u64)); + ProverTranscript::::prover_message(&mut p, &FieldFrameMsg(scalars.clone())); + let p_c1: NativeChallenge = ProverTranscript::::verifier_message(&mut p); + ProverTranscript::::prover_message(&mut p, &CommitmentsMsg(gts.clone())); + ProverTranscript::::prover_message(&mut p, &CommitmentsMsg::(vec![])); + FsAbsorb::absorb_commitment(&mut p, >s[0]); + FsAbsorb::absorb_scalars(&mut p, &scalars); + let p_c2: NativeChallenge = ProverTranscript::::verifier_message(&mut p); + let narg = ProverTranscript::::narg_string(&p).to_vec(); + + // The NARG transport is byte-identical to the byte-sponge `BytesMsg` + // framing of the same frames. + let mut expected = Vec::new(); + for body in [serialize_slice(&scalars), serialize_slice(>s), Vec::new()] { + expected.extend_from_slice(&(body.len() as u64).to_le_bytes()); + expected.extend_from_slice(&body); + } + assert_eq!(narg, expected, "NARG framing changed"); + + let mut v = poseidon_verifier_transcript(SESSION, instance, PoseidonSponge::default(), &narg); + FsAbsorb::absorb_scalar(&mut v, &Fr::from(2026u64)); + let frame: FieldFrameMsg = + VerifierTranscript::::prover_message(&mut v).unwrap(); + assert_eq!(frame.0, scalars, "scalar frame reconstructed incorrectly"); + let v_c1: NativeChallenge = VerifierTranscript::::verifier_message(&mut v); + let read_gts: CommitmentsMsg = + VerifierTranscript::::prover_message(&mut v).unwrap(); + assert_eq!( + read_gts.0, gts, + "commitments frame reconstructed incorrectly" + ); + let presence: CommitmentsMsg = + VerifierTranscript::::prover_message(&mut v).unwrap(); + assert!(presence.0.is_empty(), "presence frame must be empty"); + FsAbsorb::absorb_commitment(&mut v, >s[0]); + FsAbsorb::absorb_scalars(&mut v, &scalars); + let v_c2: NativeChallenge = VerifierTranscript::::verifier_message(&mut v); + VerifierTranscript::::check_eof(v).unwrap(); + + assert_eq!(p_c1.0, v_c1.0, "mid-schedule challenge diverged"); + assert_eq!(p_c2.0, v_c2.0, "final challenge diverged"); +} + +/// FIX #9 regression: on Poseidon, `absorb_field`/`absorb_field_slice` must +/// emit the SAME count-led field frame as `absorb_scalar`/`absorb_scalars` +/// (the byte-sponge trait defaults would diverge to the byte rule). No prover +/// in the gated suite calls `absorb_field` on Poseidon, so this equivalence is +/// otherwise unexercised — lock it here. `absorb_field` bounds on +/// `jolt_field::Field` ([`JoltFr`]); `absorb_scalar` on `CanonicalSerialize` +/// (`ark_bn254::Fr`); the same value through both must yield one challenge. +#[test] +fn absorb_field_matches_absorb_scalar_on_poseidon() { + let instance = [0x9au8; 32]; + let n: u64 = 0x1234_5678_9abc_def0; + + let mut a = poseidon_prover_transcript(SESSION, instance, PoseidonSponge::default()); + FsAbsorb::absorb_field(&mut a, &JoltFr::from(n)); + let ca: NativeChallenge = ProverTranscript::::verifier_message(&mut a); + + let mut b = poseidon_prover_transcript(SESSION, instance, PoseidonSponge::default()); + FsAbsorb::absorb_scalar(&mut b, &Fr::from(n)); + let cb: NativeChallenge = ProverTranscript::::verifier_message(&mut b); + + assert_eq!( + ca.0, cb.0, + "absorb_field diverged from absorb_scalar on Poseidon" + ); + + let js: Vec = (1..=4).map(|i| JoltFr::from(i * 97 + 5u64)).collect(); + let xs: Vec = (1..=4).map(|i| Fr::from(i * 97 + 5u64)).collect(); + + let mut c = poseidon_prover_transcript(SESSION, instance, PoseidonSponge::default()); + FsAbsorb::absorb_field_slice(&mut c, &js); + let cc: NativeChallenge = ProverTranscript::::verifier_message(&mut c); + + let mut d = poseidon_prover_transcript(SESSION, instance, PoseidonSponge::default()); + FsAbsorb::absorb_scalars(&mut d, &xs); + let cd: NativeChallenge = ProverTranscript::::verifier_message(&mut d); + + assert_eq!( + cc.0, cd.0, + "absorb_field_slice diverged from absorb_scalars on Poseidon" + ); +} + +/// The unit-generic `prover_transcript`/`verifier_transcript` factories +/// dispatch to the Poseidon-specific ones for `PoseidonSponge`. +#[test] +fn generic_factories_dispatch_to_poseidon_path() { + let instance = [0x07u8; 32]; + + let mut a = prover_transcript(SESSION, instance, PoseidonSponge::default()); + let mut b = poseidon_prover_transcript(SESSION, instance, PoseidonSponge::default()); + let ca: NativeChallenge = ProverTranscript::::verifier_message(&mut a); + let cb: NativeChallenge = ProverTranscript::::verifier_message(&mut b); + assert_eq!(ca.0, cb.0); + + let v = verifier_transcript(SESSION, instance, PoseidonSponge::default(), &[]); + let mut v = v; + let cv: NativeChallenge = VerifierTranscript::::verifier_message(&mut v); + assert_eq!(cv.0, ca.0, "prover and verifier domain separators diverged"); + VerifierTranscript::::check_eof(v).unwrap(); +} + +/// Different sessions / instances diverge the transcript. +#[test] +fn domain_separator_binds_session_and_instance() { + let mut base = poseidon_prover_transcript(SESSION, [1u8; 32], PoseidonSponge::default()); + let mut other_session = + poseidon_prover_transcript(b"other-session", [1u8; 32], PoseidonSponge::default()); + let mut other_instance = + poseidon_prover_transcript(SESSION, [2u8; 32], PoseidonSponge::default()); + + let c0: NativeChallenge = ProverTranscript::::verifier_message(&mut base); + let c1: NativeChallenge = + ProverTranscript::::verifier_message(&mut other_session); + let c2: NativeChallenge = + ProverTranscript::::verifier_message(&mut other_instance); + assert_ne!(c0.0, c1.0); + assert_ne!(c0.0, c2.0); +} + +/// `FieldFrameMsg` NARG reads reject non-canonical (≥ r) elements and bodies +/// that are not a multiple of 32 bytes — mirroring the native `read_all` +/// strictness — and reject truncation without advancing the cursor. +#[test] +fn field_frame_narg_rejects_malformed_bodies() { + use jolt_transcript::NargDeserialize; + + // Non-canonical element (0xFF…FF ≥ r). + let mut narg = 32u64.to_le_bytes().to_vec(); + narg.extend_from_slice(&[0xFF; 32]); + let mut cursor: &[u8] = &narg; + assert!(FieldFrameMsg::deserialize_from_narg(&mut cursor).is_err()); + assert_eq!(cursor.len(), narg.len(), "cursor must not advance on error"); + + // Body length not a multiple of 32. + let mut narg = 31u64.to_le_bytes().to_vec(); + narg.extend_from_slice(&[0u8; 31]); + let mut cursor: &[u8] = &narg; + assert!(FieldFrameMsg::deserialize_from_narg(&mut cursor).is_err()); + + // Truncated body. + let mut narg = 64u64.to_le_bytes().to_vec(); + narg.extend_from_slice(&[0u8; 32]); + let mut cursor: &[u8] = &narg; + assert!(FieldFrameMsg::deserialize_from_narg(&mut cursor).is_err()); +} + +/// The typed `FsAbsorb` methods and the raw message encodings agree: the +/// vocabulary surface and the codec layer cannot drift. +#[test] +fn typed_absorb_methods_match_message_encodings() { + let instance = [0x55u8; 32]; + let x = Fr::from(40_961u64); + + // absorb_scalar == public_message(FieldFrameMsg([x])) + let mut a = poseidon_prover_transcript(SESSION, instance, PoseidonSponge::default()); + FsAbsorb::absorb_scalar(&mut a, &x); + let mut b = poseidon_prover_transcript(SESSION, instance, PoseidonSponge::default()); + ProverTranscript::::public_message(&mut b, &FieldFrameMsg(vec![x])); + let ca: NativeChallenge = ProverTranscript::::verifier_message(&mut a); + let cb: NativeChallenge = ProverTranscript::::verifier_message(&mut b); + assert_eq!(ca.0, cb.0); + + // absorb_commitment == the byte rule over the compressed serialization + // (one per-GT group; a lone commitment absorb carries no frame count, so + // it is NOT a singleton `CommitmentsMsg` frame). + let gt: FakeGt = [11u64; 48]; + let mut c = poseidon_prover_transcript(SESSION, instance, PoseidonSponge::default()); + FsAbsorb::absorb_commitment(&mut c, >); + let mut d = poseidon_prover_transcript(SESSION, instance, PoseidonSponge::default()); + ProverTranscript::::public_message( + &mut d, + &RawBytesMsg(serialize_slice(&[gt])), + ); + let cc: NativeChallenge = ProverTranscript::::verifier_message(&mut c); + let cd: NativeChallenge = ProverTranscript::::verifier_message(&mut d); + assert_eq!(cc.0, cd.0); + + let mut e = poseidon_prover_transcript(SESSION, instance, PoseidonSponge::default()); + ProverTranscript::::public_message(&mut e, &CommitmentsMsg(vec![gt])); + let ce: NativeChallenge = ProverTranscript::::verifier_message(&mut e); + assert_ne!(cc.0, ce.0, "a commitments frame leads with a count unit"); +} diff --git a/crates/jolt-transcript/tests/poseidon_tests.rs b/crates/jolt-transcript/tests/poseidon_tests.rs deleted file mode 100644 index d0008ce101..0000000000 --- a/crates/jolt-transcript/tests/poseidon_tests.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! Tests for PoseidonTranscript implementation. - -mod common; - -use jolt_field::Fr; -use jolt_transcript::PoseidonTranscript; - -type Pos = PoseidonTranscript; - -transcript_tests!(Pos); - -#[test] -fn test_poseidon_known_vector() { - use ark_ff::PrimeField; - use jolt_transcript::Transcript; - - let mut transcript = PoseidonTranscript::::new(b"Jolt"); - transcript.append_bytes(&12345u64.to_be_bytes()); - let challenge: Fr = transcript.challenge(); - - // Pinned wire-format check; see Blake2b counterpart for rationale. - let expected: ark_bn254::Fr = ark_bn254::Fr::from_le_bytes_mod_order(&[ - 0xF7, 0x54, 0x3B, 0x32, 0x71, 0x47, 0x68, 0xEE, 0x04, 0x09, 0xEC, 0xAB, 0x9B, 0x91, 0x2E, - 0x8A, 0xD0, 0x51, 0x7E, 0x7C, 0x6E, 0xB2, 0xB8, 0x77, 0x52, 0x59, 0x2B, 0x10, 0x38, 0x67, - 0x78, 0x06, - ]); - let got: ark_bn254::Fr = challenge.into(); - assert_eq!(got, expected, "Poseidon known-vector regression"); -} diff --git a/crates/jolt-verifier/src/compat/audit.rs b/crates/jolt-verifier/src/compat/audit.rs index 14766ec0d1..35608bdeec 100644 --- a/crates/jolt-verifier/src/compat/audit.rs +++ b/crates/jolt-verifier/src/compat/audit.rs @@ -1,11 +1,12 @@ //! Compatibility audits for imported `jolt-core` ZK proof artifacts. +use ark_serialize::CanonicalSerialize; use common::jolt_device::JoltDevice; use jolt_blindfold::BlindFoldProtocol; use jolt_crypto::{HomomorphicCommitment, VectorCommitment}; use jolt_field::Field; use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme, ZkOpeningScheme}; -use jolt_transcript::{AppendToTranscript, Transcript}; +use jolt_transcript::{verifier_transcript, DuplexSpongeInterface, VerifierState}; use crate::{ config::{validate_proof_config, JoltProtocolConfig}, @@ -49,21 +50,22 @@ impl ZkBlindFoldProtocolShape { } } -pub fn audit_zk_blindfold_protocol_shape( +pub fn audit_zk_blindfold_protocol_shape( preprocessing: &JoltVerifierPreprocessing, public_io: &JoltDevice, proof: &JoltProof, trusted_advice_commitment: Option<&PCS::Output>, ) -> Result where - F: Field + AppendToTranscript, + F: Field, PCS: CommitmentScheme + AdditivelyHomomorphic + ZkOpeningScheme, - PCS::Output: AppendToTranscript + HomomorphicCommitment, + PCS::Output: CanonicalSerialize + HomomorphicCommitment, VC: VectorCommitment, - VC::Output: Copy + HomomorphicCommitment + AppendToTranscript, - T: Transcript, + VC::Output: Copy + HomomorphicCommitment + CanonicalSerialize, + H: DuplexSpongeInterface + Default, + for<'a> VerifierState<'a, H>: jolt_transcript::FsTranscript, { let config = JoltProtocolConfig::for_zk(true); validate_proof_config(&config, proof)?; @@ -77,7 +79,7 @@ where )?; validate_proof_consistency(proof, true)?; - let mut transcript = T::new(b"Jolt"); + let mut transcript = verifier_transcript(b"Jolt", [0u8; 32], H::default(), &[]); absorb_preamble(&checked, proof, &mut transcript); absorb_commitments(proof, trusted_advice_commitment, &mut transcript); diff --git a/crates/jolt-verifier/src/compat/convert.rs b/crates/jolt-verifier/src/compat/convert.rs index 10c35c6ec7..80ce04be33 100644 --- a/crates/jolt-verifier/src/compat/convert.rs +++ b/crates/jolt-verifier/src/compat/convert.rs @@ -117,7 +117,7 @@ where >; type VerifierRoundCommitment: Copy + HomomorphicCommitment - + jolt_transcript::AppendToTranscript + + ark_serialize::CanonicalSerialize + serde::Serialize + serde::de::DeserializeOwned; diff --git a/crates/jolt-verifier/src/stages/stage1/verify.rs b/crates/jolt-verifier/src/stages/stage1/verify.rs index 99c99f9f72..d0dd995a11 100644 --- a/crates/jolt-verifier/src/stages/stage1/verify.rs +++ b/crates/jolt-verifier/src/stages/stage1/verify.rs @@ -10,9 +10,8 @@ use jolt_r1cs::constraints::jolt::{ }; use jolt_sumcheck::{ BatchedSumcheckVerifier, CenteredIntegerDomain, SumcheckClaim, SumcheckStatement, - UNISKIP_ROUND_TRANSCRIPT_LABEL, }; -use jolt_transcript::Transcript; +use jolt_transcript::FsTranscript; use super::inputs::spartan_outer_opening_order; use super::outputs::{ @@ -33,7 +32,7 @@ pub fn verify( where PCS: CommitmentScheme, VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { let stage = JoltRelationId::SpartanOuter; @@ -100,7 +99,6 @@ where uniskip_input_claim, ), CenteredIntegerDomain::new(domain_size), - UNISKIP_ROUND_TRANSCRIPT_LABEL, transcript, ) .map_err(|error| VerifierError::StageClaimSumcheckFailed { @@ -119,7 +117,7 @@ where // Core absorbs the uni-skip output as an opening claim before deriving // the batching challenge for the remainder sumcheck. - transcript.append_labeled(b"opening_claim", &uniskip.expected_output_claim); + transcript.absorb_field(&uniskip.expected_output_claim); let [uniskip_challenge] = uniskip.sumcheck_point.as_slice() else { return Err(VerifierError::StageClaimSumcheckFailed { @@ -243,7 +241,7 @@ where expected_output_claim: expected_remainder_output_claim, }; for opening_claim in &r1cs_input_claims { - transcript.append_labeled(b"opening_claim", opening_claim); + transcript.absorb_field(opening_claim); } let remainder_challenges = remainder.sumcheck_point.as_slice().to_vec(); diff --git a/crates/jolt-verifier/src/stages/stage2/verify.rs b/crates/jolt-verifier/src/stages/stage2/verify.rs index d011c4b758..a768820c2f 100644 --- a/crates/jolt-verifier/src/stages/stage2/verify.rs +++ b/crates/jolt-verifier/src/stages/stage2/verify.rs @@ -30,9 +30,8 @@ use jolt_r1cs::constraints::jolt::{ }; use jolt_sumcheck::{ BatchedSumcheckVerifier, CenteredIntegerDomain, SumcheckClaim, SumcheckStatement, - UNISKIP_ROUND_TRANSCRIPT_LABEL, }; -use jolt_transcript::Transcript; +use jolt_transcript::FsTranscript; use super::{ inputs::{Deps, Stage2BatchOutputOpeningClaims}, @@ -165,7 +164,7 @@ pub fn verify( where PCS: CommitmentScheme, VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { match (checked.zk, deps) { (true, Deps::Clear { .. }) => { @@ -269,7 +268,7 @@ fn verify_product_uniskip( where PCS: CommitmentScheme, VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { let stage = JoltRelationId::SpartanProductVirtualization; let log_t = checked.trace_length.ilog2() as usize; @@ -342,7 +341,6 @@ where uniskip_input_claim, ), CenteredIntegerDomain::new(domain_size), - UNISKIP_ROUND_TRANSCRIPT_LABEL, transcript, ) .map_err(|error| VerifierError::StageClaimSumcheckFailed { @@ -353,7 +351,7 @@ where return Err(VerifierError::StageClaimOutputMismatch { stage }); } - transcript.append_labeled(b"opening_claim", &uniskip_claim); + transcript.absorb_field(&uniskip_claim); Ok(Stage2ProductUniSkip::Clear(VerifiedProductUniSkip { tau_low, @@ -414,7 +412,7 @@ fn verify_regular_batch( where PCS: CommitmentScheme, VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { let log_t = checked.trace_length.ilog2() as usize; let log_k = checked.ram_K.ilog2() as usize; @@ -1055,81 +1053,52 @@ where }); } - transcript.append_labeled(b"opening_claim", &claims.batch_outputs.ram_read_write.val); - transcript.append_labeled(b"opening_claim", &claims.batch_outputs.ram_read_write.ra); - transcript.append_labeled(b"opening_claim", &claims.batch_outputs.ram_read_write.inc); - transcript.append_labeled( - b"opening_claim", + transcript.absorb_field(&claims.batch_outputs.ram_read_write.val); + transcript.absorb_field(&claims.batch_outputs.ram_read_write.ra); + transcript.absorb_field(&claims.batch_outputs.ram_read_write.inc); + transcript.absorb_field( &claims .batch_outputs .product_remainder .left_instruction_input, ); - transcript.append_labeled( - b"opening_claim", + transcript.absorb_field( &claims .batch_outputs .product_remainder .right_instruction_input, ); - transcript.append_labeled( - b"opening_claim", - &claims.batch_outputs.product_remainder.jump_flag, - ); - transcript.append_labeled( - b"opening_claim", + transcript.absorb_field(&claims.batch_outputs.product_remainder.jump_flag); + transcript.absorb_field( &claims .batch_outputs .product_remainder .write_lookup_output_to_rd, ); - transcript.append_labeled( - b"opening_claim", - &claims.batch_outputs.product_remainder.lookup_output, - ); - transcript.append_labeled( - b"opening_claim", - &claims.batch_outputs.product_remainder.branch_flag, - ); - transcript.append_labeled( - b"opening_claim", - &claims.batch_outputs.product_remainder.next_is_noop, - ); - transcript.append_labeled( - b"opening_claim", - &claims.batch_outputs.product_remainder.virtual_instruction, - ); + transcript.absorb_field(&claims.batch_outputs.product_remainder.lookup_output); + transcript.absorb_field(&claims.batch_outputs.product_remainder.branch_flag); + transcript.absorb_field(&claims.batch_outputs.product_remainder.next_is_noop); + transcript.absorb_field(&claims.batch_outputs.product_remainder.virtual_instruction); #[cfg(feature = "field-inline")] { - transcript.append_labeled( - b"opening_claim", - &claims.batch_outputs.field_inline.product.field_rs1_value, - ); - transcript.append_labeled( - b"opening_claim", - &claims.batch_outputs.field_inline.product.field_rs2_value, - ); - transcript.append_labeled( - b"opening_claim", - &claims.batch_outputs.field_inline.product.field_rd_value, - ); + transcript.absorb_field(&claims.batch_outputs.field_inline.product.field_rs1_value); + transcript.absorb_field(&claims.batch_outputs.field_inline.product.field_rs2_value); + transcript.absorb_field(&claims.batch_outputs.field_inline.product.field_rd_value); } - transcript.append_labeled( - b"opening_claim", + transcript.absorb_field( &claims .batch_outputs .instruction_claim_reduction .left_lookup_operand, ); - transcript.append_labeled( - b"opening_claim", + transcript.absorb_field( &claims .batch_outputs .instruction_claim_reduction .right_lookup_operand, ); - transcript.append_labeled(b"opening_claim", &claims.batch_outputs.ram_raf_evaluation); - transcript.append_labeled(b"opening_claim", &claims.batch_outputs.ram_output_check); + transcript.absorb_field(&claims.batch_outputs.ram_raf_evaluation); + transcript.absorb_field(&claims.batch_outputs.ram_output_check); Ok(Stage2Batch::Clear { verified: VerifiedStage2Batch { diff --git a/crates/jolt-verifier/src/stages/stage3/verify.rs b/crates/jolt-verifier/src/stages/stage3/verify.rs index 977e3c8e45..5859a69fe7 100644 --- a/crates/jolt-verifier/src/stages/stage3/verify.rs +++ b/crates/jolt-verifier/src/stages/stage3/verify.rs @@ -12,7 +12,7 @@ use jolt_field::Field; use jolt_openings::CommitmentScheme; use jolt_poly::{try_eq_mle, EqPlusOnePolynomial}; use jolt_sumcheck::{BatchedSumcheckVerifier, SumcheckClaim, SumcheckStatement}; -use jolt_transcript::Transcript; +use jolt_transcript::FsTranscript; use super::{ inputs::{Deps, Stage3Claims}, @@ -52,7 +52,7 @@ pub fn verify( where PCS: CommitmentScheme, VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { match (checked.zk, deps) { (true, Deps::Clear { .. }) => { @@ -440,39 +440,28 @@ where fn append_stage3_opening_claims(transcript: &mut T, claims: &Stage3Claims) where F: Field, - T: Transcript, + T: FsTranscript, { - transcript.append_labeled(b"opening_claim", &claims.shift.unexpanded_pc); - transcript.append_labeled(b"opening_claim", &claims.shift.pc); - transcript.append_labeled(b"opening_claim", &claims.shift.is_virtual); - transcript.append_labeled(b"opening_claim", &claims.shift.is_first_in_sequence); - transcript.append_labeled(b"opening_claim", &claims.shift.is_noop); - transcript.append_labeled( - b"opening_claim", - &claims.instruction_input.left_operand_is_rs1, - ); - transcript.append_labeled(b"opening_claim", &claims.instruction_input.rs1_value); - transcript.append_labeled( - b"opening_claim", - &claims.instruction_input.left_operand_is_pc, - ); - transcript.append_labeled( - b"opening_claim", - &claims.instruction_input.right_operand_is_rs2, - ); - transcript.append_labeled(b"opening_claim", &claims.instruction_input.rs2_value); - transcript.append_labeled( - b"opening_claim", - &claims.instruction_input.right_operand_is_imm, - ); - transcript.append_labeled(b"opening_claim", &claims.instruction_input.imm); - transcript.append_labeled( - b"opening_claim", - &claims.registers_claim_reduction.rd_write_value, - ); + transcript.absorb_field(&claims.shift.unexpanded_pc); + transcript.absorb_field(&claims.shift.pc); + transcript.absorb_field(&claims.shift.is_virtual); + transcript.absorb_field(&claims.shift.is_first_in_sequence); + transcript.absorb_field(&claims.shift.is_noop); + transcript.absorb_field(&claims.instruction_input.left_operand_is_rs1); + transcript.absorb_field(&claims.instruction_input.rs1_value); + transcript.absorb_field(&claims.instruction_input.left_operand_is_pc); + transcript.absorb_field(&claims.instruction_input.right_operand_is_rs2); + transcript.absorb_field(&claims.instruction_input.rs2_value); + transcript.absorb_field(&claims.instruction_input.right_operand_is_imm); + transcript.absorb_field(&claims.instruction_input.imm); + transcript.absorb_field(&claims.registers_claim_reduction.rd_write_value); } #[cfg(test)] +#[expect( + clippy::unwrap_used, + reason = "test recording transcript serializes into an in-memory Vec, which is infallible" +)] mod tests { use super::*; @@ -480,32 +469,42 @@ mod tests { InstructionInputOutputOpeningClaims, RegistersClaimReductionOutputOpeningClaims, SpartanShiftOutputOpeningClaims, }; - use jolt_field::{CanonicalBytes, FixedByteSize, Fr, FromPrimitiveInt}; - use jolt_transcript::Transcript; + use ark_serialize::CanonicalSerialize; + use jolt_field::{CanonicalBytes, Fr, FromPrimitiveInt}; + use jolt_transcript::{FsAbsorb, FsChallenge}; #[derive(Clone, Default)] struct RecordingTranscript { chunks: Vec>, - state: [u8; 32], } - impl Transcript for RecordingTranscript { - type Challenge = Fr; + impl FsAbsorb for RecordingTranscript { + fn absorb(&mut self, value: &T) { + let mut buf = Vec::with_capacity(value.compressed_size()); + value.serialize_compressed(&mut buf).unwrap(); + self.chunks.push(buf); + } - fn new(_label: &'static [u8]) -> Self { - Self::default() + fn absorb_slice(&mut self, values: &[T]) { + let mut buf = Vec::new(); + for v in values { + v.serialize_compressed(&mut buf).unwrap(); + } + self.chunks.push(buf); } - fn append_bytes(&mut self, bytes: &[u8]) { + fn absorb_bytes(&mut self, bytes: &[u8]) { self.chunks.push(bytes.to_vec()); } + } - fn challenge(&mut self) -> Self::Challenge { + impl FsChallenge for RecordingTranscript { + fn challenge(&mut self) -> Fr { Fr::from_u64(0) } - fn state(&self) -> [u8; 32] { - self.state + fn challenge_scalar(&mut self) -> Fr { + Fr::from_u64(0) } } @@ -535,7 +534,7 @@ mod tests { rs2_value: Fr::from_u64(16), }, }; - let mut transcript = RecordingTranscript::new(b"stage3-openings"); + let mut transcript = RecordingTranscript::default(); append_stage3_opening_claims(&mut transcript, &claims); @@ -554,28 +553,15 @@ mod tests { claims.instruction_input.imm, claims.registers_claim_reduction.rd_write_value, ]; - assert_eq!(transcript.chunks.len(), expected_payloads.len() * 2); + // Like jolt-core: each opening claim is absorbed as just its value (no label). + assert_eq!(transcript.chunks.len(), expected_payloads.len()); - let label = opening_claim_label(); for (index, expected_payload) in expected_payloads.into_iter().enumerate() { - assert_eq!(transcript.chunks[2 * index], label); - assert_eq!( - transcript.chunks[2 * index + 1], - scalar_bytes(expected_payload) - ); + assert_eq!(transcript.chunks[index], scalar_bytes(expected_payload)); } } - fn opening_claim_label() -> Vec { - let mut label = vec![0; 32]; - label[..b"opening_claim".len()].copy_from_slice(b"opening_claim"); - label - } - fn scalar_bytes(value: Fr) -> Vec { - let mut bytes = vec![0; Fr::NUM_BYTES]; - value.to_bytes_le(&mut bytes); - bytes.reverse(); - bytes + value.to_bytes_le_vec() } } diff --git a/crates/jolt-verifier/src/stages/stage4/verify.rs b/crates/jolt-verifier/src/stages/stage4/verify.rs index 12c9ede22c..d422fc34e0 100644 --- a/crates/jolt-verifier/src/stages/stage4/verify.rs +++ b/crates/jolt-verifier/src/stages/stage4/verify.rs @@ -17,7 +17,7 @@ use jolt_openings::CommitmentScheme; use jolt_poly::{block_selector_mle_msb, sparse_segments_mle_msb, try_eq_mle, LtPolynomial}; use jolt_program::preprocess::PublicInitialRam; use jolt_sumcheck::{BatchedSumcheckVerifier, SumcheckClaim, SumcheckStatement}; -use jolt_transcript::{LabelWithCount, Transcript}; +use jolt_transcript::{FsAbsorb, FsTranscript}; use super::{ inputs::{Deps, Stage4Claims}, @@ -64,7 +64,7 @@ pub fn verify( where PCS: CommitmentScheme, VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { let log_t = checked.trace_length.ilog2() as usize; let log_k = checked.ram_K.ilog2() as usize; @@ -758,7 +758,7 @@ fn append_stage4_opening_claims( ) -> Result<(), VerifierError> where F: Field, - T: Transcript, + T: FsTranscript, { if untrusted_advice_commitment_present { let id = ram::val_check_advice_opening(JoltAdviceKind::Untrusted); @@ -766,7 +766,7 @@ where .advice .untrusted .ok_or(VerifierError::MissingOpeningClaim { id })?; - transcript.append_labeled(b"opening_claim", &opening_claim); + transcript.absorb_field(&opening_claim); } if trusted_advice_commitment_present { let id = ram::val_check_advice_opening(JoltAdviceKind::Trusted); @@ -774,33 +774,36 @@ where .advice .trusted .ok_or(VerifierError::MissingOpeningClaim { id })?; - transcript.append_labeled(b"opening_claim", &opening_claim); + transcript.absorb_field(&opening_claim); } - transcript.append_labeled(b"opening_claim", &claims.registers_read_write.registers_val); - transcript.append_labeled(b"opening_claim", &claims.registers_read_write.rs1_ra); - transcript.append_labeled(b"opening_claim", &claims.registers_read_write.rs2_ra); - transcript.append_labeled(b"opening_claim", &claims.registers_read_write.rd_wa); - transcript.append_labeled(b"opening_claim", &claims.registers_read_write.rd_inc); + transcript.absorb_field(&claims.registers_read_write.registers_val); + transcript.absorb_field(&claims.registers_read_write.rs1_ra); + transcript.absorb_field(&claims.registers_read_write.rs2_ra); + transcript.absorb_field(&claims.registers_read_write.rd_wa); + transcript.absorb_field(&claims.registers_read_write.rd_inc); #[cfg(feature = "field-inline")] { let field_claims = &claims.field_inline.field_registers_read_write; - transcript.append_labeled(b"opening_claim", &field_claims.field_registers_val); - transcript.append_labeled(b"opening_claim", &field_claims.field_rs1_ra); - transcript.append_labeled(b"opening_claim", &field_claims.field_rs2_ra); - transcript.append_labeled(b"opening_claim", &field_claims.field_rd_wa); - transcript.append_labeled(b"opening_claim", &field_claims.field_rd_inc); + transcript.absorb_field(&field_claims.field_registers_val); + transcript.absorb_field(&field_claims.field_rs1_ra); + transcript.absorb_field(&field_claims.field_rs2_ra); + transcript.absorb_field(&field_claims.field_rd_wa); + transcript.absorb_field(&field_claims.field_rd_inc); } - transcript.append_labeled(b"opening_claim", &claims.ram_val_check.ram_ra); - transcript.append_labeled(b"opening_claim", &claims.ram_val_check.ram_inc); + transcript.absorb_field(&claims.ram_val_check.ram_ra); + transcript.absorb_field(&claims.ram_val_check.ram_inc); Ok(()) } -fn append_ram_val_check_gamma_domain_separator(transcript: &mut T) { - transcript.append(&LabelWithCount(b"ram_val_check_gamma", 0)); - transcript.append_bytes(&[]); +fn append_ram_val_check_gamma_domain_separator(transcript: &mut T) { + transcript.absorb_bytes(&[]); } #[cfg(test)] +#[expect( + clippy::unwrap_used, + reason = "test recording transcript serializes into an in-memory Vec, which is infallible" +)] mod tests { use super::*; @@ -812,38 +815,49 @@ mod tests { RamValCheckAdviceOpeningClaims, RamValCheckOutputOpeningClaims, RegistersReadWriteOutputOpeningClaims, }; - use jolt_field::{CanonicalBytes, FixedByteSize, Fr, FromPrimitiveInt}; + use ark_serialize::CanonicalSerialize; + use jolt_field::{CanonicalBytes, Fr, FromPrimitiveInt}; + use jolt_transcript::{FsAbsorb, FsChallenge}; #[derive(Clone, Default)] struct RecordingTranscript { chunks: Vec>, - state: [u8; 32], } - impl Transcript for RecordingTranscript { - type Challenge = Fr; + impl FsAbsorb for RecordingTranscript { + fn absorb(&mut self, value: &T) { + let mut buf = Vec::with_capacity(value.compressed_size()); + value.serialize_compressed(&mut buf).unwrap(); + self.chunks.push(buf); + } - fn new(_label: &'static [u8]) -> Self { - Self::default() + fn absorb_slice(&mut self, values: &[T]) { + let mut buf = Vec::new(); + for v in values { + v.serialize_compressed(&mut buf).unwrap(); + } + self.chunks.push(buf); } - fn append_bytes(&mut self, bytes: &[u8]) { + fn absorb_bytes(&mut self, bytes: &[u8]) { self.chunks.push(bytes.to_vec()); } + } - fn challenge(&mut self) -> Self::Challenge { + impl FsChallenge for RecordingTranscript { + fn challenge(&mut self) -> Fr { Fr::from_u64(0) } - fn state(&self) -> [u8; 32] { - self.state + fn challenge_scalar(&mut self) -> Fr { + Fr::from_u64(0) } } #[test] fn opening_claim_appends_follow_core_order_without_advice() { let claims = test_claims(); - let mut transcript = RecordingTranscript::new(b"stage4-openings"); + let mut transcript = RecordingTranscript::default(); let result = append_stage4_opening_claims(&mut transcript, false, false, &claims); assert!(result.is_ok(), "stage 4 openings should append: {result:?}"); @@ -876,15 +890,13 @@ mod tests { #[test] fn ram_val_check_gamma_domain_separator_matches_core_empty_bytes_append() { - let mut transcript = RecordingTranscript::new(b"stage4-gamma"); + let mut transcript = RecordingTranscript::default(); append_ram_val_check_gamma_domain_separator(&mut transcript); - assert_eq!(transcript.chunks.len(), 2); - let mut packed = vec![0; 32]; - packed[..b"ram_val_check_gamma".len()].copy_from_slice(b"ram_val_check_gamma"); - assert_eq!(transcript.chunks[0], packed); - assert!(transcript.chunks[1].is_empty()); + // Like jolt-core: no label, just the empty-bytes domain separator. + assert_eq!(transcript.chunks.len(), 1); + assert!(transcript.chunks[0].is_empty()); } fn test_claims() -> Stage4Claims { @@ -918,27 +930,14 @@ mod tests { } fn assert_opening_claim_payloads(transcript: &RecordingTranscript, expected: &[Fr]) { - assert_eq!(transcript.chunks.len(), expected.len() * 2); - let label = opening_claim_label(); + // Like jolt-core: each opening claim is absorbed as just its value (no label). + assert_eq!(transcript.chunks.len(), expected.len()); for (index, expected_payload) in expected.iter().copied().enumerate() { - assert_eq!(transcript.chunks[2 * index], label); - assert_eq!( - transcript.chunks[2 * index + 1], - scalar_bytes(expected_payload) - ); + assert_eq!(transcript.chunks[index], scalar_bytes(expected_payload)); } } - fn opening_claim_label() -> Vec { - let mut label = vec![0; 32]; - label[..b"opening_claim".len()].copy_from_slice(b"opening_claim"); - label - } - fn scalar_bytes(value: Fr) -> Vec { - let mut bytes = vec![0; Fr::NUM_BYTES]; - value.to_bytes_le(&mut bytes); - bytes.reverse(); - bytes + value.to_bytes_le_vec() } } diff --git a/crates/jolt-verifier/src/stages/stage5/verify.rs b/crates/jolt-verifier/src/stages/stage5/verify.rs index 234693e5cf..6701ae9b16 100644 --- a/crates/jolt-verifier/src/stages/stage5/verify.rs +++ b/crates/jolt-verifier/src/stages/stage5/verify.rs @@ -19,7 +19,7 @@ use jolt_poly::{ OperandSide, }; use jolt_sumcheck::{BatchedSumcheckVerifier, SumcheckClaim, SumcheckStatement}; -use jolt_transcript::Transcript; +use jolt_transcript::FsTranscript; use num_traits::Zero; use super::{ @@ -73,7 +73,7 @@ pub fn verify( where PCS: CommitmentScheme, VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { match (checked.zk, deps) { (true, Deps::Clear { .. }) => { @@ -910,25 +910,22 @@ where fn append_stage5_opening_claims(transcript: &mut T, claims: &Stage5Claims) where F: Field, - T: Transcript, + T: FsTranscript, { for opening_claim in &claims.instruction_read_raf.lookup_table_flags { - transcript.append_labeled(b"opening_claim", opening_claim); + transcript.absorb_field(opening_claim); } for opening_claim in &claims.instruction_read_raf.instruction_ra { - transcript.append_labeled(b"opening_claim", opening_claim); + transcript.absorb_field(opening_claim); } - transcript.append_labeled( - b"opening_claim", - &claims.instruction_read_raf.instruction_raf_flag, - ); - transcript.append_labeled(b"opening_claim", &claims.ram_ra_claim_reduction.ram_ra); - transcript.append_labeled(b"opening_claim", &claims.registers_val_evaluation.rd_inc); - transcript.append_labeled(b"opening_claim", &claims.registers_val_evaluation.rd_wa); + transcript.absorb_field(&claims.instruction_read_raf.instruction_raf_flag); + transcript.absorb_field(&claims.ram_ra_claim_reduction.ram_ra); + transcript.absorb_field(&claims.registers_val_evaluation.rd_inc); + transcript.absorb_field(&claims.registers_val_evaluation.rd_wa); #[cfg(feature = "field-inline")] { let field_claims = &claims.field_inline.field_registers_val_evaluation; - transcript.append_labeled(b"opening_claim", &field_claims.field_rd_inc); - transcript.append_labeled(b"opening_claim", &field_claims.field_rd_wa); + transcript.absorb_field(&field_claims.field_rd_inc); + transcript.absorb_field(&field_claims.field_rd_wa); } } diff --git a/crates/jolt-verifier/src/stages/stage6/verify.rs b/crates/jolt-verifier/src/stages/stage6/verify.rs index aef007c2e4..144b18c5a2 100644 --- a/crates/jolt-verifier/src/stages/stage6/verify.rs +++ b/crates/jolt-verifier/src/stages/stage6/verify.rs @@ -19,7 +19,7 @@ use jolt_openings::CommitmentScheme; use jolt_poly::try_eq_mle; use jolt_riscv::NUM_CIRCUIT_FLAGS; use jolt_sumcheck::{BatchedSumcheckVerifier, SumcheckClaim, SumcheckStatement}; -use jolt_transcript::Transcript; +use jolt_transcript::FsTranscript; use num_traits::{One, Zero}; use super::{ @@ -78,7 +78,7 @@ pub fn verify( where PCS: CommitmentScheme, VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { match (checked.zk, deps) { (true, Deps::Clear { .. }) => { diff --git a/crates/jolt-verifier/src/stages/stage6/verify_a.rs b/crates/jolt-verifier/src/stages/stage6/verify_a.rs index aa937fd6ae..299a62b0e9 100644 --- a/crates/jolt-verifier/src/stages/stage6/verify_a.rs +++ b/crates/jolt-verifier/src/stages/stage6/verify_a.rs @@ -8,7 +8,7 @@ use jolt_sumcheck::{ BatchedCommittedSumcheckConsistency, BatchedEvaluationClaim, BatchedSumcheckVerifier, SumcheckClaim, SumcheckStatement, }; -use jolt_transcript::Transcript; +use jolt_transcript::FsTranscript; use super::inputs::Stage6Claims; use crate::{ @@ -48,7 +48,7 @@ pub(super) fn verify_zk( where PCS: CommitmentScheme, VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { let address_statements = vec![ SumcheckStatement::new( @@ -123,7 +123,7 @@ pub(super) fn verify_clear( where PCS: CommitmentScheme, VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { let address_sumcheck_claims = vec![ SumcheckClaim::new( @@ -214,8 +214,8 @@ where pub(super) fn append_opening_claims(transcript: &mut T, claims: &Stage6Claims) where F: Field, - T: Transcript, + T: FsTranscript, { - transcript.append_labeled(b"opening_claim", &claims.address_phase.bytecode_read_raf); - transcript.append_labeled(b"opening_claim", &claims.address_phase.booleanity); + transcript.absorb_field(&claims.address_phase.bytecode_read_raf); + transcript.absorb_field(&claims.address_phase.booleanity); } diff --git a/crates/jolt-verifier/src/stages/stage6/verify_b.rs b/crates/jolt-verifier/src/stages/stage6/verify_b.rs index 3fac9ec7ec..de1ac0b071 100644 --- a/crates/jolt-verifier/src/stages/stage6/verify_b.rs +++ b/crates/jolt-verifier/src/stages/stage6/verify_b.rs @@ -5,7 +5,7 @@ use jolt_claims::protocols::jolt::{ JoltAdviceKind, JoltPublicId, JoltRelationClaims, JoltRelationId, }; use jolt_field::Field; -use jolt_transcript::Transcript; +use jolt_transcript::FsTranscript; use crate::{ stages::{ @@ -157,13 +157,13 @@ pub(super) fn append_opening_claims( booleanity_point: &[F], ) where F: Field, - T: Transcript, + T: FsTranscript, { for opening_claim in &claims.bytecode_read_raf.bytecode_ra { - transcript.append_labeled(b"opening_claim", opening_claim); + transcript.absorb_field(opening_claim); } for opening_claim in &claims.booleanity.instruction_ra { - transcript.append_labeled(b"opening_claim", opening_claim); + transcript.absorb_field(opening_claim); } for (index, opening_claim) in claims.booleanity.bytecode_ra.iter().enumerate() { if bytecode_read_raf_points @@ -172,30 +172,27 @@ pub(super) fn append_opening_claims( { continue; } - transcript.append_labeled(b"opening_claim", opening_claim); + transcript.absorb_field(opening_claim); } for opening_claim in &claims.booleanity.ram_ra { - transcript.append_labeled(b"opening_claim", opening_claim); + transcript.absorb_field(opening_claim); } - transcript.append_labeled( - b"opening_claim", - &claims.ram_hamming_booleanity.ram_hamming_weight, - ); + transcript.absorb_field(&claims.ram_hamming_booleanity.ram_hamming_weight); for opening_claim in &claims.ram_ra_virtualization.ram_ra { - transcript.append_labeled(b"opening_claim", opening_claim); + transcript.absorb_field(opening_claim); } for opening_claim in &claims .instruction_ra_virtualization .committed_instruction_ra { - transcript.append_labeled(b"opening_claim", opening_claim); + transcript.absorb_field(opening_claim); } - transcript.append_labeled(b"opening_claim", &claims.inc_claim_reduction.ram_inc); - transcript.append_labeled(b"opening_claim", &claims.inc_claim_reduction.rd_inc); + transcript.absorb_field(&claims.inc_claim_reduction.ram_inc); + transcript.absorb_field(&claims.inc_claim_reduction.rd_inc); if let Some(opening_claim) = &claims.advice_cycle_phase.trusted { - transcript.append_labeled(b"opening_claim", &opening_claim.opening_claim); + transcript.absorb_field(&opening_claim.opening_claim); } if let Some(opening_claim) = &claims.advice_cycle_phase.untrusted { - transcript.append_labeled(b"opening_claim", &opening_claim.opening_claim); + transcript.absorb_field(&opening_claim.opening_claim); } } diff --git a/crates/jolt-verifier/src/stages/stage7/verify.rs b/crates/jolt-verifier/src/stages/stage7/verify.rs index 8238cc9779..3b535205fa 100644 --- a/crates/jolt-verifier/src/stages/stage7/verify.rs +++ b/crates/jolt-verifier/src/stages/stage7/verify.rs @@ -15,7 +15,7 @@ use jolt_poly::try_eq_mle; use jolt_sumcheck::{ BatchedCommittedSumcheckConsistency, BatchedSumcheckVerifier, SumcheckClaim, SumcheckStatement, }; -use jolt_transcript::Transcript; +use jolt_transcript::FsTranscript; use super::{ inputs::{AdviceAddressPhaseOutputClaim, Deps, Stage7Claims}, @@ -64,7 +64,7 @@ pub fn verify( where PCS: CommitmentScheme, VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { match (checked.zk, deps) { (true, Deps::Clear { .. }) => { @@ -924,21 +924,21 @@ fn stage6_advice_cycle_phase_public( fn append_stage7_opening_claims(transcript: &mut T, claims: &Stage7Claims) where F: Field, - T: Transcript, + T: FsTranscript, { for opening_claim in &claims.hamming_weight_claim_reduction.instruction_ra { - transcript.append_labeled(b"opening_claim", opening_claim); + transcript.absorb_field(opening_claim); } for opening_claim in &claims.hamming_weight_claim_reduction.bytecode_ra { - transcript.append_labeled(b"opening_claim", opening_claim); + transcript.absorb_field(opening_claim); } for opening_claim in &claims.hamming_weight_claim_reduction.ram_ra { - transcript.append_labeled(b"opening_claim", opening_claim); + transcript.absorb_field(opening_claim); } if let Some(opening_claim) = &claims.advice_address_phase.trusted { - transcript.append_labeled(b"opening_claim", &opening_claim.opening_claim); + transcript.absorb_field(&opening_claim.opening_claim); } if let Some(opening_claim) = &claims.advice_address_phase.untrusted { - transcript.append_labeled(b"opening_claim", &opening_claim.opening_claim); + transcript.absorb_field(&opening_claim.opening_claim); } } diff --git a/crates/jolt-verifier/src/stages/stage8/verify.rs b/crates/jolt-verifier/src/stages/stage8/verify.rs index 51bfa76ab2..f842046320 100644 --- a/crates/jolt-verifier/src/stages/stage8/verify.rs +++ b/crates/jolt-verifier/src/stages/stage8/verify.rs @@ -29,7 +29,7 @@ use jolt_openings::{ AdditivelyHomomorphic, CommitmentScheme, EvaluationClaim, VerifierOpeningClaim, ZkOpeningScheme, }; use jolt_poly::{EqPolynomial, Point}; -use jolt_transcript::{AppendToTranscript, LabelWithCount, Transcript}; +use jolt_transcript::FsTranscript; struct AdviceFinalOpening { point: Vec, @@ -65,7 +65,7 @@ where + ZkOpeningScheme, PCS::Output: Clone + HomomorphicCommitment, VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { match (checked.zk, deps) { (true, Deps::Clear { .. }) => { @@ -478,9 +478,8 @@ where } } - transcript.append(&LabelWithCount(b"rlc_claims", opening_claims.len() as u64)); for claim in &opening_claims { - claim.evaluation.value.append_to_transcript(transcript); + transcript.absorb_field(&claim.evaluation.value); } let gamma_powers = transcript.challenge_scalar_powers(opening_claims.len()); diff --git a/crates/jolt-verifier/src/verifier.rs b/crates/jolt-verifier/src/verifier.rs index a424ae95e0..6542813431 100644 --- a/crates/jolt-verifier/src/verifier.rs +++ b/crates/jolt-verifier/src/verifier.rs @@ -1,12 +1,15 @@ //! Top-level verifier entry point. +use ark_serialize::CanonicalSerialize; use common::jolt_device::JoltDevice; use jolt_crypto::{HomomorphicCommitment, VectorCommitment}; use jolt_field::{Field, RingAccumulator, WithAccumulator}; use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme, ZkOpeningScheme}; use jolt_program::preprocess::{compute_max_ram_k, compute_min_ram_k}; use jolt_sumcheck::SumcheckProof; -use jolt_transcript::{AppendToTranscript, Label, LabelWithCount, Transcript, U64Word}; +use jolt_transcript::{ + verifier_transcript, DuplexSpongeInterface, FsAbsorb, FsTranscript, VerifierState, +}; use crate::{ config::{validate_proof_config, JoltProtocolConfig}, @@ -19,7 +22,7 @@ use crate::{ VerifierError, }; -pub fn verify( +pub fn verify( preprocessing: &JoltVerifierPreprocessing, public_io: &JoltDevice, proof: &JoltProof, @@ -27,14 +30,15 @@ pub fn verify( zk: bool, ) -> Result<(), VerifierError> where - F: Field + AppendToTranscript, + F: Field, PCS: CommitmentScheme + AdditivelyHomomorphic + ZkOpeningScheme, - PCS::Output: AppendToTranscript + HomomorphicCommitment, + PCS::Output: CanonicalSerialize + HomomorphicCommitment, VC: VectorCommitment, - VC::Output: Copy + HomomorphicCommitment + AppendToTranscript, - T: Transcript, + VC::Output: Copy + HomomorphicCommitment + CanonicalSerialize, + H: DuplexSpongeInterface + Default, + for<'a> VerifierState<'a, H>: FsTranscript, ::Accumulator: RingAccumulator, { let checked = validate_inputs( @@ -47,7 +51,10 @@ where validate_proof_consistency(proof, checked.zk)?; validate_proof_config(&JoltProtocolConfig::for_zk(checked.zk), proof)?; - let mut transcript = T::new(b"Jolt"); + // Option-A symmetric model: no NARG. Construct the transcript with a zero + // instance digest and absorb the public preamble explicitly afterwards, + // preserving the model's self-consistency. + let mut transcript = verifier_transcript(b"Jolt", [0u8; 32], H::default(), &[]); absorb_preamble(&checked, proof, &mut transcript); absorb_commitments(proof, trusted_advice_commitment, &mut transcript); @@ -124,10 +131,9 @@ where .vc_setup .as_ref() .ok_or(VerifierError::MissingVectorCommitmentSetup)?; - transcript.append(&Label(b"BlindFold")); blindfold .protocol - .verify::(proof.blindfold_proof()?, vc_setup, &mut transcript) + .verify::(proof.blindfold_proof()?, vc_setup, &mut transcript) .map_err(|error| VerifierError::BlindFoldVerificationFailed { reason: error.to_string(), })?; @@ -270,66 +276,35 @@ pub(crate) fn absorb_preamble( ) where PCS: CommitmentScheme, VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { let public_io = &checked.public_io; - absorb_labeled_bytes( - transcript, - b"preprocessing_digest", - &checked.preprocessing_digest, - ); - absorb_labeled_u64( - transcript, - b"max_input_size", - public_io.memory_layout.max_input_size, - ); - absorb_labeled_u64( - transcript, - b"max_output_size", - public_io.memory_layout.max_output_size, - ); - absorb_labeled_u64(transcript, b"heap_size", public_io.memory_layout.heap_size); - absorb_labeled_bytes(transcript, b"inputs", &public_io.inputs); - absorb_labeled_bytes(transcript, b"outputs", &public_io.outputs); - absorb_labeled_u64(transcript, b"panic", public_io.panic as u64); - absorb_labeled_u64(transcript, b"ram_K", checked.ram_K as u64); - absorb_labeled_u64(transcript, b"trace_length", checked.trace_length as u64); - absorb_labeled_u64(transcript, b"entry_address", checked.entry_address); - absorb_labeled_u64( - transcript, - b"ram_rw_phase1_num_rounds", - proof.rw_config.ram_rw_phase1_num_rounds as u64, - ); - absorb_labeled_u64( - transcript, - b"ram_rw_phase2_num_rounds", - proof.rw_config.ram_rw_phase2_num_rounds as u64, - ); - absorb_labeled_u64( + transcript.absorb_bytes(&checked.preprocessing_digest); + absorb_u64(transcript, public_io.memory_layout.max_input_size); + absorb_u64(transcript, public_io.memory_layout.max_output_size); + absorb_u64(transcript, public_io.memory_layout.heap_size); + transcript.absorb_bytes(&public_io.inputs); + transcript.absorb_bytes(&public_io.outputs); + absorb_u64(transcript, public_io.panic as u64); + absorb_u64(transcript, checked.ram_K as u64); + absorb_u64(transcript, checked.trace_length as u64); + absorb_u64(transcript, checked.entry_address); + absorb_u64(transcript, proof.rw_config.ram_rw_phase1_num_rounds as u64); + absorb_u64(transcript, proof.rw_config.ram_rw_phase2_num_rounds as u64); + absorb_u64( transcript, - b"registers_rw_phase1_num_rounds", proof.rw_config.registers_rw_phase1_num_rounds as u64, ); - absorb_labeled_u64( + absorb_u64( transcript, - b"registers_rw_phase2_num_rounds", proof.rw_config.registers_rw_phase2_num_rounds as u64, ); - absorb_labeled_u64( - transcript, - b"log_k_chunk", - proof.one_hot_config.log_k_chunk as u64, - ); - absorb_labeled_u64( + absorb_u64(transcript, proof.one_hot_config.log_k_chunk as u64); + absorb_u64( transcript, - b"lookups_ra_virtual_log_k_chunk", proof.one_hot_config.lookups_ra_virtual_log_k_chunk as u64, ); - absorb_labeled_u64( - transcript, - b"dory_layout", - proof.trace_polynomial_order.transcript_scalar(), - ); + absorb_u64(transcript, proof.trace_polynomial_order.transcript_scalar()); } pub(crate) fn absorb_commitments( @@ -338,13 +313,12 @@ pub(crate) fn absorb_commitments( transcript: &mut T, ) where PCS: CommitmentScheme, - PCS::Output: AppendToTranscript, + PCS::Output: CanonicalSerialize, VC: VectorCommitment, - T: Transcript, + T: FsTranscript, { let mut absorb_commitment = |commitment: &PCS::Output| { - append_payload_label(transcript, b"commitment", commitment); - transcript.append(commitment); + transcript.absorb(commitment); }; absorb_commitment(&proof.commitments.rd_inc); absorb_commitment(&proof.commitments.ram_inc); @@ -358,35 +332,18 @@ pub(crate) fn absorb_commitments( absorb_commitment(commitment); } if let Some(untrusted_advice_commitment) = &proof.untrusted_advice_commitment { - append_payload_label(transcript, b"untrusted_advice", untrusted_advice_commitment); - transcript.append(untrusted_advice_commitment); + transcript.absorb(untrusted_advice_commitment); } if let Some(trusted_advice_commitment) = trusted_advice_commitment { - append_payload_label(transcript, b"trusted_advice", trusted_advice_commitment); - transcript.append(trusted_advice_commitment); - } -} - -fn append_payload_label(transcript: &mut T, label: &'static [u8], payload: &A) -where - T: Transcript, - A: AppendToTranscript, -{ - if let Some(len) = payload.transcript_payload_len() { - transcript.append(&LabelWithCount(label, len)); - } else { - transcript.append(&Label(label)); + transcript.absorb(trusted_advice_commitment); } } -fn absorb_labeled_bytes(transcript: &mut T, label: &'static [u8], bytes: &[u8]) { - transcript.append(&LabelWithCount(label, bytes.len() as u64)); - transcript.append_bytes(bytes); -} - -fn absorb_labeled_u64(transcript: &mut T, label: &'static [u8], value: u64) { - transcript.append(&Label(label)); - transcript.append(&U64Word(value)); +/// Absorbs a `u64` statement field as raw little-endian bytes. Like jolt-core, +/// no domain-separation label is absorbed — statement fields are separated +/// positionally and by the transcript's one-time `DomainSeparator`/instance. +fn absorb_u64(transcript: &mut T, value: u64) { + transcript.absorb_bytes(&value.to_le_bytes()); } pub fn validate_proof_consistency( @@ -484,6 +441,9 @@ where mod tests { use super::*; use crate::proof::{ClearProofClaims, JoltProofClaims, JoltStageProofs}; + use ark_serialize::{ + CanonicalDeserialize, CanonicalSerialize, Compress, SerializationError, Valid, Validate, + }; use common::jolt_device::{JoltDevice, MemoryConfig}; use jolt_claims::protocols::jolt::{JoltOneHotConfig, JoltReadWriteConfig}; use jolt_crypto::{Bn254G1, Commitment, Pedersen, PedersenSetup, VectorCommitmentOpening}; @@ -496,8 +456,9 @@ mod tests { use jolt_sumcheck::{ ClearProof, ClearSumcheckProof, CommittedSumcheckProof, CompressedSumcheckProof, }; - use jolt_transcript::Transcript; + use jolt_transcript::FsTranscript; use num_traits::Zero; + use std::io::{Read, Write}; #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] struct TestPcs; @@ -537,7 +498,7 @@ mod tests { _eval: Self::Field, _setup: &Self::ProverSetup, _hint: Option, - _transcript: &mut impl Transcript, + _transcript: &mut impl FsTranscript, ) -> Self::Proof { } @@ -547,21 +508,47 @@ mod tests { _eval: Self::Field, _proof: &Self::Proof, _setup: &Self::VerifierSetup, - _transcript: &mut impl Transcript, + _transcript: &mut impl FsTranscript, ) -> Result<(), OpeningsError> { Ok(()) } fn bind_opening_inputs( - _transcript: &mut impl Transcript, + _transcript: &mut impl FsTranscript, _point: &[Self::Field], _eval: &Self::Field, ) { } } - impl jolt_transcript::AppendToTranscript for TestCommitment { - fn append_to_transcript(&self, _transcript: &mut T) {} + impl CanonicalSerialize for TestCommitment { + fn serialize_with_mode( + &self, + _writer: W, + _compress: Compress, + ) -> Result<(), SerializationError> { + Ok(()) + } + + fn serialized_size(&self, _compress: Compress) -> usize { + 0 + } + } + + impl Valid for TestCommitment { + fn check(&self) -> Result<(), SerializationError> { + Ok(()) + } + } + + impl CanonicalDeserialize for TestCommitment { + fn deserialize_with_mode( + _reader: R, + _compress: Compress, + _validate: Validate, + ) -> Result { + Ok(TestCommitment) + } } type TestProof = JoltProof>; diff --git a/crates/jolt-verifier/tests/completeness/zk.rs b/crates/jolt-verifier/tests/completeness/zk.rs index 9772e009cc..c3c9f9c57e 100644 --- a/crates/jolt-verifier/tests/completeness/zk.rs +++ b/crates/jolt-verifier/tests/completeness/zk.rs @@ -18,7 +18,7 @@ use jolt_field::Fr; #[cfg(all(feature = "core-fixtures", feature = "zk"))] use jolt_sumcheck::SumcheckProof; #[cfg(all(feature = "core-fixtures", feature = "zk"))] -use jolt_transcript::Blake2bTranscript; +use jolt_transcript::Blake2b512; #[cfg(all(feature = "core-fixtures", feature = "zk"))] use jolt_verifier::JoltProofClaims; @@ -36,7 +36,7 @@ fn zk_muldiv_blindfold_shape_audit_matches_modular_protocol() { Fr, DoryScheme, Pedersen, - Blake2bTranscript, + Blake2b512, _, >(&case.preprocessing, &case.public_io, &case.proof, None) .expect("build modular BlindFold protocol shape"); diff --git a/crates/jolt-verifier/tests/statistical_independence/zk.rs b/crates/jolt-verifier/tests/statistical_independence/zk.rs index b9e2d99a51..c5e7a4aad2 100644 --- a/crates/jolt-verifier/tests/statistical_independence/zk.rs +++ b/crates/jolt-verifier/tests/statistical_independence/zk.rs @@ -18,7 +18,7 @@ use jolt_field::{FixedBytes, Fr}; #[cfg(all(feature = "core-fixtures", feature = "zk"))] use jolt_sumcheck::SumcheckProof; #[cfg(all(feature = "core-fixtures", feature = "zk"))] -use jolt_transcript::{AppendToTranscript, Blake2bTranscript, Transcript}; +use jolt_transcript::{prover_transcript, Blake2b512, FsAbsorb, FsChallenge}; #[cfg(all(feature = "core-fixtures", feature = "zk"))] use jolt_verifier::JoltProofClaims; @@ -351,7 +351,7 @@ fn collect_sumcheck_statistics( proof: &SumcheckProof, tracker: &mut BucketTracker, ) where - C: AppendToTranscript, + C: CanonicalSerialize, { let proof = proof .as_committed() @@ -500,15 +500,18 @@ impl BucketTracker { Self::default() } - fn record_append(&mut self, name: impl Into, value: &A) { + fn record_append(&mut self, name: impl Into, value: &A) { let name = name.into(); - let mut transcript = Blake2bTranscript::::new(b"jolt-zk-stat"); - transcript.append_bytes(name.as_bytes()); - value.append_to_transcript(&mut transcript); - self.record_projected(name, field_low_u64(transcript.challenge())); + let mut transcript = prover_transcript(b"jolt-zk-stat", [0u8; 32], Blake2b512::default()); + transcript.absorb_bytes(name.as_bytes()); + transcript.absorb(value); + self.record_projected( + name, + field_low_u64(FsChallenge::::challenge(&mut transcript)), + ); } - fn record_append_positions(&mut self, prefix: &str, values: &[A]) { + fn record_append_positions(&mut self, prefix: &str, values: &[A]) { for index in selected_positions(values.len()) { self.record_append(format!("{prefix}.{index}"), &values[index]); } @@ -551,10 +554,13 @@ impl BucketTracker { } fn record_bytes(&mut self, name: String, bytes: &[u8]) { - let mut transcript = Blake2bTranscript::::new(b"jolt-zk-stat"); - transcript.append_bytes(name.as_bytes()); - transcript.append_bytes(bytes); - self.record_projected(name, field_low_u64(transcript.challenge())); + let mut transcript = prover_transcript(b"jolt-zk-stat", [0u8; 32], Blake2b512::default()); + transcript.absorb_bytes(name.as_bytes()); + transcript.absorb_bytes(bytes); + self.record_projected( + name, + field_low_u64(FsChallenge::::challenge(&mut transcript)), + ); } fn record_projected(&mut self, name: String, value: u64) { diff --git a/crates/jolt-verifier/tests/support/core_fixtures.rs b/crates/jolt-verifier/tests/support/core_fixtures.rs index 95dd9b54e2..ad4d9c74f1 100644 --- a/crates/jolt-verifier/tests/support/core_fixtures.rs +++ b/crates/jolt-verifier/tests/support/core_fixtures.rs @@ -33,7 +33,7 @@ use jolt_dory::{DoryScheme, DoryVerifierSetup}; use jolt_field::Fr; use jolt_program::preprocess::JoltProgramPreprocessing; use jolt_riscv::{CircuitFlags, InstructionFlags}; -use jolt_transcript::Blake2bTranscript; +use jolt_transcript::Blake2b512; use jolt_verifier::{ compat::convert::ImportedCoreProof, verify, JoltVerifierPreprocessing, VerifierError, }; @@ -210,7 +210,7 @@ pub struct CoreVerifierCase { #[cfg(not(feature = "zk"))] impl CoreVerifierCase { pub fn verify(&self) -> Result<(), VerifierError> { - verify::, Blake2bTranscript>( + verify::, Blake2b512>( &self.preprocessing, &self.public_io, &self.proof, @@ -259,7 +259,7 @@ impl CorePrecompatVerifierCase { pub fn verify_after_compat(&self) -> Result<(), VerifierError> { let proof = self.proof.clone_via_bytes().try_into()?; - verify::, Blake2bTranscript>( + verify::, Blake2b512>( &self.preprocessing, &self.public_io, &proof, @@ -431,7 +431,7 @@ pub struct CoreZkVerifierCase { #[cfg(feature = "zk")] impl CoreZkVerifierCase { pub fn verify(&self) -> Result<(), VerifierError> { - verify::, Blake2bTranscript>( + verify::, Blake2b512>( &self.preprocessing, &self.public_io, &self.proof, diff --git a/jolt-core/Cargo.toml b/jolt-core/Cargo.toml index fdf76ce837..4f21f5c5cb 100644 --- a/jolt-core/Cargo.toml +++ b/jolt-core/Cargo.toml @@ -43,9 +43,23 @@ monitor = ["dep:sysinfo"] pprof = ["dep:pprof", "dep:prost"] test_incremental = [] challenge-254-bit = [] -transcript-poseidon = ["dep:light-poseidon", "challenge-254-bit"] -transcript-keccak = [] -transcript-blake2b = [] +# D5b (#1586 reviewer): `transcript-poseidon` forces `challenge-254-bit`, so +# under Poseidon `F::Challenge` is the full-field `Mont254BitChallenge`. Poseidon +# deliberately has NO 128-bit optimized challenge (`challenge_128` is +# `unimplemented!` for the Poseidon sponge): for recursion the 128-bit truncation +# is costly and defeats the purpose of using Poseidon in the first place, so the +# Poseidon configuration keeps genuine full-field challenges. +transcript-poseidon = [ + "jolt-transcript/transcript-poseidon", + "challenge-254-bit", +] +transcript-keccak = ["jolt-transcript/transcript-keccak"] +transcript-blake2b = ["jolt-transcript/transcript-blake2b"] +# Gates `zkvm::transpilable_verifier`: a generic mirror of `JoltVerifier` over the +# opening accumulator and the spongefish `VerifierFs` surface (non-ZK stages 1-7 +# only), consumed by the `transpiler` crate to generate the on-chain verifier. +# See specs/on-chain-solidity-verifier-plan.md Part II. +transpiler = [] [dependencies] ark-bn254.workspace = true @@ -68,7 +82,6 @@ rayon = { workspace = true, optional = true } serde = { workspace = true, default-features = false } sha3.workspace = true blake2.workspace = true -light-poseidon = { version = "0.4.0", optional = true } chrono.workspace = true strum.workspace = true strum_macros.workspace = true @@ -86,6 +99,7 @@ allocative.workspace = true inferno = { workspace = true, optional = true } jolt-program = { workspace = true, features = ["image", "serialization"] } jolt-riscv = { workspace = true, features = ["serialization"] } +jolt-transcript.workspace = true jolt-inlines-sha2 = { workspace = true, features = ["host"], optional = true } jolt-inlines-keccak256 = { workspace = true, features = [ "host", diff --git a/jolt-core/benches/e2e_profiling.rs b/jolt-core/benches/e2e_profiling.rs index 69ff405932..99b1fd7e63 100644 --- a/jolt-core/benches/e2e_profiling.rs +++ b/jolt-core/benches/e2e_profiling.rs @@ -300,19 +300,11 @@ fn prove_example_with_trace( .joint_opening_proof .serialized_size(ark_serialize::Compress::No); - // Commitments (curve points - benefits from compression) - let commitments_size_compressed = jolt_proof - .commitments - .serialized_size(ark_serialize::Compress::Yes); - let commitments_size_uncompressed = jolt_proof - .commitments - .serialized_size(ark_serialize::Compress::No); + // Witness-polynomial commitments now live inside the NARG byte-string (already + // counted in `proof_size`), so they no longer have a separate compression estimate. - // Estimate proof size with full Dory compression (assuming ~3x compression ratio) - let proof_size_full_compressed = proof_size - stage8_size_compressed - + (stage8_size_uncompressed / 3) - - commitments_size_compressed - + (commitments_size_uncompressed / 3); + let proof_size_full_compressed = + proof_size - stage8_size_compressed + (stage8_size_uncompressed / 3); let verifier_preprocessing = JoltVerifierPreprocessing::from(&preprocessing); let verifier = diff --git a/jolt-core/src/field/challenge/mont_ark_u254.rs b/jolt-core/src/field/challenge/mont_ark_u254.rs index 3575e64544..a9ff2c8a0f 100644 --- a/jolt-core/src/field/challenge/mont_ark_u254.rs +++ b/jolt-core/src/field/challenge/mont_ark_u254.rs @@ -26,6 +26,9 @@ use std::ops::*; CanonicalDeserialize, Allocative, )] +// `repr(transparent)` guarantees identical layout to `F` — relied on by the +// `transmute_copy` in `transcript_msgs`'s Poseidon `challenge_optimized` (full-field path). +#[repr(transparent)] pub struct Mont254BitChallenge { value: F, } diff --git a/jolt-core/src/guest/prover.rs b/jolt-core/src/guest/prover.rs index f87d373e98..218a11a7db 100644 --- a/jolt-core/src/guest/prover.rs +++ b/jolt-core/src/guest/prover.rs @@ -4,13 +4,15 @@ use crate::field::JoltField; use crate::poly::commitment::commitment_scheme::CommitmentScheme; use crate::poly::commitment::commitment_scheme::{StreamingCommitmentScheme, ZkEvalCommitment}; use crate::poly::commitment::dory::DoryCommitmentScheme; -use crate::transcripts::Transcript; +use crate::transcript_msgs::ProverFs; use crate::zkvm::bytecode::PreprocessingError; use crate::zkvm::program::ProgramPreprocessing; use crate::zkvm::proof_serialization::JoltProof; use crate::zkvm::prover::JoltProverPreprocessing; use crate::zkvm::ProverDebugInfo; use common::jolt_device::MemoryLayout; +use jolt_transcript::{ProverState, TranscriptInit}; +use rand::rngs::StdRng; use tracer::JoltDevice; #[allow(clippy::type_complexity)] @@ -41,7 +43,7 @@ pub fn prove< F: JoltField, C: JoltCurve, PCS: StreamingCommitmentScheme + ZkEvalCommitment, - FS: Transcript, + H: TranscriptInit + Default, >( guest: &Program, inputs_bytes: &[u8], @@ -52,13 +54,16 @@ pub fn prove< output_bytes: &mut [u8], preprocessing: &JoltProverPreprocessing, ) -> ( - JoltProof, + JoltProof, JoltDevice, - Option>, -) { + Option>, +) +where + ProverState: ProverFs, +{ use crate::zkvm::prover::JoltCpuProver; - let prover = JoltCpuProver::::gen_from_elf( + let prover = JoltCpuProver::::gen_from_elf( preprocessing, &guest.elf_contents, inputs_bytes, diff --git a/jolt-core/src/guest/verifier.rs b/jolt-core/src/guest/verifier.rs index 2ffe8a35cc..074a1b1535 100644 --- a/jolt-core/src/guest/verifier.rs +++ b/jolt-core/src/guest/verifier.rs @@ -8,7 +8,7 @@ use crate::zkvm::verifier::BlindfoldSetup; use crate::guest::program::Program; use crate::poly::commitment::dory::DoryCommitmentScheme; -use crate::transcripts::Transcript; +use crate::transcript_msgs::VerifierFs; use crate::zkvm::program::ProgramPreprocessing; use crate::zkvm::proof_serialization::JoltProof; use crate::zkvm::verifier::JoltSharedPreprocessing; @@ -16,6 +16,7 @@ use crate::zkvm::verifier::JoltVerifier; use crate::zkvm::verifier::JoltVerifierPreprocessing; use common::jolt_device::MemoryConfig; use common::jolt_device::MemoryLayout; +use jolt_transcript::{TranscriptInit, VerifierState}; pub fn preprocess( guest: &Program, @@ -55,14 +56,17 @@ pub fn verify< F: JoltField, C: JoltCurve, PCS: StreamingCommitmentScheme + ZkEvalCommitment, - FS: Transcript, + H: TranscriptInit + Default, >( inputs_bytes: &[u8], trusted_advice_commitment: Option<::Commitment>, outputs_bytes: &[u8], - proof: JoltProof, + proof: JoltProof, preprocessing: &JoltVerifierPreprocessing, -) -> Result<(), ProofVerifyError> { +) -> Result<(), ProofVerifyError> +where + for<'b> VerifierState<'b, H>: VerifierFs, +{ use common::jolt_device::JoltDevice; let memory_layout = &preprocessing.shared.memory_layout; let memory_config = MemoryConfig { diff --git a/jolt-core/src/lib.rs b/jolt-core/src/lib.rs index b7d5cd1028..f7c848d0ad 100644 --- a/jolt-core/src/lib.rs +++ b/jolt-core/src/lib.rs @@ -18,7 +18,7 @@ pub mod guest; pub mod msm; pub mod poly; pub mod subprotocols; -pub mod transcripts; +pub mod transcript_msgs; pub mod utils; pub mod zkvm; pub use ark_bn254; diff --git a/jolt-core/src/poly/commitment/commitment_scheme.rs b/jolt-core/src/poly/commitment/commitment_scheme.rs index f316cce487..4165750d6c 100644 --- a/jolt-core/src/poly/commitment/commitment_scheme.rs +++ b/jolt-core/src/poly/commitment/commitment_scheme.rs @@ -2,7 +2,7 @@ use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; use std::borrow::Borrow; use std::fmt::Debug; -use crate::transcripts::Transcript; +use crate::transcript_msgs::{ProverFs, VerifierFs}; use crate::{ curve::JoltCurve, field::JoltField, @@ -103,12 +103,12 @@ pub trait CommitmentScheme: Clone + Sync + Send + 'static { /// A tuple containing: /// - The proof of the polynomial evaluation at the specified point /// - An optional ZK blinding factor (y_blinding) for use in BlindFold; None for non-ZK schemes - fn prove( + fn prove>( setup: &Self::ProverSetup, poly: &MultilinearPolynomial, opening_point: &[::Challenge], hint: Option, - transcript: &mut ProofTranscript, + transcript: &mut T, ) -> (Self::Proof, Option); /// Verifies a proof of polynomial evaluation at a specific point. @@ -123,10 +123,10 @@ pub trait CommitmentScheme: Clone + Sync + Send + 'static { /// /// # Returns /// Ok(()) if the proof is valid, otherwise a ProofVerifyError - fn verify( + fn verify>( proof: &Self::Proof, setup: &Self::VerifierSetup, - transcript: &mut ProofTranscript, + transcript: &mut T, opening_point: &[::Challenge], opening: &Self::Field, commitment: &Self::Commitment, diff --git a/jolt-core/src/poly/commitment/dory/commitment_scheme.rs b/jolt-core/src/poly/commitment/dory/commitment_scheme.rs index 84b3c38012..7c3502719d 100644 --- a/jolt-core/src/poly/commitment/dory/commitment_scheme.rs +++ b/jolt-core/src/poly/commitment/dory/commitment_scheme.rs @@ -13,7 +13,7 @@ use crate::{ CommitmentScheme, StreamingCommitmentScheme, ZkEvalCommitment, }, poly::multilinear_polynomial::MultilinearPolynomial, - transcripts::Transcript, + transcript_msgs::{FsAbsorb, ProverFs, VerifierFs}, utils::{errors::ProofVerifyError, math::Math, small_scalar::SmallScalar}, }; use ark_bn254::{G1Affine, G1Projective}; @@ -76,8 +76,8 @@ fn canonical_setup_log_n(max_num_vars: usize) -> usize { } } -pub fn bind_opening_inputs( - transcript: &mut ProofTranscript, +pub fn bind_opening_inputs( + transcript: &mut T, opening_point: &[F::Challenge], opening: &F, ) { @@ -86,14 +86,15 @@ pub fn bind_opening_inputs( let scalar: F = (*point).into(); point_scalars.push(scalar); } - transcript.append_scalars(b"dory_opening_point", &point_scalars); - - transcript.append_scalar(b"dory_opening_eval", opening); + // Both the opening point (sumcheck challenges) and the claimed evaluation are + // shared: the verifier already holds them before reading the proof → `absorb`. + transcript.absorb_scalars(&point_scalars); + transcript.absorb_scalar(opening); } #[cfg(feature = "zk")] -pub fn bind_opening_inputs_zk, ProofTranscript: Transcript>( - transcript: &mut ProofTranscript, +pub fn bind_opening_inputs_zk, T: FsAbsorb>( + transcript: &mut T, opening_point: &[F::Challenge], y_com: &C::G1, ) { @@ -102,9 +103,9 @@ pub fn bind_opening_inputs_zk, ProofTranscript let scalar: F = (*point).into(); point_scalars.push(scalar); } - transcript.append_scalars(b"dory_opening_point", &point_scalars); + transcript.absorb_scalars(&point_scalars); - transcript.append_commitment(b"dory_eval_commitment", y_com); + transcript.absorb_commitment(y_com); } impl CommitmentScheme for DoryCommitmentScheme { @@ -186,12 +187,12 @@ impl CommitmentScheme for DoryCommitmentScheme { .collect() } - fn prove( + fn prove>( setup: &Self::ProverSetup, poly: &MultilinearPolynomial, opening_point: &[::Challenge], hint: Option, - transcript: &mut ProofTranscript, + transcript: &mut T, ) -> (Self::Proof, Option) { let _span = trace_span!("DoryCommitmentScheme::prove").entered(); @@ -216,7 +217,7 @@ impl CommitmentScheme for DoryCommitmentScheme { }) .collect(); - let mut dory_transcript = JoltToDoryTranscript::::new(transcript); + let mut dory_transcript = JoltToDoryTranscript::::new(transcript); #[cfg(feature = "zk")] type DoryMode = dory::ZK; @@ -239,10 +240,10 @@ impl CommitmentScheme for DoryCommitmentScheme { (proof, y_blinding.map(|b| ark_to_jolt(&b))) } - fn verify( + fn verify>( proof: &Self::Proof, setup: &Self::VerifierSetup, - transcript: &mut ProofTranscript, + transcript: &mut T, opening_point: &[::Challenge], opening: &ark_bn254::Fr, commitment: &Self::Commitment, @@ -260,7 +261,7 @@ impl CommitmentScheme for DoryCommitmentScheme { .collect(); let ark_eval: ArkFr = jolt_to_ark(opening); - let mut dory_transcript = JoltToDoryTranscript::::new(transcript); + let mut dory_transcript = JoltToDoryTranscript::::new(transcript); #[cfg(not(feature = "zk"))] if proof.e2.is_some() diff --git a/jolt-core/src/poly/commitment/dory/tests.rs b/jolt-core/src/poly/commitment/dory/tests.rs index 85f6a3ea57..350a5660a5 100644 --- a/jolt-core/src/poly/commitment/dory/tests.rs +++ b/jolt-core/src/poly/commitment/dory/tests.rs @@ -7,11 +7,11 @@ mod tests { use crate::poly::commitment::dory::{bind_opening_inputs, DoryContext}; use crate::poly::dense_mlpoly::DensePolynomial; use crate::poly::multilinear_polynomial::{MultilinearPolynomial, PolynomialEvaluation}; - use crate::transcripts::{Blake2bTranscript, Transcript}; use crate::utils::math::Math; use ark_ff::biginteger::S128; use ark_std::rand::{thread_rng, Rng}; use ark_std::{UniformRand, Zero}; + use jolt_transcript::{prover_transcript, verifier_transcript, Blake2b512}; use serial_test::serial; type Fr = ark_bn254::Fr; @@ -35,7 +35,8 @@ mod tests { &opening_point, ); - let mut prove_transcript = Blake2bTranscript::new(b"dory_test"); + let mut prove_transcript = + prover_transcript(b"dory_test", [0u8; 32], Blake2b512::default()); bind_opening_inputs::(&mut prove_transcript, &opening_point, &evaluation); let (proof, _y_blinding) = DoryCommitmentScheme::prove( prover_setup, @@ -45,7 +46,8 @@ mod tests { &mut prove_transcript, ); - let mut verify_transcript = Blake2bTranscript::new(b"dory_test"); + let mut verify_transcript = + verifier_transcript(b"dory_test", [0u8; 32], Blake2b512::default(), &[]); bind_opening_inputs::(&mut verify_transcript, &opening_point, &evaluation); let verification_result = DoryCommitmentScheme::verify( &proof, @@ -280,7 +282,11 @@ mod tests { let (commitment, row_commitments) = DoryCommitmentScheme::commit(&poly, &prover_setup); - let mut prove_transcript = Blake2bTranscript::new(DoryCommitmentScheme::protocol_name()); + let mut prove_transcript = prover_transcript( + DoryCommitmentScheme::protocol_name(), + [0u8; 32], + Blake2b512::default(), + ); let correct_evaluation = poly.evaluate(&opening_point); @@ -297,8 +303,12 @@ mod tests { { let tampered_evaluation = Fr::rand(&mut rng); - let mut verify_transcript = - Blake2bTranscript::new(DoryCommitmentScheme::protocol_name()); + let mut verify_transcript = verifier_transcript( + DoryCommitmentScheme::protocol_name(), + [0u8; 32], + Blake2b512::default(), + &[], + ); bind_opening_inputs::( &mut verify_transcript, &opening_point, @@ -331,8 +341,12 @@ mod tests { panic!("ZK proof missing committed evaluation fields"); } - let mut verify_transcript = - Blake2bTranscript::new(DoryCommitmentScheme::protocol_name()); + let mut verify_transcript = verifier_transcript( + DoryCommitmentScheme::protocol_name(), + [0u8; 32], + Blake2b512::default(), + &[], + ); bind_opening_inputs::( &mut verify_transcript, &opening_point, @@ -359,8 +373,12 @@ mod tests { .map(|_| ::Challenge::random(&mut rng)) .collect(); - let mut verify_transcript = - Blake2bTranscript::new(DoryCommitmentScheme::protocol_name()); + let mut verify_transcript = verifier_transcript( + DoryCommitmentScheme::protocol_name(), + [0u8; 32], + Blake2b512::default(), + &[], + ); bind_opening_inputs::( &mut verify_transcript, &tampered_opening_point, @@ -389,8 +407,12 @@ mod tests { MultilinearPolynomial::LargeScalars(DensePolynomial::new(wrong_coeffs)); let (wrong_commitment, _) = DoryCommitmentScheme::commit(&wrong_poly, &prover_setup); - let mut verify_transcript = - Blake2bTranscript::new(DoryCommitmentScheme::protocol_name()); + let mut verify_transcript = verifier_transcript( + DoryCommitmentScheme::protocol_name(), + [0u8; 32], + Blake2b512::default(), + &[], + ); bind_opening_inputs::( &mut verify_transcript, &opening_point, @@ -413,7 +435,8 @@ mod tests { // Test 4: Use wrong domain in transcript { - let mut verify_transcript = Blake2bTranscript::new(b"wrong_domain"); + let mut verify_transcript = + verifier_transcript(b"wrong_domain", [0u8; 32], Blake2b512::default(), &[]); bind_opening_inputs::( &mut verify_transcript, &opening_point, @@ -436,8 +459,12 @@ mod tests { // Test 5: Verify that correct proof still passes { - let mut verify_transcript = - Blake2bTranscript::new(DoryCommitmentScheme::protocol_name()); + let mut verify_transcript = verifier_transcript( + DoryCommitmentScheme::protocol_name(), + [0u8; 32], + Blake2b512::default(), + &[], + ); bind_opening_inputs::( &mut verify_transcript, &opening_point, @@ -502,7 +529,8 @@ mod tests { &opening_point, ); - let mut prove_transcript = Blake2bTranscript::new(b"dory_test"); + let mut prove_transcript = + prover_transcript(b"dory_test", [0u8; 32], Blake2b512::default()); bind_opening_inputs::(&mut prove_transcript, &opening_point, &evaluation); let (proof, _y_blinding) = DoryCommitmentScheme::prove( &prover_setup, @@ -512,7 +540,8 @@ mod tests { &mut prove_transcript, ); - let mut verify_transcript = Blake2bTranscript::new(b"dory_test"); + let mut verify_transcript = + verifier_transcript(b"dory_test", [0u8; 32], Blake2b512::default(), &[]); bind_opening_inputs::(&mut verify_transcript, &opening_point, &evaluation); let verification_result = DoryCommitmentScheme::verify( &proof, @@ -587,7 +616,8 @@ mod tests { let combined_poly = MultilinearPolynomial::from(combined_poly.Z); // Step 8: Create evaluation proof using combined commitment and hint - let mut prove_transcript = Blake2bTranscript::new(b"dory_homomorphic_test"); + let mut prove_transcript = + prover_transcript(b"dory_homomorphic_test", [0u8; 32], Blake2b512::default()); bind_opening_inputs::(&mut prove_transcript, &opening_point, &evaluation); let (proof, _y_blinding) = DoryCommitmentScheme::prove( &prover_setup, @@ -598,7 +628,12 @@ mod tests { ); // Step 9: Verify the proof - let mut verify_transcript = Blake2bTranscript::new(b"dory_homomorphic_test"); + let mut verify_transcript = verifier_transcript( + b"dory_homomorphic_test", + [0u8; 32], + Blake2b512::default(), + &[], + ); bind_opening_inputs::(&mut verify_transcript, &opening_point, &evaluation); let result = DoryCommitmentScheme::verify( &proof, @@ -683,7 +718,11 @@ mod tests { ); // Step 9: Create evaluation proof using combined hint - let mut prove_transcript = Blake2bTranscript::new(b"dory_batch_commit_e2e_test"); + let mut prove_transcript = prover_transcript( + b"dory_batch_commit_e2e_test", + [0u8; 32], + Blake2b512::default(), + ); bind_opening_inputs::(&mut prove_transcript, &opening_point, &evaluation); let (proof, _y_blinding) = DoryCommitmentScheme::prove( &prover_setup, @@ -694,7 +733,12 @@ mod tests { ); // Step 10: Verify the proof - let mut verify_transcript = Blake2bTranscript::new(b"dory_batch_commit_e2e_test"); + let mut verify_transcript = verifier_transcript( + b"dory_batch_commit_e2e_test", + [0u8; 32], + Blake2b512::default(), + &[], + ); bind_opening_inputs::(&mut verify_transcript, &opening_point, &evaluation); let result = DoryCommitmentScheme::verify( &proof, @@ -711,7 +755,11 @@ mod tests { ); // Step 11: Also verify that proving with the direct hint works - let mut prove_transcript2 = Blake2bTranscript::new(b"dory_batch_commit_e2e_test"); + let mut prove_transcript2 = prover_transcript( + b"dory_batch_commit_e2e_test", + [0u8; 32], + Blake2b512::default(), + ); bind_opening_inputs::(&mut prove_transcript2, &opening_point, &evaluation); let (proof2, _y_blinding2) = DoryCommitmentScheme::prove( &prover_setup, @@ -721,7 +769,12 @@ mod tests { &mut prove_transcript2, ); - let mut verify_transcript2 = Blake2bTranscript::new(b"dory_batch_commit_e2e_test"); + let mut verify_transcript2 = verifier_transcript( + b"dory_batch_commit_e2e_test", + [0u8; 32], + Blake2b512::default(), + &[], + ); bind_opening_inputs::(&mut verify_transcript2, &opening_point, &evaluation); let result2 = DoryCommitmentScheme::verify( &proof2, @@ -928,7 +981,8 @@ mod tests { let evaluation = as PolynomialEvaluation>::evaluate(&poly, &eval_point); - let mut prove_transcript = Blake2bTranscript::new(b"dory_test"); + let mut prove_transcript = + prover_transcript(b"dory_test", [0u8; 32], Blake2b512::default()); bind_opening_inputs::(&mut prove_transcript, &opening_point, &evaluation); let (proof, _y_binding) = DoryCommitmentScheme::prove( &prover_setup, @@ -938,7 +992,8 @@ mod tests { &mut prove_transcript, ); - let mut verify_transcript = Blake2bTranscript::new(b"dory_test"); + let mut verify_transcript = + verifier_transcript(b"dory_test", [0u8; 32], Blake2b512::default(), &[]); bind_opening_inputs::(&mut verify_transcript, &opening_point, &evaluation); let verification_result = DoryCommitmentScheme::verify( &proof, diff --git a/jolt-core/src/poly/commitment/dory/wrappers.rs b/jolt-core/src/poly/commitment/dory/wrappers.rs index 1ce7149ec3..e15870b9a9 100644 --- a/jolt-core/src/poly/commitment/dory/wrappers.rs +++ b/jolt-core/src/poly/commitment/dory/wrappers.rs @@ -6,7 +6,7 @@ use crate::{ commitment::dory::{DoryContext, DoryGlobals, DoryLayout}, multilinear_polynomial::{MultilinearPolynomial, PolynomialEvaluation}, }, - transcripts::Transcript, + transcript_msgs::{FsAbsorb, FsChallenge}, }; use ark_bn254::Fr; use ark_ec::CurveGroup; @@ -412,11 +412,11 @@ where /// Wrapper to bridge Jolt transcripts to Dory transcript trait #[derive(Default)] -pub struct JoltToDoryTranscript<'a, T: Transcript> { +pub struct JoltToDoryTranscript<'a, T: FsAbsorb + FsChallenge> { transcript: Option<&'a mut T>, } -impl<'a, T: Transcript> JoltToDoryTranscript<'a, T> { +impl<'a, T: FsAbsorb + FsChallenge> JoltToDoryTranscript<'a, T> { pub fn new(transcript: &'a mut T) -> Self { Self { transcript: Some(transcript), @@ -424,7 +424,7 @@ impl<'a, T: Transcript> JoltToDoryTranscript<'a, T> { } } -impl<'a, T: Transcript> DoryTranscript for JoltToDoryTranscript<'a, T> { +impl<'a, T: FsAbsorb + FsChallenge> DoryTranscript for JoltToDoryTranscript<'a, T> { type Curve = BN254; fn append_bytes(&mut self, _label: &[u8], bytes: &[u8]) { @@ -432,7 +432,7 @@ impl<'a, T: Transcript> DoryTranscript for JoltToDoryTranscript<'a, T> { .transcript .as_mut() .expect("Transcript not initialized"); - transcript.append_bytes(b"dory_bytes", bytes); + transcript.absorb(&bytes.to_vec()); } fn append_field(&mut self, _label: &[u8], x: &ArkFr) { @@ -441,7 +441,7 @@ impl<'a, T: Transcript> DoryTranscript for JoltToDoryTranscript<'a, T> { .as_mut() .expect("Transcript not initialized"); let jolt_scalar: Fr = ark_to_jolt(x); - transcript.append_scalar(b"dory_field", &jolt_scalar); + transcript.absorb_scalar(&jolt_scalar); } fn append_group(&mut self, _label: &[u8], g: &G) { @@ -453,7 +453,7 @@ impl<'a, T: Transcript> DoryTranscript for JoltToDoryTranscript<'a, T> { let mut buffer = Vec::new(); g.serialize_compressed(&mut buffer) .expect("DorySerialize serialization should not fail"); - transcript.append_bytes(b"dory_group", &buffer); + transcript.absorb_commitment_bytes(&buffer); } fn append_serde(&mut self, _label: &[u8], s: &S) { @@ -465,7 +465,7 @@ impl<'a, T: Transcript> DoryTranscript for JoltToDoryTranscript<'a, T> { let mut buffer = Vec::new(); s.serialize_compressed(&mut buffer) .expect("DorySerialize serialization should not fail"); - transcript.append_bytes(b"dory_serde", &buffer); + transcript.absorb_commitment_bytes(&buffer); } fn challenge_scalar(&mut self, _label: &[u8]) -> ArkFr { @@ -473,7 +473,8 @@ impl<'a, T: Transcript> DoryTranscript for JoltToDoryTranscript<'a, T> { .transcript .as_mut() .expect("Transcript not initialized"); - jolt_to_ark(&transcript.challenge_scalar::()) + let challenge: Fr = transcript.challenge_field(); + jolt_to_ark(&challenge) } fn reset(&mut self, _domain_label: &[u8]) { diff --git a/jolt-core/src/poly/commitment/hyperkzg.rs b/jolt-core/src/poly/commitment/hyperkzg.rs index d3975ed093..057ce0dd87 100644 --- a/jolt-core/src/poly/commitment/hyperkzg.rs +++ b/jolt-core/src/poly/commitment/hyperkzg.rs @@ -19,7 +19,7 @@ use crate::zkvm::witness::CommittedPolynomial; use crate::{ msm::VariableBaseMSM, poly::{commitment::kzg::SRS, dense_mlpoly::DensePolynomial, unipoly::UniPoly}, - transcripts::Transcript, + transcript_msgs::{FsAbsorb, FsChallenge, ProverFs, VerifierFs}, utils::{errors::ProofVerifyError, small_scalar::SmallScalar}, }; use ark_ec::{pairing::Pairing, AffineRepr, CurveGroup}; @@ -127,15 +127,15 @@ where h } -fn absorb_hyperkzg_witness_commitments( - transcript: &mut ProofTranscript, +fn absorb_hyperkzg_witness_commitments>( + transcript: &mut T, witness_commitments: &[P::G1Affine], ) -> P::ScalarField where

::ScalarField: JoltField, { - transcript.append_commitments_serializable(b"hyperkzg_witness", witness_commitments); - transcript.challenge_scalar() + transcript.absorb(&witness_commitments.to_vec()); + transcript.challenge_field() } fn validate_hyperkzg_g1_point(point: &P::G1Affine) -> Result<(), ProofVerifyError> @@ -157,11 +157,11 @@ where Ok(()) } -fn kzg_open_batch( +fn kzg_open_batch>( f: &[MultilinearPolynomial], u: &[P::ScalarField], pk: &HyperKZGProverKey

, - transcript: &mut ProofTranscript, + transcript: &mut T, ) -> (Vec, Vec>) where

::ScalarField: JoltField, @@ -182,8 +182,8 @@ where // TODO(moodlezoup): Avoid cloned() let scalars: Vec = v.iter().flatten().cloned().collect(); - transcript.append_scalars(b"hyperkzg_evals", &scalars); - let q_powers: Vec = transcript.challenge_scalar_powers(f.len()); + transcript.absorb(&scalars); + let q_powers: Vec = transcript.challenge_powers(f.len()); let f_arc: Vec>> = f.iter().map(|poly| Arc::new(poly.clone())).collect(); @@ -209,20 +209,19 @@ where // The prover and verifier must absorb the witness commitments identically // so they derive the same Fiat-Shamir challenge. - let _d_0: P::ScalarField = - absorb_hyperkzg_witness_commitments::(transcript, &w); + let _d_0: P::ScalarField = absorb_hyperkzg_witness_commitments::(transcript, &w); (w, v) } // vk is hashed in transcript already, so we do not add it here -fn kzg_verify_batch( +fn kzg_verify_batch>( vk: &HyperKZGVerifierKey

, C: &[P::G1Affine], W: &[P::G1Affine], u: &[P::ScalarField], v: &[Vec], - transcript: &mut ProofTranscript, + transcript: &mut T, ) -> bool where

::ScalarField: JoltField, @@ -231,11 +230,10 @@ where let t = u.len(); let scalars: Vec = v.iter().flatten().cloned().collect(); - transcript.append_scalars(b"hyperkzg_evals", &scalars); - let q_powers: Vec = transcript.challenge_scalar_powers(k); + transcript.absorb(&scalars); + let q_powers: Vec = transcript.challenge_powers(k); - let d_0: P::ScalarField = - absorb_hyperkzg_witness_commitments::(transcript, W); + let d_0: P::ScalarField = absorb_hyperkzg_witness_commitments::(transcript, W); let d_1 = d_0 * d_0; assert_eq!(t, 3); @@ -327,12 +325,12 @@ where } #[tracing::instrument(skip_all, name = "HyperKZG::open")] - pub fn open( + pub fn open>( pk: &HyperKZGProverKey

, poly: &MultilinearPolynomial, point: &[::Challenge], _eval: &P::ScalarField, - transcript: &mut ProofTranscript, + transcript: &mut T, ) -> Result, ProofVerifyError> { let ell = point.len(); let n = poly.len(); @@ -364,8 +362,8 @@ where // Phase 2 // We do not need to add x to the transcript, because in our context x was obtained from the transcript. // We also do not need to absorb `C` and `eval` as they are already absorbed by the transcript by the caller - transcript.append_commitments_serializable(b"hyperkzg_com", &com); - let r:

::ScalarField = transcript.challenge_scalar(); + transcript.absorb(&com); + let r:

::ScalarField = transcript.challenge_field(); let u = vec![r, -r, r * r]; // Phase 3 -- create response @@ -375,13 +373,13 @@ where } /// A method to verify purported evaluations of a batch of polynomials - pub fn verify( + pub fn verify>( vk: &HyperKZGVerifierKey

, C: &HyperKZGCommitment

, point: &[::Challenge], P_of_x: &P::ScalarField, pi: &HyperKZGProof

, - transcript: &mut ProofTranscript, + transcript: &mut T, ) -> Result<(), ProofVerifyError> { let y = P_of_x; @@ -399,8 +397,8 @@ where // we do not need to add x to the transcript, because in our context x was // obtained from the transcript - transcript.append_commitments_serializable(b"hyperkzg_com", &com); - let r:

::ScalarField = transcript.challenge_scalar(); + transcript.absorb(&com); + let r:

::ScalarField = transcript.challenge_field(); if r == P::ScalarField::zero() { return Err(ProofVerifyError::InternalError); @@ -513,22 +511,22 @@ where HyperKZGCommitment(combined_commitment.into_affine()) } - fn prove( + fn prove>( setup: &Self::ProverSetup, poly: &MultilinearPolynomial, opening_point: &[::Challenge], // point at which the polynomial is evaluated _hint: Option, - transcript: &mut ProofTranscript, + transcript: &mut T, ) -> (Self::Proof, Option) { let eval = poly.evaluate(opening_point); let proof = HyperKZG::

::open(setup, poly, opening_point, &eval, transcript).unwrap(); (proof, None) // HyperKZG doesn't have ZK blinding } - fn verify( + fn verify>( proof: &Self::Proof, setup: &Self::VerifierSetup, - transcript: &mut ProofTranscript, + transcript: &mut T, opening_point: &[::Challenge], // point at which the polynomial is evaluated opening: &Self::Field, // evaluation \widetilde{Z}(r) commitment: &Self::Commitment, @@ -569,11 +567,12 @@ where #[cfg(test)] mod tests { use super::*; - use crate::transcripts::{Blake2bTranscript, Transcript}; + use crate::transcript_msgs::FsChallenge; use ark_bn254::{Bn254, Fq}; use ark_ec::AffineRepr; use ark_std::UniformRand; use ark_std::Zero; + use jolt_transcript::{prover_transcript, verifier_transcript, Blake2b512}; use rand::Rng; use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; @@ -716,29 +715,32 @@ mod tests { let C = HyperKZG::commit(&pk, &poly).unwrap(); // prove an evaluation - let mut prover_transcript = Blake2bTranscript::new(b"TestEval"); + let mut prover_ts = prover_transcript(b"TestEval", [0u8; 32], Blake2b512::default()); let proof: HyperKZGProof = - HyperKZG::open(&pk, &poly, &point, &eval, &mut prover_transcript).unwrap(); + HyperKZG::open(&pk, &poly, &point, &eval, &mut prover_ts).unwrap(); // verify the evaluation - let mut verifier_tr = Blake2bTranscript::new(b"TestEval"); + let mut verifier_tr = + verifier_transcript(b"TestEval", [0u8; 32], Blake2b512::default(), &[]); assert!(HyperKZG::verify(&vk, &C, &point, &eval, &proof, &mut verifier_tr,).is_ok()); - let post_c_p = prover_transcript.challenge_scalar::(); - let post_c_v = verifier_tr.challenge_scalar::(); + let post_c_p = FsChallenge::::challenge_field(&mut prover_ts); + let post_c_v = FsChallenge::::challenge_field(&mut verifier_tr); assert_eq!(post_c_p, post_c_v); // Change the proof and expect verification to fail let mut bad_proof = proof.clone(); let v1 = bad_proof.v[1].clone(); bad_proof.v[0].clone_from(&v1); - let mut verifier_tr2 = Blake2bTranscript::new(b"TestEval"); + let mut verifier_tr2 = + verifier_transcript(b"TestEval", [0u8; 32], Blake2b512::default(), &[]); assert!( HyperKZG::verify(&vk, &C, &point, &eval, &bad_proof, &mut verifier_tr2,).is_err() ); let mut bad_identity_commitment = C.clone(); bad_identity_commitment.0 = ::G1Affine::zero(); - let mut verifier_tr3 = Blake2bTranscript::new(b"TestEval"); + let mut verifier_tr3 = + verifier_transcript(b"TestEval", [0u8; 32], Blake2b512::default(), &[]); assert!(HyperKZG::verify( &vk, &bad_identity_commitment, @@ -752,7 +754,8 @@ mod tests { let mut bad_offcurve_proof = proof.clone(); bad_offcurve_proof.w[0] = ::G1Affine::new_unchecked(Fq::zero(), Fq::zero()); - let mut verifier_tr4 = Blake2bTranscript::new(b"TestEval"); + let mut verifier_tr4 = + verifier_transcript(b"TestEval", [0u8; 32], Blake2b512::default(), &[]); assert!(HyperKZG::verify( &vk, &C, diff --git a/jolt-core/src/poly/commitment/mock.rs b/jolt-core/src/poly/commitment/mock.rs index f0453a6def..1219b7fd2e 100644 --- a/jolt-core/src/poly/commitment/mock.rs +++ b/jolt-core/src/poly/commitment/mock.rs @@ -6,7 +6,7 @@ use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; use crate::{ field::JoltField, poly::multilinear_polynomial::MultilinearPolynomial, - transcripts::Transcript, + transcript_msgs::{ProverFs, VerifierFs}, utils::{errors::ProofVerifyError, small_scalar::SmallScalar}, }; @@ -76,12 +76,12 @@ where ) -> Self::OpeningProofHint { } - fn prove( + fn prove>( _setup: &Self::ProverSetup, _poly: &MultilinearPolynomial, opening_point: &[::Challenge], _hint: Option, - _transcript: &mut ProofTranscript, + _transcript: &mut T, ) -> (Self::Proof, Option) { ( MockProof { @@ -91,10 +91,10 @@ where ) } - fn verify( + fn verify>( proof: &Self::Proof, _setup: &Self::VerifierSetup, - _transcript: &mut ProofTranscript, + _transcript: &mut T, opening_point: &[::Challenge], _opening: &Self::Field, _commitment: &Self::Commitment, diff --git a/jolt-core/src/poly/opening_proof.rs b/jolt-core/src/poly/opening_proof.rs index 2d3ee6a5f5..641ce83e1e 100644 --- a/jolt-core/src/poly/opening_proof.rs +++ b/jolt-core/src/poly/opening_proof.rs @@ -24,7 +24,7 @@ use super::{ }; use crate::{ field::JoltField, - transcripts::Transcript, + transcript_msgs::{ProverFs, VerifierFs}, zkvm::witness::{CommittedPolynomial, VirtualPolynomial}, }; @@ -314,7 +314,7 @@ pub trait AbstractVerifierOpeningAccumulator: OpeningAccumulator(&mut self, transcript: &mut T); + fn flush_to_transcript>(&mut self, transcript: &mut T); /// Take pending claims (for ZK mode output commitment). fn take_pending_claims(&mut self) -> Vec; @@ -616,9 +616,9 @@ where self.insert_or_alias_opening(key, opening_point, claim); } - pub fn flush_to_transcript(&mut self, transcript: &mut T) { + pub fn flush_to_transcript>(&mut self, transcript: &mut T) { for claim in self.pending_claims.drain(..) { - transcript.append_scalar(b"opening_claim", &claim); + transcript.absorb_scalar(&claim); } self.pending_claim_ids.clear(); } @@ -738,9 +738,9 @@ impl AbstractVerifierOpeningAccumulator for VerifierOpeningAccu self.populate_or_alias_opening(key, opening_point); } - fn flush_to_transcript(&mut self, transcript: &mut T) { + fn flush_to_transcript>(&mut self, transcript: &mut T) { for claim in self.pending_claims.drain(..) { - transcript.append_scalar(b"opening_claim", &claim); + transcript.absorb_scalar(&claim); } self.pending_claim_ids.clear(); } @@ -768,6 +768,16 @@ where } } + /// Pre-seed the accumulator with a proof's structural opening claims (empty + /// points), as `JoltVerifier::new` does. + #[cfg(not(feature = "zk"))] + pub fn preseed_structural_claims(&mut self, claims: &Openings) { + for (id, (_, claim)) in claims { + self.openings + .insert(*id, (OpeningPoint::::new(vec![]), *claim)); + } + } + fn resolve_alias(&self, mut key: OpeningId) -> OpeningId { while let Some(next) = self.aliases.get(&key) { key = *next; diff --git a/jolt-core/src/subprotocols/blindfold/mod.rs b/jolt-core/src/subprotocols/blindfold/mod.rs index 5869314bde..82608444ba 100644 --- a/jolt-core/src/subprotocols/blindfold/mod.rs +++ b/jolt-core/src/subprotocols/blindfold/mod.rs @@ -26,8 +26,7 @@ pub use output_constraint::{ InputClaimConstraint, OutputClaimConstraint, ProductTerm, SumOfProductsVisitor, ValueSource, }; pub use protocol::{ - BlindFoldProof, BlindFoldProver, BlindFoldVerifier, BlindFoldVerifierInput, - BlindFoldVerifyError, + BlindFoldProver, BlindFoldVerifier, BlindFoldVerifierInput, BlindFoldVerifyError, }; pub use r1cs::{SparseR1CSMatrix, VerifierR1CS, VerifierR1CSBuilder}; pub use relaxed_r1cs::{RelaxedR1CSInstance, RelaxedR1CSWitness}; diff --git a/jolt-core/src/subprotocols/blindfold/protocol.rs b/jolt-core/src/subprotocols/blindfold/protocol.rs index 1d7ccb3193..67f4ba418a 100644 --- a/jolt-core/src/subprotocols/blindfold/protocol.rs +++ b/jolt-core/src/subprotocols/blindfold/protocol.rs @@ -6,15 +6,13 @@ //! 3. Using Spartan sumcheck to prove R1CS satisfaction without revealing the witness //! 4. Hyrax-style openings to verify W(ry) and E(rx) evaluations -use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; - use crate::curve::{JoltCurve, JoltGroupElement}; use crate::field::JoltField; use crate::poly::commitment::hyrax::{self as hyrax, HyraxOpeningProof}; use crate::poly::commitment::pedersen::PedersenGenerators; use crate::poly::eq_poly::EqPolynomial; use crate::poly::unipoly::CompressedUniPoly; -use crate::transcripts::Transcript; +use crate::transcript_msgs::{ProverFs, VerifierFs}; use crate::utils::math::Math; use super::folding::{commit_cross_term_rows, compute_cross_term, sample_random_satisfying_pair}; @@ -22,37 +20,6 @@ use super::r1cs::VerifierR1CS; use super::relaxed_r1cs::{RelaxedR1CSInstance, RelaxedR1CSWitness}; use super::spartan::{INNER_SUMCHECK_DEGREE_BOUND, SPARTAN_DEGREE_BOUND}; -/// BlindFold proof with Hyrax-style openings for W and E. -/// -/// The real instance is NOT included — verifier reconstructs from round_commitments, -/// eval_commitments, public_inputs. The random instance IS included — verifier reads -/// it from the proof and absorbs into transcript, never learning the random witness. -#[derive(Clone, Debug, CanonicalSerialize, CanonicalDeserialize)] -pub struct BlindFoldProof> { - pub random_instance: RelaxedR1CSInstance, - - /// Non-coefficient W row commitments from the real instance - pub noncoeff_row_commitments: Vec, - /// Cross-term T row commitments (E grid layout) - pub cross_term_row_commitments: Vec, - pub spartan_proof: Vec>, - pub az_r: F, - pub bz_r: F, - pub cz_r: F, - pub inner_sumcheck_proof: Vec>, - pub w_opening: HyraxOpeningProof, - pub e_opening: HyraxOpeningProof, - /// Folded eval output values (one per extra constraint / eval_commitment). - /// Safe to reveal: folded = real + r*random, where random is a one-time pad. - pub folded_eval_outputs: Vec, - /// Folded eval blinding values (one per extra constraint / eval_commitment). - pub folded_eval_blindings: Vec, - /// Folded witness row openings for the evaluation variables used by final PCS bindings. - pub folded_eval_output_openings: Vec>, - /// Folded witness row openings for the blinding variables used by final PCS bindings. - pub folded_eval_blinding_openings: Vec>, -} - pub struct BlindFoldProver<'a, F: JoltField, C: JoltCurve> { gens: &'a PedersenGenerators, r1cs: &'a VerifierR1CS, @@ -73,27 +40,18 @@ impl<'a, F: JoltField, C: JoltCurve> BlindFoldProver<'a, F, C> { } #[tracing::instrument(skip_all, name = "BlindFoldProver::prove")] - pub fn prove( + pub fn prove( &self, real_instance: &RelaxedR1CSInstance, real_witness: &RelaxedR1CSWitness, real_z: &[F], - transcript: &mut T, - ) -> BlindFoldProof { + transcript: &mut impl ProverFs, + ) { use super::spartan::{BlindFoldInnerSumcheckProver, BlindFoldSpartanProver}; let mut rng = rand::thread_rng(); - append_instance_to_transcript( - real_instance, - self.r1cs, - b"bf_committed_u", - b"bf_committed_w", - b"bf_committed_e", - b"bf_committed_eval", - transcript, - ) - .expect("prover-controlled real instance shape must match verifier R1CS"); + write_instance_to_transcript(real_instance, transcript, InstanceRole::Real); let (random_instance, random_witness, random_z) = sample_random_satisfying_pair( self.gens, @@ -102,16 +60,7 @@ impl<'a, F: JoltField, C: JoltCurve> BlindFoldProver<'a, F, C> { &mut rng, ); - append_instance_to_transcript( - &random_instance, - self.r1cs, - b"bf_random_u", - b"bf_random_w", - b"bf_random_e", - b"bf_random_eval", - transcript, - ) - .expect("prover-controlled random instance shape must match verifier R1CS"); + write_instance_to_transcript(&random_instance, transcript, InstanceRole::Random); let T = compute_cross_term( self.r1cs, @@ -126,9 +75,9 @@ impl<'a, F: JoltField, C: JoltCurve> BlindFoldProver<'a, F, C> { let (t_row_commitments, t_row_blindings) = commit_cross_term_rows(self.gens, &T, R_E, C_E, &mut rng); - transcript.append_commitments(b"bf_cross_e", &t_row_commitments); + transcript.write_slice(&t_row_commitments); - let r: F::Challenge = transcript.challenge_scalar_optimized::(); + let r: F::Challenge = transcript.challenge_optimized(); let r_field: F = r.into(); let folded_instance = real_instance @@ -172,21 +121,17 @@ impl<'a, F: JoltField, C: JoltCurve> BlindFoldProver<'a, F, C> { }) .collect(); + // Bind the folded eval outputs/blindings to the NARG before the eval-commitment + // check (now bound before `tau` — a deliberate clean-break FS change). Safe to + // reveal: each folded value = real + r*random, where `random` is a one-time pad. + transcript.write_slice(&folded_eval_outputs); + transcript.write_slice(&folded_eval_blindings); + for opening in &folded_eval_output_openings { - append_hyrax_opening( - transcript, - b"bf_eval_out_open", - b"bf_eval_out_blind", - opening, - ); + write_hyrax_opening(transcript, opening); } for opening in &folded_eval_blinding_openings { - append_hyrax_opening( - transcript, - b"bf_eval_blind_open", - b"bf_eval_blind_bl", - opening, - ); + write_hyrax_opening(transcript, opening); } let mut folded_z = Vec::with_capacity(self.r1cs.num_vars); @@ -198,24 +143,21 @@ impl<'a, F: JoltField, C: JoltCurve> BlindFoldProver<'a, F, C> { e_padded.resize(padded_e_len, F::zero()); let e_for_hyrax = e_padded.clone(); - transcript.append_label(b"bf_spartan"); let num_vars = padded_e_len.log_2(); - let tau: Vec<_> = transcript.challenge_vector_optimized::(num_vars); + let tau: Vec<_> = transcript.challenge_optimized_vec(num_vars); let mut spartan_prover = BlindFoldSpartanProver::new(self.r1cs, folded_instance.u, folded_z, e_padded, tau); - let mut spartan_proof = Vec::with_capacity(num_vars); let mut spartan_challenges: Vec = Vec::with_capacity(num_vars); let mut claim = F::zero(); for _ in 0..num_vars { let poly = spartan_prover.compute_round_polynomial(claim); let compressed = poly.compress(); - transcript.append_scalars(b"sumcheck_poly", &compressed.coeffs_except_linear_term); - spartan_proof.push(compressed); + transcript.write_slice(&compressed.coeffs_except_linear_term); - let r_j = transcript.challenge_scalar_optimized::(); + let r_j = transcript.challenge_optimized(); claim = poly.evaluate(&r_j); spartan_prover.bind_challenge(r_j); spartan_challenges.push(r_j); @@ -236,17 +178,12 @@ impl<'a, F: JoltField, C: JoltCurve> BlindFoldProver<'a, F, C> { combined_blinding: e_combined_blinding, }; - transcript.append_scalars(b"bf_az_bz_cz", &[az_r, bz_r, cz_r]); - append_hyrax_opening( - transcript, - b"bf_error_opening", - b"bf_error_blind", - &e_opening, - ); + transcript.write_slice(&[az_r, bz_r, cz_r]); + write_hyrax_opening(transcript, &e_opening); - let ra: F = transcript.challenge_scalar_optimized::().into(); - let rb: F = transcript.challenge_scalar_optimized::().into(); - let rc: F = transcript.challenge_scalar_optimized::().into(); + let ra: F = transcript.challenge_optimized().into(); + let rb: F = transcript.challenge_optimized().into(); + let rc: F = transcript.challenge_optimized().into(); let w_for_inner = folded_W.clone(); let mut inner_prover = BlindFoldInnerSumcheckProver::new( @@ -259,7 +196,6 @@ impl<'a, F: JoltField, C: JoltCurve> BlindFoldProver<'a, F, C> { ); let inner_num_vars = inner_prover.num_vars(); - let mut inner_proof = Vec::with_capacity(inner_num_vars); let mut inner_challenges: Vec = Vec::with_capacity(inner_num_vars); let (w_az, w_bz, w_cz) = spartan_prover.witness_contributions(&spartan_challenges); @@ -274,13 +210,9 @@ impl<'a, F: JoltField, C: JoltCurve> BlindFoldProver<'a, F, C> { ); let compressed = poly.compress(); - transcript.append_scalars( - b"inner_sumcheck_poly", - &compressed.coeffs_except_linear_term, - ); - inner_proof.push(compressed); + transcript.write_slice(&compressed.coeffs_except_linear_term); - let r_j = transcript.challenge_scalar_optimized::(); + let r_j = transcript.challenge_optimized(); inner_claim = poly.evaluate(&r_j); inner_prover.bind_challenge(r_j); inner_challenges.push(r_j); @@ -295,28 +227,11 @@ impl<'a, F: JoltField, C: JoltCurve> BlindFoldProver<'a, F, C> { combined_row: w_combined_row, combined_blinding: w_combined_blinding, }; - append_hyrax_opening( - transcript, - b"bf_witness_opening", - b"bf_witness_blind", - &w_opening, - ); - BlindFoldProof { - random_instance, - noncoeff_row_commitments: real_instance.noncoeff_row_commitments.clone(), - cross_term_row_commitments: t_row_commitments, - spartan_proof, - az_r, - bz_r, - cz_r, - inner_sumcheck_proof: inner_proof, - w_opening, - e_opening, - folded_eval_outputs, - folded_eval_blindings, - folded_eval_output_openings, - folded_eval_blinding_openings, - } + write_hyrax_opening(transcript, &w_opening); + + // All prover-only BlindFold values now live in the NARG (written above via + // `write_slice` at their natural absorb positions); the verifier reconstructs + // every value from the NARG, so `prove` returns no proof object. } } @@ -335,26 +250,69 @@ fn open_witness_variable( } } -fn append_hyrax_opening( - transcript: &mut impl Transcript, - row_label: &'static [u8], - blinding_label: &'static [u8], +/// Bind a Hyrax opening to the NARG (Option B): `combined_row` then `combined_blinding`, +/// at the same two positions the structured proof previously `absorb`'d them. +fn write_hyrax_opening( + transcript: &mut impl ProverFs, opening: &HyraxOpeningProof, ) { - transcript.append_scalars(row_label, &opening.combined_row); - transcript.append_scalar(blinding_label, &opening.combined_blinding); + transcript.write_slice(&opening.combined_row); + transcript.write_slice(std::slice::from_ref(&opening.combined_blinding)); +} + +/// Read one self-delimiting NARG frame as a `Vec`, mapping any read failure to +/// [`BlindFoldVerifyError::MalformedProof`]. +fn read_vec( + transcript: &mut impl VerifierFs, +) -> Result, BlindFoldVerifyError> { + transcript + .read_slice() + .map_err(|_| BlindFoldVerifyError::MalformedProof) +} + +/// Read a NARG frame carrying exactly one `T` (rejects empty/over-long frames), mapping +/// any failure to [`BlindFoldVerifyError::MalformedProof`]. +fn read_one( + transcript: &mut impl VerifierFs, +) -> Result { + transcript + .read_single() + .map_err(|_| BlindFoldVerifyError::MalformedProof) } +/// Read a Hyrax opening back from the NARG (matching [`write_hyrax_opening`]): the +/// `combined_row` frame, then the single `combined_blinding`. +fn read_hyrax_opening( + transcript: &mut impl VerifierFs, + expected_len: usize, +) -> Result, BlindFoldVerifyError> { + let combined_row: Vec = read_vec(transcript)?; + // Guard against an adversarial over-/under-long frame before it reaches + // `gens.commit`, which asserts `combined_row.len() <= message_generators.len()`. + if combined_row.len() != expected_len { + return Err(BlindFoldVerifyError::MalformedProof); + } + let combined_blinding: F = read_one(transcript)?; + Ok(HyraxOpeningProof { + combined_row, + combined_blinding, + }) +} + +#[allow(clippy::too_many_arguments)] fn verify_folded_eval_witness_bindings>( r1cs: &VerifierR1CS, gens: &PedersenGenerators, folded_instance: &RelaxedR1CSInstance, - proof: &BlindFoldProof, + folded_eval_outputs: &[F], + folded_eval_blindings: &[F], + folded_eval_output_openings: &[HyraxOpeningProof], + folded_eval_blinding_openings: &[HyraxOpeningProof], ) -> Result<(), BlindFoldVerifyError> { - if proof.folded_eval_output_openings.len() != r1cs.extra_output_vars.len() - || proof.folded_eval_blinding_openings.len() != r1cs.extra_blinding_vars.len() - || proof.folded_eval_outputs.len() != r1cs.extra_output_vars.len() - || proof.folded_eval_blindings.len() != r1cs.extra_blinding_vars.len() + if folded_eval_output_openings.len() != r1cs.extra_output_vars.len() + || folded_eval_blinding_openings.len() != r1cs.extra_blinding_vars.len() + || folded_eval_outputs.len() != r1cs.extra_output_vars.len() + || folded_eval_blindings.len() != r1cs.extra_blinding_vars.len() { return Err(BlindFoldVerifyError::MalformedProof); } @@ -362,12 +320,12 @@ fn verify_folded_eval_witness_bindings>( for (index, (&variable, opening)) in r1cs .extra_output_vars .iter() - .zip(&proof.folded_eval_output_openings) + .zip(folded_eval_output_openings) .enumerate() { let opened = verify_witness_variable_opening(r1cs, gens, folded_instance, variable, opening)?; - if opened != proof.folded_eval_outputs[index] { + if opened != folded_eval_outputs[index] { return Err(BlindFoldVerifyError::EvalWitnessMismatch); } } @@ -375,12 +333,12 @@ fn verify_folded_eval_witness_bindings>( for (index, (&variable, opening)) in r1cs .extra_blinding_vars .iter() - .zip(&proof.folded_eval_blinding_openings) + .zip(folded_eval_blinding_openings) .enumerate() { let opened = verify_witness_variable_opening(r1cs, gens, folded_instance, variable, opening)?; - if opened != proof.folded_eval_blindings[index] { + if opened != folded_eval_blindings[index] { return Err(BlindFoldVerifyError::EvalWitnessMismatch); } } @@ -426,12 +384,10 @@ fn verify_witness_variable_opening>( #[derive(Clone, Debug, PartialEq, Eq)] pub enum BlindFoldVerifyError { SpartanSumcheckFailed(usize), - WrongSpartanProofLength { expected: usize, got: usize }, DegreeBoundExceeded { expected: usize, got: usize }, MalformedProof, EOpeningFailed, OuterClaimMismatch, - WrongInnerSumcheckLength { expected: usize, got: usize }, InnerSumcheckFailed(usize), WOpeningFailed, FinalClaimMismatch, @@ -442,7 +398,7 @@ pub enum BlindFoldVerifyError { pub struct BlindFoldVerifierInput { pub round_commitments: Vec, - /// Hyrax OC row commitments, extracted from stage proofs (not from BlindFoldProof). + /// Hyrax OC row commitments, extracted from stage proofs (not from the NARG). pub output_claims_row_commitments: Vec, pub eval_commitments: Vec, } @@ -466,134 +422,120 @@ impl<'a, F: JoltField, C: JoltCurve> BlindFoldVerifier<'a, F, C> { } } + /// Verify the BlindFold proof by reconstructing every prover-only value from the NARG + /// at its natural absorb position (Option B). There is no proof struct to read from; + /// all data is read from `transcript`. #[tracing::instrument(skip_all, name = "BlindFoldVerifier::verify")] - pub fn verify( + pub fn verify( &self, - proof: &BlindFoldProof, - input: &BlindFoldVerifierInput, - transcript: &mut T, + input: BlindFoldVerifierInput, + transcript: &mut impl VerifierFs, ) -> Result<(), BlindFoldVerifyError> { use super::spartan::{compute_L_w_at_ry, BlindFoldSpartanVerifier}; let hyrax = &self.r1cs.hyrax; - let (R_E, _C_E) = hyrax.e_grid(self.r1cs.num_constraints); + let (R_E, C_E) = hyrax.e_grid(self.r1cs.num_constraints); let expected_noncoeff_rows = hyrax.regular_noncoeff_rows(); let expected_oc_rows = hyrax.output_claims_rows; - if proof.noncoeff_row_commitments.len() != expected_noncoeff_rows - || proof.random_instance.noncoeff_row_commitments.len() != expected_noncoeff_rows - { - return Err(BlindFoldVerifyError::MalformedProof); - } - if input.round_commitments.len() != hyrax.R_coeff - || proof.random_instance.round_commitments.len() != hyrax.R_coeff - { - return Err(BlindFoldVerifyError::MalformedProof); - } - if input.output_claims_row_commitments.len() != expected_oc_rows - || proof.random_instance.output_claims_row_commitments.len() != expected_oc_rows - { + if input.round_commitments.len() != hyrax.R_coeff { return Err(BlindFoldVerifyError::MalformedProof); } - if proof.random_instance.e_row_commitments.len() != R_E { + if input.output_claims_row_commitments.len() != expected_oc_rows { return Err(BlindFoldVerifyError::MalformedProof); } - let real_instance = RelaxedR1CSInstance { - u: F::one(), - round_commitments: input.round_commitments.clone(), - output_claims_row_commitments: input.output_claims_row_commitments.clone(), - noncoeff_row_commitments: proof.noncoeff_row_commitments.clone(), - e_row_commitments: vec![C::G1::zero(); R_E], - eval_commitments: input.eval_commitments.clone(), - }; - - append_instance_to_transcript( - &real_instance, - self.r1cs, - b"bf_committed_u", - b"bf_committed_w", - b"bf_committed_e", - b"bf_committed_eval", + // Step 1: real instance — shared fields absorbed, prover-only noncoeff read from NARG. + let real_instance = read_real_instance_from_transcript::( + F::one(), + input.round_commitments, + input.output_claims_row_commitments, + vec![C::G1::zero(); R_E], + input.eval_commitments, transcript, )?; + if real_instance.noncoeff_row_commitments.len() != expected_noncoeff_rows { + return Err(BlindFoldVerifyError::MalformedProof); + } - append_instance_to_transcript( - &proof.random_instance, - self.r1cs, - b"bf_random_u", - b"bf_random_w", - b"bf_random_e", - b"bf_random_eval", - transcript, - )?; + // Step 2: random instance — every field read from NARG (prover-only, sampled). + let random_instance = read_random_instance_from_transcript::(transcript)?; + if random_instance.noncoeff_row_commitments.len() != expected_noncoeff_rows + || random_instance.round_commitments.len() != hyrax.R_coeff + || random_instance.output_claims_row_commitments.len() != expected_oc_rows + || random_instance.e_row_commitments.len() != R_E + { + return Err(BlindFoldVerifyError::MalformedProof); + } - transcript.append_commitments(b"bf_cross_e", &proof.cross_term_row_commitments); + // Step 3: cross-term row commitments. + let cross_term_row_commitments: Vec = read_vec(transcript)?; - let r: F::Challenge = transcript.challenge_scalar_optimized::(); + let r: F::Challenge = transcript.challenge_optimized(); let r_field: F = r.into(); - let folded_instance = real_instance.fold( - &proof.random_instance, - &proof.cross_term_row_commitments, - r_field, - )?; + let folded_instance = + real_instance.fold(&random_instance, &cross_term_row_commitments, r_field)?; + + // Step 5: folded eval outputs/blindings (now bound before `tau`). + let folded_eval_outputs: Vec = read_vec(transcript)?; + let folded_eval_blindings: Vec = read_vec(transcript)?; if let Some((g1_0, h1)) = self.eval_commitment_gens { - if proof.folded_eval_outputs.len() != folded_instance.eval_commitments.len() - || proof.folded_eval_blindings.len() != folded_instance.eval_commitments.len() + if folded_eval_outputs.len() != folded_instance.eval_commitments.len() + || folded_eval_blindings.len() != folded_instance.eval_commitments.len() { return Err(BlindFoldVerifyError::MalformedProof); } for (i, eval_com) in folded_instance.eval_commitments.iter().enumerate() { - let expected = g1_0.scalar_mul(&proof.folded_eval_outputs[i]) - + h1.scalar_mul(&proof.folded_eval_blindings[i]); + let expected = g1_0.scalar_mul(&folded_eval_outputs[i]) + + h1.scalar_mul(&folded_eval_blindings[i]); if *eval_com != expected { return Err(BlindFoldVerifyError::EvalCommitmentMismatch); } } } - verify_folded_eval_witness_bindings(self.r1cs, self.gens, &folded_instance, proof)?; - for opening in &proof.folded_eval_output_openings { - append_hyrax_opening( - transcript, - b"bf_eval_out_open", - b"bf_eval_out_blind", - opening, - ); + + // Step 6: folded eval witness openings (count fixed by the R1CS). + let mut folded_eval_output_openings = Vec::with_capacity(self.r1cs.extra_output_vars.len()); + for _ in 0..self.r1cs.extra_output_vars.len() { + folded_eval_output_openings.push(read_hyrax_opening(transcript, self.r1cs.hyrax.C)?); } - for opening in &proof.folded_eval_blinding_openings { - append_hyrax_opening( - transcript, - b"bf_eval_blind_open", - b"bf_eval_blind_bl", - opening, - ); + let mut folded_eval_blinding_openings = + Vec::with_capacity(self.r1cs.extra_blinding_vars.len()); + for _ in 0..self.r1cs.extra_blinding_vars.len() { + folded_eval_blinding_openings.push(read_hyrax_opening(transcript, self.r1cs.hyrax.C)?); } - transcript.append_label(b"bf_spartan"); - let num_vars = self.r1cs.num_constraints.next_power_of_two().log_2(); + verify_folded_eval_witness_bindings( + self.r1cs, + self.gens, + &folded_instance, + &folded_eval_outputs, + &folded_eval_blindings, + &folded_eval_output_openings, + &folded_eval_blinding_openings, + )?; - if proof.spartan_proof.len() != num_vars { - return Err(BlindFoldVerifyError::WrongSpartanProofLength { - expected: num_vars, - got: proof.spartan_proof.len(), - }); - } + let num_vars = self.r1cs.num_constraints.next_power_of_two().log_2(); - let tau: Vec<_> = transcript.challenge_vector_optimized::(num_vars); + // Step 7: tau. + let tau: Vec<_> = transcript.challenge_optimized_vec(num_vars); let mut claim = F::zero(); let mut challenges: Vec = Vec::with_capacity(num_vars); - for (round, compressed_poly) in proof.spartan_proof.iter().enumerate() { - if compressed_poly.coeffs_except_linear_term.len() != SPARTAN_DEGREE_BOUND { + // Step 8: spartan rounds — each round's compressed coeffs read from NARG. + for round in 0..num_vars { + let coeffs_except_linear_term: Vec = read_vec(transcript)?; + if coeffs_except_linear_term.len() != SPARTAN_DEGREE_BOUND { return Err(BlindFoldVerifyError::DegreeBoundExceeded { expected: SPARTAN_DEGREE_BOUND, - got: compressed_poly.coeffs_except_linear_term.len(), + got: coeffs_except_linear_term.len(), }); } - - transcript.append_scalars(b"sumcheck_poly", &compressed_poly.coeffs_except_linear_term); + let compressed_poly = CompressedUniPoly { + coeffs_except_linear_term, + }; let poly = compressed_poly.decompress(&claim); let sum = poly.coeffs[0] + poly.coeffs.iter().sum::(); @@ -601,26 +543,27 @@ impl<'a, F: JoltField, C: JoltCurve> BlindFoldVerifier<'a, F, C> { return Err(BlindFoldVerifyError::SpartanSumcheckFailed(round)); } - let r_j = transcript.challenge_scalar_optimized::(); + let r_j = transcript.challenge_optimized(); challenges.push(r_j); claim = poly.evaluate(&r_j); } - let az_r = proof.az_r; - let bz_r = proof.bz_r; - let cz_r = proof.cz_r; + // Step 9: az_r/bz_r/cz_r as one frame of 3. + let azbzcz: Vec = read_vec(transcript)?; + if azbzcz.len() != 3 { + return Err(BlindFoldVerifyError::MalformedProof); + } + let az_r = azbzcz[0]; + let bz_r = azbzcz[1]; + let cz_r = azbzcz[2]; - transcript.append_scalars(b"bf_az_bz_cz", &[az_r, bz_r, cz_r]); - append_hyrax_opening( - transcript, - b"bf_error_opening", - b"bf_error_blind", - &proof.e_opening, - ); + // Step 10: E opening. Its `combined_row` is the E-grid row of width `C_E` + // (= min(hyrax.C, padded_e_len)), which can be narrower than `hyrax.C`. + let e_opening = read_hyrax_opening(transcript, C_E)?; - let ra: F = transcript.challenge_scalar_optimized::().into(); - let rb: F = transcript.challenge_scalar_optimized::().into(); - let rc: F = transcript.challenge_scalar_optimized::().into(); + let ra: F = transcript.challenge_optimized().into(); + let rb: F = transcript.challenge_optimized().into(); + let rc: F = transcript.challenge_optimized().into(); let spartan_verifier = BlindFoldSpartanVerifier::new(self.r1cs, tau, folded_instance.u); @@ -630,27 +573,20 @@ impl<'a, F: JoltField, C: JoltCurve> BlindFoldVerifier<'a, F, C> { let w_padded_len = hyrax.R_prime * hyrax.C; let inner_num_vars = w_padded_len.log_2(); - if proof.inner_sumcheck_proof.len() != inner_num_vars { - return Err(BlindFoldVerifyError::WrongInnerSumcheckLength { - expected: inner_num_vars, - got: proof.inner_sumcheck_proof.len(), - }); - } - let mut inner_challenges: Vec = Vec::with_capacity(inner_num_vars); - for (round, compressed_poly) in proof.inner_sumcheck_proof.iter().enumerate() { - if compressed_poly.coeffs_except_linear_term.len() != INNER_SUMCHECK_DEGREE_BOUND { + // Step 12: inner sumcheck rounds — each round's compressed coeffs read from NARG. + for round in 0..inner_num_vars { + let coeffs_except_linear_term: Vec = read_vec(transcript)?; + if coeffs_except_linear_term.len() != INNER_SUMCHECK_DEGREE_BOUND { return Err(BlindFoldVerifyError::DegreeBoundExceeded { expected: INNER_SUMCHECK_DEGREE_BOUND, - got: compressed_poly.coeffs_except_linear_term.len(), + got: coeffs_except_linear_term.len(), }); } - - transcript.append_scalars( - b"inner_sumcheck_poly", - &compressed_poly.coeffs_except_linear_term, - ); + let compressed_poly = CompressedUniPoly { + coeffs_except_linear_term, + }; let poly = compressed_poly.decompress(&inner_claim); let sum = poly.coeffs[0] + poly.coeffs.iter().sum::(); @@ -658,7 +594,7 @@ impl<'a, F: JoltField, C: JoltCurve> BlindFoldVerifier<'a, F, C> { return Err(BlindFoldVerifyError::InnerSumcheckFailed(round)); } - let r_j = transcript.challenge_scalar_optimized::(); + let r_j = transcript.challenge_optimized(); inner_challenges.push(r_j); inner_claim = poly.evaluate(&r_j); } @@ -669,14 +605,13 @@ impl<'a, F: JoltField, C: JoltCurve> BlindFoldVerifier<'a, F, C> { let eq_rx_row: Vec = EqPolynomial::evals(rx_row); let c_combined_e = C::g1_msm(&folded_instance.e_row_commitments, &eq_rx_row); - let expected_e_com = self.gens.commit( - &proof.e_opening.combined_row, - &proof.e_opening.combined_blinding, - ); + let expected_e_com = self + .gens + .commit(&e_opening.combined_row, &e_opening.combined_blinding); if c_combined_e != expected_e_com { return Err(BlindFoldVerifyError::EOpeningFailed); } - let e_r = hyrax::evaluate(&proof.e_opening.combined_row, rx_col); + let e_r = hyrax::evaluate(&e_opening.combined_row, rx_col); let expected_outer = spartan_verifier.expected_claim(&challenges, az_r, bz_r, cz_r, e_r); if claim != expected_outer { @@ -690,47 +625,121 @@ impl<'a, F: JoltField, C: JoltCurve> BlindFoldVerifier<'a, F, C> { let all_w_rows = folded_instance.all_w_row_commitments(hyrax.R_coeff, hyrax.R_prime)?; let eq_ry_row: Vec = EqPolynomial::evals(ry_row); let c_combined_w = C::g1_msm(&all_w_rows, &eq_ry_row); - let expected_w_com = self.gens.commit( - &proof.w_opening.combined_row, - &proof.w_opening.combined_blinding, - ); + + // Step 13: W opening — read, check, then bind to the transcript (matching the prover, + // which binds it last). + let w_opening = read_hyrax_opening(transcript, hyrax.C)?; + let expected_w_com = self + .gens + .commit(&w_opening.combined_row, &w_opening.combined_blinding); if c_combined_w != expected_w_com { return Err(BlindFoldVerifyError::WOpeningFailed); } - let w_ry = hyrax::evaluate(&proof.w_opening.combined_row, ry_col); + let w_ry = hyrax::evaluate(&w_opening.combined_row, ry_col); let l_w_at_ry = compute_L_w_at_ry(self.r1cs, &challenges, &inner_challenges, ra, rb, rc); let expected_inner_final = l_w_at_ry * w_ry; if inner_claim != expected_inner_final { return Err(BlindFoldVerifyError::FinalClaimMismatch); } - append_hyrax_opening( - transcript, - b"bf_witness_opening", - b"bf_witness_blind", - &proof.w_opening, - ); Ok(()) } } -fn append_instance_to_transcript>( +/// Which instance is being bound — controls per-field NARG transport (Option B). +/// +/// The instance fields are bound to the transcript field-by-field (round → oc → noncoeff +/// → e_row → eval) in a fixed order; spongefish provides positional domain separation, so +/// the legacy per-field labels are dropped. For the REAL instance all components except +/// `noncoeff_row_commitments` are SHARED (the verifier already holds them in +/// `BlindFoldVerifierInput` / derives `e_row = 0`), so they are `absorb`'d; only +/// `noncoeff_row_commitments` is prover-only and crosses the NARG. For the RANDOM instance +/// every field is prover-only (freshly sampled), so every field crosses the NARG. +#[derive(Clone, Copy, PartialEq, Eq)] +enum InstanceRole { + Real, + Random, +} + +/// Prover side of binding an instance to the transcript (Option B): shared fields are +/// `absorb`'d (not shipped), prover-only fields are `write_slice`'d into the NARG. The +/// bytes written are byte-identical to what the structured proof previously absorbed — +/// the transport changes, not the content. +fn write_instance_to_transcript>( instance: &RelaxedR1CSInstance, - r1cs: &VerifierR1CS, - u_label: &'static [u8], - witness_label: &'static [u8], - error_label: &'static [u8], - eval_label: &'static [u8], - transcript: &mut impl Transcript, -) -> Result<(), BlindFoldVerifyError> { - let witness_row_commitments = - instance.all_w_row_commitments(r1cs.hyrax.R_coeff, r1cs.hyrax.R_prime)?; - transcript.append_scalar(u_label, &instance.u); - transcript.append_commitments(witness_label, &witness_row_commitments); - transcript.append_commitments(error_label, &instance.e_row_commitments); - transcript.append_commitments(eval_label, &instance.eval_commitments); - Ok(()) + transcript: &mut impl ProverFs, + role: InstanceRole, +) { + match role { + InstanceRole::Real => { + transcript.absorb(&instance.u); + transcript.absorb(&instance.round_commitments); + transcript.absorb(&instance.output_claims_row_commitments); + transcript.write_slice(&instance.noncoeff_row_commitments); + transcript.absorb(&instance.e_row_commitments); + transcript.absorb(&instance.eval_commitments); + } + InstanceRole::Random => { + transcript.write_slice(std::slice::from_ref(&instance.u)); + transcript.write_slice(&instance.round_commitments); + transcript.write_slice(&instance.output_claims_row_commitments); + transcript.write_slice(&instance.noncoeff_row_commitments); + transcript.write_slice(&instance.e_row_commitments); + transcript.write_slice(&instance.eval_commitments); + } + } +} + +/// Verifier side of binding the REAL instance: the shared components are supplied by the +/// caller (from `BlindFoldVerifierInput` / `e_row = 0`) and `absorb`'d; the prover-only +/// `noncoeff_row_commitments` is `read_slice`'d from the NARG at the matching position. +/// Returns the reconstructed real instance. +fn read_real_instance_from_transcript>( + u: F, + round_commitments: Vec, + output_claims_row_commitments: Vec, + e_row_commitments: Vec, + eval_commitments: Vec, + transcript: &mut impl VerifierFs, +) -> Result, BlindFoldVerifyError> { + transcript.absorb(&u); + transcript.absorb(&round_commitments); + transcript.absorb(&output_claims_row_commitments); + let noncoeff_row_commitments: Vec = read_vec(transcript)?; + transcript.absorb(&e_row_commitments); + transcript.absorb(&eval_commitments); + Ok(RelaxedR1CSInstance { + u, + round_commitments, + output_claims_row_commitments, + noncoeff_row_commitments, + e_row_commitments, + eval_commitments, + }) +} + +/// Verifier side of binding the RANDOM instance: every field is prover-only, so each is +/// `read_slice`'d from the NARG (which absorbs it at the matching position) and the +/// instance is reconstructed entirely from the reads. The verifier never learns the +/// random witness — only the (hiding) commitments. +fn read_random_instance_from_transcript>( + transcript: &mut impl VerifierFs, +) -> Result, BlindFoldVerifyError> { + let u: F = read_one(transcript)?; + let round_commitments: Vec = read_vec(transcript)?; + let output_claims_row_commitments: Vec = read_vec(transcript)?; + let noncoeff_row_commitments: Vec = read_vec(transcript)?; + let e_row_commitments: Vec = read_vec(transcript)?; + let eval_commitments: Vec = read_vec(transcript)?; + Ok(RelaxedR1CSInstance { + u, + round_commitments, + output_claims_row_commitments, + noncoeff_row_commitments, + e_row_commitments, + eval_commitments, + }) } #[cfg(test)] @@ -740,11 +749,37 @@ mod tests { use crate::subprotocols::blindfold::r1cs::VerifierR1CSBuilder; use crate::subprotocols::blindfold::witness::{BlindFoldWitness, RoundWitness, StageWitness}; use crate::subprotocols::blindfold::{BakedPublicInputs, StageConfig}; - use crate::transcripts::KeccakTranscript; use ark_bn254::Fr; use ark_std::Zero; + use jolt_transcript::{Blake2b512, ProverState, VerifierState}; + use rand::rngs::StdRng; use rand::thread_rng; + const TEST_INSTANCE: [u8; 32] = [0u8; 32]; + + /// Prover transcript for the BlindFold tests (fixed test session/instance/sponge). + fn bf_prover(label: &'static [u8]) -> ProverState { + jolt_transcript::prover_transcript(label, TEST_INSTANCE, Blake2b512::default()) + } + + /// Verifier transcript for the BlindFold tests. BlindFold now writes all its prover-only + /// values into the NARG (Option B), so the verifier replays over the prover's `narg` + /// (not an empty slice), using the same (session, instance) sponge as the prover. + fn bf_verifier<'a>(label: &'static [u8], narg: &'a [u8]) -> VerifierState<'a, Blake2b512> { + jolt_transcript::verifier_transcript(label, TEST_INSTANCE, Blake2b512::default(), narg) + } + + /// The verifier's public input is fully derived from the (honest) real instance. + fn make_verifier_input( + real_instance: &RelaxedR1CSInstance, + ) -> BlindFoldVerifierInput { + BlindFoldVerifierInput { + round_commitments: real_instance.round_commitments.clone(), + output_claims_row_commitments: real_instance.output_claims_row_commitments.clone(), + eval_commitments: real_instance.eval_commitments.clone(), + } + } + type TestInstance = ( RelaxedR1CSInstance, RelaxedR1CSWitness, @@ -810,28 +845,45 @@ mod tests { (real_instance, real_witness, r1cs, gens, z) } - fn prove_and_verify( + /// Run the prover and return the NARG byte-string carrying every prover-only value. + fn prove_to_narg( r1cs: &VerifierR1CS, gens: &PedersenGenerators, real_instance: &RelaxedR1CSInstance, real_witness: &RelaxedR1CSWitness, z: &[Fr], label: &'static [u8], - ) -> Result<(), BlindFoldVerifyError> { + ) -> Vec { let prover = BlindFoldProver::new(gens, r1cs, None); - let verifier = BlindFoldVerifier::new(gens, r1cs, None); - - let mut prover_transcript = KeccakTranscript::new(label); - let proof = prover.prove(real_instance, real_witness, z, &mut prover_transcript); + let mut prover_transcript = bf_prover(label); + prover.prove(real_instance, real_witness, z, &mut prover_transcript); + prover_transcript.narg_string().to_vec() + } - let verifier_input = BlindFoldVerifierInput { - round_commitments: real_instance.round_commitments.clone(), - output_claims_row_commitments: real_instance.output_claims_row_commitments.clone(), - eval_commitments: real_instance.eval_commitments.clone(), - }; + /// Verify over a (possibly tampered) NARG with the given verifier input. + fn verify_narg( + r1cs: &VerifierR1CS, + gens: &PedersenGenerators, + verifier_input: BlindFoldVerifierInput, + narg: &[u8], + label: &'static [u8], + ) -> Result<(), BlindFoldVerifyError> { + let verifier = BlindFoldVerifier::new(gens, r1cs, None); + let mut verifier_transcript = bf_verifier(label, narg); + verifier.verify(verifier_input, &mut verifier_transcript) + } - let mut verifier_transcript = KeccakTranscript::new(label); - verifier.verify(&proof, &verifier_input, &mut verifier_transcript) + fn prove_and_verify( + r1cs: &VerifierR1CS, + gens: &PedersenGenerators, + real_instance: &RelaxedR1CSInstance, + real_witness: &RelaxedR1CSWitness, + z: &[Fr], + label: &'static [u8], + ) -> Result<(), BlindFoldVerifyError> { + let narg = prove_to_narg(r1cs, gens, real_instance, real_witness, z, label); + let verifier_input = make_verifier_input(real_instance); + verify_narg(r1cs, gens, verifier_input, &narg, label) } #[test] @@ -885,31 +937,26 @@ mod tests { let (real_instance, real_witness, r1cs, gens, z) = make_test_instance(&configs, &blindfold_witness); - let prover = BlindFoldProver::new(&gens, &r1cs, None); - let verifier = BlindFoldVerifier::new(&gens, &r1cs, None); - - let mut prover_transcript = KeccakTranscript::new(b"BlindFold_test"); - let mut proof = prover.prove(&real_instance, &real_witness, &z, &mut prover_transcript); - - if !proof.spartan_proof.is_empty() { - proof.spartan_proof.pop(); - } + let narg = prove_to_narg( + &r1cs, + &gens, + &real_instance, + &real_witness, + &z, + b"BlindFold_test", + ); - let verifier_input = BlindFoldVerifierInput { - round_commitments: real_instance.round_commitments.clone(), - output_claims_row_commitments: real_instance.output_claims_row_commitments.clone(), - eval_commitments: real_instance.eval_commitments.clone(), - }; + // The Spartan round polynomials live in the NARG (one frame per round). Truncate the + // tail so a later frame read runs off the end → the verifier rejects the proof. + let mut truncated = narg.clone(); + truncated.truncate(truncated.len() / 2); - let mut verifier_transcript = KeccakTranscript::new(b"BlindFold_test"); - let result = verifier.verify(&proof, &verifier_input, &mut verifier_transcript); + let verifier_input = make_verifier_input(&real_instance); + let result = verify_narg(&r1cs, &gens, verifier_input, &truncated, b"BlindFold_test"); assert!( - matches!( - result, - Err(BlindFoldVerifyError::WrongSpartanProofLength { .. }) - ), - "Verification should fail with wrong proof length: {result:?}" + result.is_err(), + "Verification should fail on a truncated NARG: {result:?}" ); } @@ -933,25 +980,23 @@ mod tests { let (real_instance, real_witness, r1cs, gens, z) = make_test_instance(&configs, &blindfold_witness); - let prover = BlindFoldProver::new(&gens, &r1cs, None); - let verifier = BlindFoldVerifier::new(&gens, &r1cs, None); + let narg = prove_to_narg( + &r1cs, + &gens, + &real_instance, + &real_witness, + &z, + b"BlindFold_test", + ); - let mut prover_transcript = KeccakTranscript::new(b"BlindFold_test"); - let mut proof = prover.prove(&real_instance, &real_witness, &z, &mut prover_transcript); - let extra_commitment = proof.random_instance.round_commitments[0]; - proof - .random_instance - .round_commitments - .push(extra_commitment); + // The random instance is now reconstructed from the NARG; instance-shape mismatches are + // caught against the public input. Feed a verifier input whose `round_commitments` has + // the wrong length → the verifier rejects with `MalformedProof`. + let mut verifier_input = make_verifier_input(&real_instance); + let extra = verifier_input.round_commitments[0]; + verifier_input.round_commitments.push(extra); - let verifier_input = BlindFoldVerifierInput { - round_commitments: real_instance.round_commitments.clone(), - output_claims_row_commitments: real_instance.output_claims_row_commitments.clone(), - eval_commitments: real_instance.eval_commitments.clone(), - }; - - let mut verifier_transcript = KeccakTranscript::new(b"BlindFold_test"); - let result = verifier.verify(&proof, &verifier_input, &mut verifier_transcript); + let result = verify_narg(&r1cs, &gens, verifier_input, &narg, b"BlindFold_test"); assert_eq!(result, Err(BlindFoldVerifyError::MalformedProof)); } @@ -1029,25 +1074,27 @@ mod tests { let (real_instance, real_witness, r1cs, gens, z) = make_test_instance(&configs, &blindfold_witness); - let prover = BlindFoldProver::new(&gens, &r1cs, None); - let verifier = BlindFoldVerifier::new(&gens, &r1cs, None); - - let mut prover_transcript = KeccakTranscript::new(b"BlindFold_test"); - let mut proof = prover.prove(&real_instance, &real_witness, &z, &mut prover_transcript); - - proof.az_r += F::from_u64(1); + let narg = prove_to_narg( + &r1cs, + &gens, + &real_instance, + &real_witness, + &z, + b"BlindFold_test", + ); - let verifier_input = BlindFoldVerifierInput { - round_commitments: real_instance.round_commitments.clone(), - output_claims_row_commitments: real_instance.output_claims_row_commitments.clone(), - eval_commitments: real_instance.eval_commitments.clone(), - }; + // `[az_r, bz_r, cz_r]` is a NARG frame written after the Spartan rounds. Flipping any + // byte in the second half of the NARG (which contains it and everything after) diverges + // the absorbed transcript and/or fails the outer-claim check → verification fails. + let mut tampered = narg.clone(); + let i = tampered.len() * 3 / 4; + tampered[i] ^= 0x01; - let mut verifier_transcript = KeccakTranscript::new(b"BlindFold_test"); - let result = verifier.verify(&proof, &verifier_input, &mut verifier_transcript); + let verifier_input = make_verifier_input(&real_instance); + let result = verify_narg(&r1cs, &gens, verifier_input, &tampered, b"BlindFold_test"); assert!( result.is_err(), - "Tampered az_r should cause verification failure" + "Tampering the NARG (az_r region) should cause verification failure: {result:?}" ); } @@ -1071,27 +1118,27 @@ mod tests { let (real_instance, real_witness, r1cs, gens, z) = make_test_instance(&configs, &blindfold_witness); - let prover = BlindFoldProver::new(&gens, &r1cs, None); - let verifier = BlindFoldVerifier::new(&gens, &r1cs, None); - - let mut prover_transcript = KeccakTranscript::new(b"BlindFold_test"); - let mut proof = prover.prove(&real_instance, &real_witness, &z, &mut prover_transcript); - - if !proof.w_opening.combined_row.is_empty() { - proof.w_opening.combined_row[0] += F::from_u64(1); - } + let narg = prove_to_narg( + &r1cs, + &gens, + &real_instance, + &real_witness, + &z, + b"BlindFold_test", + ); - let verifier_input = BlindFoldVerifierInput { - round_commitments: real_instance.round_commitments.clone(), - output_claims_row_commitments: real_instance.output_claims_row_commitments.clone(), - eval_commitments: real_instance.eval_commitments.clone(), - }; + // The W opening (`combined_row` + `combined_blinding`) is the LAST NARG frame. Flipping a + // byte in its `combined_row` makes the Hyrax W check fail. + let mut tampered = narg.clone(); + // The very last serialized scalar is `combined_blinding`; back off into the row. + let i = tampered.len().saturating_sub(40); + tampered[i] ^= 0x01; - let mut verifier_transcript = KeccakTranscript::new(b"BlindFold_test"); - let result = verifier.verify(&proof, &verifier_input, &mut verifier_transcript); + let verifier_input = make_verifier_input(&real_instance); + let result = verify_narg(&r1cs, &gens, verifier_input, &tampered, b"BlindFold_test"); assert!( - matches!(result, Err(BlindFoldVerifyError::WOpeningFailed)), - "Tampered w_combined_row should fail W Hyrax check: {result:?}" + result.is_err(), + "Tampered W opening should fail verification: {result:?}" ); } @@ -1115,27 +1162,27 @@ mod tests { let (real_instance, real_witness, r1cs, gens, z) = make_test_instance(&configs, &blindfold_witness); - let prover = BlindFoldProver::new(&gens, &r1cs, None); - let verifier = BlindFoldVerifier::new(&gens, &r1cs, None); - - let mut prover_transcript = KeccakTranscript::new(b"BlindFold_test"); - let mut proof = prover.prove(&real_instance, &real_witness, &z, &mut prover_transcript); - - if !proof.e_opening.combined_row.is_empty() { - proof.e_opening.combined_row[0] += F::from_u64(1); - } + let narg = prove_to_narg( + &r1cs, + &gens, + &real_instance, + &real_witness, + &z, + b"BlindFold_test", + ); - let verifier_input = BlindFoldVerifierInput { - round_commitments: real_instance.round_commitments.clone(), - output_claims_row_commitments: real_instance.output_claims_row_commitments.clone(), - eval_commitments: real_instance.eval_commitments.clone(), - }; + // The E opening is read mid-protocol (before the ra/rb/rc challenges). Flipping any byte + // in the NARG region after the Spartan rounds diverges the challenges and/or fails the E + // Hyrax / outer-claim checks → verification fails. + let mut tampered = narg.clone(); + let i = tampered.len() * 7 / 10; + tampered[i] ^= 0x01; - let mut verifier_transcript = KeccakTranscript::new(b"BlindFold_test"); - let result = verifier.verify(&proof, &verifier_input, &mut verifier_transcript); + let verifier_input = make_verifier_input(&real_instance); + let result = verify_narg(&r1cs, &gens, verifier_input, &tampered, b"BlindFold_test"); assert!( - matches!(result, Err(BlindFoldVerifyError::EOpeningFailed)), - "Tampered e_combined_row should fail E Hyrax check: {result:?}" + result.is_err(), + "Tampered E opening / NARG region should fail verification: {result:?}" ); } } diff --git a/jolt-core/src/subprotocols/blindfold/spartan.rs b/jolt-core/src/subprotocols/blindfold/spartan.rs index b43f537075..d6087599ce 100644 --- a/jolt-core/src/subprotocols/blindfold/spartan.rs +++ b/jolt-core/src/subprotocols/blindfold/spartan.rs @@ -20,7 +20,6 @@ use crate::poly::unipoly::UniPoly; use crate::subprotocols::blindfold::{InputClaimConstraint, OutputClaimConstraint}; use crate::subprotocols::sumcheck_prover::SumcheckInstanceProver; use crate::subprotocols::sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}; -use crate::transcripts::Transcript; use crate::utils::math::Math; use super::r1cs::VerifierR1CS; @@ -230,7 +229,7 @@ impl<'a, F: JoltField> BlindFoldSpartanProver<'a, F> { } } -impl SumcheckInstanceProver for BlindFoldSpartanProver<'_, F> { +impl SumcheckInstanceProver for BlindFoldSpartanProver<'_, F> { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -382,7 +381,7 @@ impl<'a, F: JoltField> BlindFoldSpartanVerifier<'a, F> { } } -impl SumcheckInstanceVerifier> +impl SumcheckInstanceVerifier> for BlindFoldSpartanVerifier<'_, F> { fn get_params(&self) -> &dyn SumcheckInstanceParams { @@ -532,18 +531,22 @@ mod tests { use crate::subprotocols::blindfold::r1cs::VerifierR1CSBuilder; use crate::subprotocols::blindfold::witness::{BlindFoldWitness, RoundWitness, StageWitness}; use crate::subprotocols::blindfold::{BakedPublicInputs, StageConfig}; - use crate::transcripts::KeccakTranscript; + use crate::transcript_msgs::FsChallenge; use ark_bn254::Fr; use ark_std::{One, Zero}; + use jolt_transcript::{prover_transcript, Blake2b512}; + + const TEST_INSTANCE: [u8; 32] = [0u8; 32]; #[test] fn test_eq_polynomial_binding_vs_mle() { type F = Fr; // Generate tau and challenges from transcript (using proper Challenge type) - let mut transcript = KeccakTranscript::new(b"test_eq_binding"); - let tau: Vec<_> = transcript.challenge_vector_optimized::(3); - let challenges: Vec<_> = transcript.challenge_vector_optimized::(3); + let mut transcript = + prover_transcript(b"test_eq_binding", TEST_INSTANCE, Blake2b512::default()); + let tau: Vec<_> = FsChallenge::::challenge_optimized_vec(&mut transcript, 3); + let challenges: Vec<_> = FsChallenge::::challenge_optimized_vec(&mut transcript, 3); // Convert tau to F for building eq table let tau_f: Vec = tau.iter().map(|c| (*c).into()).collect(); @@ -606,9 +609,12 @@ mod tests { let e = vec![F::zero(); r1cs.num_constraints]; - let mut transcript = KeccakTranscript::new(b"test_spartan"); - let tau: Vec<_> = transcript - .challenge_vector_optimized::(r1cs.num_constraints.next_power_of_two().log_2()); + let mut transcript = + prover_transcript(b"test_spartan", TEST_INSTANCE, Blake2b512::default()); + let tau: Vec<_> = FsChallenge::::challenge_optimized_vec( + &mut transcript, + r1cs.num_constraints.next_power_of_two().log_2(), + ); let mut prover = BlindFoldSpartanProver::new(&r1cs, F::one(), z.clone(), e, tau.clone()); @@ -621,7 +627,7 @@ mod tests { let sum = poly.evaluate(&F::zero()) + poly.evaluate(&F::one()); assert_eq!(sum, claim, "Round {round}: p(0) + p(1) != claim"); - let r_j = transcript.challenge_scalar_optimized::(); + let r_j = FsChallenge::::challenge_optimized(&mut transcript); challenges.push(r_j); claim = poly.evaluate(&r_j); prover.bind_challenge(r_j); @@ -701,9 +707,15 @@ mod tests { .collect(); // Create transcript and derive τ - let mut transcript = KeccakTranscript::new(b"test_spartan_relaxed"); - let tau: Vec<_> = transcript - .challenge_vector_optimized::(r1cs.num_constraints.next_power_of_two().log_2()); + let mut transcript = prover_transcript( + b"test_spartan_relaxed", + TEST_INSTANCE, + Blake2b512::default(), + ); + let tau: Vec<_> = FsChallenge::::challenge_optimized_vec( + &mut transcript, + r1cs.num_constraints.next_power_of_two().log_2(), + ); // Create Spartan prover with relaxed instance let mut prover = @@ -720,7 +732,7 @@ mod tests { let sum = poly.evaluate(&F::zero()) + poly.evaluate(&F::one()); assert_eq!(sum, claim, "Round {round}: sum check failed"); - let r_j = transcript.challenge_scalar_optimized::(); + let r_j = FsChallenge::::challenge_optimized(&mut transcript); challenges.push(r_j); claim = poly.evaluate(&r_j); prover.bind_challenge(r_j); diff --git a/jolt-core/src/subprotocols/booleanity.rs b/jolt-core/src/subprotocols/booleanity.rs index e6f99dd5f0..b175167d02 100644 --- a/jolt-core/src/subprotocols/booleanity.rs +++ b/jolt-core/src/subprotocols/booleanity.rs @@ -49,7 +49,7 @@ use crate::{ sumcheck_prover::SumcheckInstanceProver, sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}, }, - transcripts::Transcript, + transcript_msgs::FsChallenge, utils::expanding_table::ExpandingTable, zkvm::{ bytecode::BytecodePreprocessing, @@ -93,7 +93,7 @@ impl BooleanitySumcheckParams { log_t: usize, one_hot_params: &OneHotParams, accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { let log_k_chunk = one_hot_params.log_k_chunk; let instruction_d = one_hot_params.instruction_d; @@ -127,7 +127,7 @@ impl BooleanitySumcheckParams { stage5_addr[stage5_addr.len() - log_k_chunk..].to_vec() } else { let mut r = stage5_addr; - let extra = transcript.challenge_vector_optimized::(log_k_chunk - r.len()); + let extra = transcript.challenge_optimized_vec(log_k_chunk - r.len()); r.extend(extra); r }; @@ -146,7 +146,7 @@ impl BooleanitySumcheckParams { } // Sample a single batching challenge γ, and derive per-polynomial weights γ^{2i}. - let mut gamma = transcript.challenge_scalar_optimized::(); + let mut gamma = transcript.challenge_optimized(); let mut gamma_f: F = gamma.into(); // Avoid the degenerate gamma=0 case (vanishing weights + non-invertible scaling). if gamma_f.is_zero() { @@ -267,9 +267,7 @@ impl BooleanityAddressSumcheckProver { } } -impl SumcheckInstanceProver - for BooleanityAddressSumcheckProver -{ +impl SumcheckInstanceProver for BooleanityAddressSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -429,9 +427,7 @@ impl BooleanityCycleSumcheckProver { } } -impl SumcheckInstanceProver - for BooleanityCycleSumcheckProver -{ +impl SumcheckInstanceProver for BooleanityCycleSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -516,8 +512,8 @@ impl BooleanityAddressSumcheckVerifier { } } -impl> - SumcheckInstanceVerifier for BooleanityAddressSumcheckVerifier +impl> SumcheckInstanceVerifier + for BooleanityAddressSumcheckVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params @@ -559,8 +555,8 @@ impl BooleanityCycleSumcheckVerifier { } } -impl> - SumcheckInstanceVerifier for BooleanityCycleSumcheckVerifier +impl> SumcheckInstanceVerifier + for BooleanityCycleSumcheckVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params diff --git a/jolt-core/src/subprotocols/streaming_sumcheck.rs b/jolt-core/src/subprotocols/streaming_sumcheck.rs index a6a51230f5..bcdcebbadd 100644 --- a/jolt-core/src/subprotocols/streaming_sumcheck.rs +++ b/jolt-core/src/subprotocols/streaming_sumcheck.rs @@ -9,7 +9,6 @@ use crate::poly::unipoly::UniPoly; use crate::subprotocols::streaming_schedule::StreamingSchedule; use crate::subprotocols::sumcheck_prover::SumcheckInstanceProver; use crate::subprotocols::sumcheck_verifier::SumcheckInstanceParams; -use crate::transcripts::Transcript; pub trait StreamingSumcheckWindow: Sized + MaybeAllocative + Send + Sync { type Shared; @@ -107,11 +106,10 @@ where } } -impl SumcheckInstanceProver +impl SumcheckInstanceProver for StreamingSumcheck where F: JoltField, - T: Transcript, S: StreamingSchedule, Shared: SumcheckInstanceParams + MaybeAllocative + Send + Sync, Streaming: StreamingSumcheckWindow, diff --git a/jolt-core/src/subprotocols/sumcheck.rs b/jolt-core/src/subprotocols/sumcheck.rs index 60256761f9..01ad3de70c 100644 --- a/jolt-core/src/subprotocols/sumcheck.rs +++ b/jolt-core/src/subprotocols/sumcheck.rs @@ -13,17 +13,13 @@ use crate::poly::opening_proof::{ use crate::poly::unipoly::{CompressedUniPoly, UniPoly}; use crate::subprotocols::sumcheck_prover::SumcheckInstanceProver; use crate::subprotocols::sumcheck_verifier::SumcheckInstanceVerifier; -use crate::transcripts::Transcript; +use crate::transcript_msgs::{ProverFs, VerifierFs}; use crate::utils::errors::ProofVerifyError; #[cfg(not(target_arch = "wasm32"))] use crate::utils::profiling::print_current_memory_usage; -use ark_serialize::*; #[cfg(feature = "zk")] use rand_core::CryptoRngCore; -use std::marker::PhantomData; - -pub use crate::subprotocols::univariate_skip::UniSkipFirstRoundProof; /// Implements the standard technique for batching parallel sumchecks to reduce /// verifier cost and proof size. @@ -32,24 +28,25 @@ pub use crate::subprotocols::univariate_skip::UniSkipFirstRoundProof; /// We do what they describe as "front-loaded" batch sumcheck. pub enum BatchedSumcheck {} impl BatchedSumcheck { - /// Returns (proof, challenges, initial_batched_claim) - /// For non-ZK mode - returns ClearSumcheckProof with polynomial coefficients visible. - pub fn prove( - mut sumcheck_instances: Vec<&mut dyn SumcheckInstanceProver>, + /// Returns (challenges, initial_batched_claim). + /// For non-ZK mode - the round polynomial coefficients are written into the NARG. + pub fn prove( + mut sumcheck_instances: Vec<&mut dyn SumcheckInstanceProver>, opening_accumulator: &mut ProverOpeningAccumulator, - transcript: &mut ProofTranscript, - ) -> (ClearSumcheckProof, Vec, F) { + transcript: &mut impl ProverFs, + ) -> (Vec, F) { let max_num_rounds = sumcheck_instances .iter() .map(|sumcheck| sumcheck.num_rounds()) .max() .unwrap(); + // Input claims are shared (the verifier recomputes them from openings). sumcheck_instances.iter().for_each(|sumcheck| { let input_claim = sumcheck.input_claim(opening_accumulator); - transcript.append_scalar(b"sumcheck_claim", &input_claim); + transcript.absorb_scalar(&input_claim); }); - let batching_coeffs: Vec = transcript.challenge_vector(sumcheck_instances.len()); + let batching_coeffs: Vec = transcript.challenge_vec(sumcheck_instances.len()); // To see why we may need to scale by a power of two, consider a batch of // two sumchecks: @@ -80,7 +77,6 @@ impl BatchedSumcheck { let mut batched_claim: F = initial_batched_claim; let mut r_sumcheck: Vec = Vec::with_capacity(max_num_rounds); - let mut compressed_polys: Vec> = Vec::with_capacity(max_num_rounds); let two_inv = F::from_u64(2).inverse().unwrap(); for round in 0..max_num_rounds { @@ -119,9 +115,11 @@ impl BatchedSumcheck { let compressed_poly = batched_univariate_poly.compress(); - // append the prover's message to the transcript - transcript.append_scalars(b"sumcheck_poly", &compressed_poly.coeffs_except_linear_term); - let r_j = transcript.challenge_scalar_optimized::(); + // Write the prover's round polynomial into the NARG (prover-only payload). + // The self-delimiting frame lets the verifier read back the exact (possibly + // round-varying) number of coefficients. + transcript.write_scalars(&compressed_poly.coeffs_except_linear_term); + let r_j = transcript.challenge_optimized(); r_sumcheck.push(r_j); individual_claims @@ -150,8 +148,6 @@ impl BatchedSumcheck { sumcheck.ingest_challenge(r_j, round - offset); } } - - compressed_polys.push(compressed_poly); } // Allow each sumcheck instance to perform any end-of-protocol work (e.g. flushing @@ -179,11 +175,7 @@ impl BatchedSumcheck { opening_accumulator.flush_to_transcript(transcript); - ( - ClearSumcheckProof::new(compressed_polys), - r_sumcheck, - initial_batched_claim, - ) + (r_sumcheck, initial_batched_claim) } /// Prove a batched sumcheck with Pedersen commitments (ZK mode). @@ -196,25 +188,17 @@ impl BatchedSumcheck { /// The Pedersen commitments are verified by BlindFold's split-committed R1CS. /// BlindFold proves that the committed coefficients satisfy the sumcheck equations. /// - /// Returns (proof, challenges, initial_batched_claim) + /// Returns (challenges, initial_batched_claim). + /// The round/output-claim commitments and per-round degrees are written into the NARG. #[cfg(feature = "zk")] - pub fn prove_zk< - F: JoltField, - C: JoltCurve, - ProofTranscript: Transcript, - R: CryptoRngCore, - >( - mut sumcheck_instances: Vec<&mut dyn SumcheckInstanceProver>, + pub fn prove_zk, R: CryptoRngCore>( + mut sumcheck_instances: Vec<&mut dyn SumcheckInstanceProver>, opening_accumulator: &mut ProverOpeningAccumulator, blindfold_accumulator: &mut crate::subprotocols::blindfold::BlindFoldAccumulator, - transcript: &mut ProofTranscript, + transcript: &mut impl ProverFs, pedersen_gens: &PedersenGenerators, rng: &mut R, - ) -> ( - SumcheckInstanceProof, - Vec, - F, - ) { + ) -> (Vec, F) { use crate::subprotocols::blindfold::ZkStageData; let max_num_rounds = sumcheck_instances @@ -225,7 +209,7 @@ impl BatchedSumcheck { // In ZK mode, don't absorb cleartext claims — polynomial commitments provide binding. // Batching coefficients are still unpredictable (from transcript state after commitments). - let batching_coeffs: Vec = transcript.challenge_vector(sumcheck_instances.len()); + let batching_coeffs: Vec = transcript.challenge_vec(sumcheck_instances.len()); let mut individual_claims: Vec = sumcheck_instances .iter() @@ -285,9 +269,13 @@ impl BatchedSumcheck { let blinding = F::random(rng); let commitment = pedersen_gens.commit(&batched_univariate_poly.coeffs, &blinding); - transcript.append_commitment(b"sumcheck_commitment", &commitment); + // Round commitments are prover-only payload: written into the NARG (which also + // absorbs them) immediately before squeezing this round's challenge. The verifier + // reads each commitment back at the SAME position in `verify_transcript_only`, so + // the per-round interleave (write commitment → squeeze challenge) is symmetric. + transcript.write_slice(std::slice::from_ref(&commitment)); - let r_j = transcript.challenge_scalar_optimized::(); + let r_j = transcript.challenge_optimized(); r_sumcheck.push(r_j); individual_claims @@ -305,7 +293,7 @@ impl BatchedSumcheck { } round_commitments_g1.push(commitment); - poly_degrees.push(batched_univariate_poly.coeffs.len() - 1); + poly_degrees.push(batched_univariate_poly.coeffs.len().saturating_sub(1)); poly_coeffs.push(batched_univariate_poly.coeffs.clone()); blinding_factors.push(blinding); } @@ -339,7 +327,12 @@ impl BatchedSumcheck { .collect(); let (output_claims_commitments, output_claims_blindings): (Vec<_>, Vec<_>) = oc_committed.into_iter().unzip(); - transcript.append_commitments(b"output_claims_coms", &output_claims_commitments); + // After the round loop, write the per-round polynomial degrees (public R1CS data the + // verifier needs in stage 8), then the output-claim commitments. The verifier reads in + // this SAME order: `poly_degrees` after its round loop (`verify_transcript_only`), then + // `output_claims_commitments` (`BatchedSumcheck::verify`). + transcript.write_slice(&poly_degrees); + transcript.write_slice(&output_claims_commitments); let output_constraints: Vec<_> = sumcheck_instances .iter() @@ -394,25 +387,22 @@ impl BatchedSumcheck { output_claims_commitments: output_claims_commitments.clone(), }); - ( - SumcheckInstanceProof::new_zk( - round_commitments_g1, - poly_degrees, - output_claims_commitments, - ), - r_sumcheck, - initial_batched_claim, - ) + (r_sumcheck, initial_batched_claim) } - pub fn verify, ProofTranscript: Transcript>( - proof: &SumcheckInstanceProof, - sumcheck_instances: Vec< - &dyn SumcheckInstanceVerifier>, - >, + /// Returns `(batching_coeffs, r_sumcheck, zk_readback)`. `zk_readback` is `Some` only + /// in ZK mode (the round commitments, per-round degrees, and output-claim commitments + /// read back from the NARG, threaded to stage 8 / BlindFold); `None` otherwise. + /// + /// `zk_mode` is the read-frame SELECTOR (sourced from the proof's single global + /// `zk_mode`): it picks the Clear round-polynomial path or the ZK commitment path. + /// The NARG read order within each path is unchanged. + pub fn verify>( + zk_mode: bool, + sumcheck_instances: Vec<&dyn SumcheckInstanceVerifier>>, opening_accumulator: &mut VerifierOpeningAccumulator, - transcript: &mut ProofTranscript, - ) -> Result<(Vec, Vec), ProofVerifyError> { + transcript: &mut impl VerifierFs, + ) -> Result<(Vec, Vec, Option>), ProofVerifyError> { let max_degree = sumcheck_instances .iter() .map(|sumcheck| sumcheck.degree()) @@ -424,14 +414,14 @@ impl BatchedSumcheck { .max() .unwrap(); - let is_zk = matches!(proof, SumcheckInstanceProof::Zk(_)); + let is_zk = zk_mode; if !is_zk { sumcheck_instances.iter().for_each(|sumcheck| { let input_claim = sumcheck.input_claim(opening_accumulator); - transcript.append_scalar(b"sumcheck_claim", &input_claim); + transcript.absorb_scalar(&input_claim); }); } - let batching_coeffs: Vec = transcript.challenge_vector(sumcheck_instances.len()); + let batching_coeffs: Vec = transcript.challenge_vec(sumcheck_instances.len()); // To see why we may need to scale by a power of two, consider a batch of // two sumchecks: @@ -452,8 +442,33 @@ impl BatchedSumcheck { }) .sum(); - let (output_claim, r_sumcheck) = - proof.verify(claim, max_num_rounds, max_degree, transcript)?; + // SELECTOR: ZK mode reads round commitments from the NARG (BlindFold proves the + // round polynomials); non-ZK reads the round polynomials directly. The per-path + // NARG read order is identical to before — only the branch source changed. + let (output_claim, r_sumcheck, zk_round_readback): ( + F, + Vec, + Option<(Vec, Vec)>, + ) = if !is_zk { + let (output_claim, r) = + clear_sumcheck::verify(claim, max_num_rounds, max_degree, transcript)?; + (output_claim, r, None) + } else { + #[cfg(feature = "zk")] + { + let (r, round_commitments, poly_degrees) = + zk_sumcheck::verify_transcript_only::( + max_num_rounds, + max_degree, + transcript, + )?; + (F::zero(), r, Some((round_commitments, poly_degrees))) + } + #[cfg(not(feature = "zk"))] + { + return Err(ProofVerifyError::ZkFeatureRequired); + } + }; let expected_output_claim: F = sumcheck_instances .iter() @@ -471,33 +486,49 @@ impl BatchedSumcheck { }) .sum(); - if !is_zk { + // In ZK mode, read the output-claim commitments back from the NARG (the prover wrote + // them right after `poly_degrees`, which `verify_transcript_only` already consumed). + // Assemble the full stage readback for stage 8. + let zk_readback: Option> = if !is_zk { opening_accumulator.flush_to_transcript(transcript); - } else if let SumcheckInstanceProof::Zk(zk_proof) = proof { - transcript - .append_commitments(b"output_claims_coms", &zk_proof.output_claims_commitments); - opening_accumulator.take_pending_claims(); - } + None + } else { + #[cfg(feature = "zk")] + { + let output_claims_commitments: Vec = transcript + .read_slice() + .map_err(|_| ProofVerifyError::SumcheckVerificationError)?; + opening_accumulator.take_pending_claims(); + let (round_commitments, poly_degrees) = + zk_round_readback.ok_or(ProofVerifyError::SumcheckVerificationError)?; + Some(ZkSumcheckReadback { + round_commitments, + poly_degrees, + output_claims_commitments, + }) + } + #[cfg(not(feature = "zk"))] + { + let _ = &zk_round_readback; + return Err(ProofVerifyError::ZkFeatureRequired); + } + }; // In ZK mode, skip output claim verification — BlindFold proves this if !is_zk && output_claim != expected_output_claim { return Err(ProofVerifyError::SumcheckVerificationError); } - Ok((batching_coeffs, r_sumcheck)) + Ok((batching_coeffs, r_sumcheck, zk_readback)) } /// Verify a standard (non-ZK) sumcheck proof without requiring a curve type. - /// Used by opening proof reduction which doesn't need ZK mode. - pub fn verify_standard< - F: JoltField, - ProofTranscript: Transcript, - A: AbstractVerifierOpeningAccumulator, - >( - proof: &ClearSumcheckProof, - sumcheck_instances: Vec<&dyn SumcheckInstanceVerifier>, + /// Used by opening proof reduction which doesn't need ZK mode. The round + /// polynomials are read back from the NARG. + pub fn verify_standard>( + sumcheck_instances: Vec<&dyn SumcheckInstanceVerifier>, opening_accumulator: &mut A, - transcript: &mut ProofTranscript, + transcript: &mut impl VerifierFs, ) -> Result, ProofVerifyError> { let max_degree = sumcheck_instances .iter() @@ -510,14 +541,14 @@ impl BatchedSumcheck { .max() .unwrap(); - // Append input claims to transcript BEFORE deriving batching coefficients + // Absorb input claims BEFORE deriving batching coefficients // (must match ordering in BatchedSumcheck::prove) sumcheck_instances.iter().for_each(|sumcheck| { let input_claim = sumcheck.input_claim(opening_accumulator); - transcript.append_scalar(b"sumcheck_claim", &input_claim); + transcript.absorb_scalar(&input_claim); }); - let batching_coeffs: Vec = transcript.challenge_vector(sumcheck_instances.len()); + let batching_coeffs: Vec = transcript.challenge_vec(sumcheck_instances.len()); let claim: F = sumcheck_instances .iter() @@ -530,7 +561,7 @@ impl BatchedSumcheck { .sum(); let (output_claim, r_sumcheck) = - proof.verify(claim, max_num_rounds, max_degree, transcript)?; + clear_sumcheck::verify(claim, max_num_rounds, max_degree, transcript)?; let expected_output_claim = sumcheck_instances .iter() @@ -554,41 +585,38 @@ impl BatchedSumcheck { } } -/// Clear (non-ZK) sumcheck proof - coefficients visible to verifier. -/// Used in non-ZK mode where the verifier evaluates polynomials directly. -#[derive(CanonicalSerialize, CanonicalDeserialize, Debug, Clone)] -pub struct ClearSumcheckProof { - pub compressed_polys: Vec>, - _marker: PhantomData, -} - -impl ClearSumcheckProof { - pub fn new(compressed_polys: Vec>) -> Self { - Self { - compressed_polys, - _marker: PhantomData, - } - } - - /// Verify this standard sumcheck proof by evaluating polynomials. - pub fn verify( - &self, +/// Clear (non-ZK) sumcheck verification namespace. +/// +/// Under the NARG (Option B) the round-polynomial coefficients live in the NARG +/// byte-string — the prover writes them via `write_slice` and the verifier reads +/// them back with `read_slice`. This type carries no data; it only groups the +/// non-ZK verification logic. The Fiat-Shamir mode is selected globally by +/// `JoltProof::zk_mode`, not by a per-stage marker. +pub mod clear_sumcheck { + use super::*; + + /// Verify this standard sumcheck by reading each round polynomial back from the + /// NARG and evaluating it — the math is identical to the cleartext path; only the + /// source of the coefficients changed (NARG instead of a struct field). + pub fn verify( claim: F, num_rounds: usize, degree_bound: usize, - transcript: &mut ProofTranscript, + transcript: &mut impl VerifierFs, ) -> Result<(F, Vec), ProofVerifyError> { let mut e = claim; - let mut r: Vec = Vec::new(); - - if self.compressed_polys.len() != num_rounds { - return Err(ProofVerifyError::InvalidInputLength( - num_rounds, - self.compressed_polys.len(), - )); - } - for i in 0..self.compressed_polys.len() { - let poly_degree = self.compressed_polys[i].degree(); + let mut r: Vec = Vec::with_capacity(num_rounds); + + for _ in 0..num_rounds { + // Read the prover's round polynomial back from the NARG and reconstruct it. + let coeffs_except_linear_term: Vec = transcript + .read_scalars() + .map_err(|_| ProofVerifyError::SumcheckVerificationError)?; + let poly = CompressedUniPoly { + coeffs_except_linear_term, + }; + + let poly_degree = poly.degree(); if poly_degree == 0 || poly_degree > degree_bound { return Err(ProofVerifyError::InvalidInputLength( degree_bound, @@ -596,267 +624,76 @@ impl ClearSumcheckProof(); + let r_i: F::Challenge = transcript.challenge_optimized(); r.push(r_i); - e = self.compressed_polys[i].eval_from_hint(&e, &r_i); + e = poly.eval_from_hint(&e, &r_i); } Ok((e, r)) } } -/// ZK sumcheck proof - only commitments visible, coefficients hidden in BlindFold. -/// The verifier appends commitments to transcript and derives challenges, -/// but polynomial evaluation is verified by BlindFold's R1CS constraints. +/// ZK sumcheck values read back from the NARG during verification, threaded to stage 8 +/// (BlindFold) which can no longer read them from the (now data-free) proof struct. +/// Constructed only in ZK builds; the type exists in both so `BatchedSumcheck::verify`'s +/// signature is uniform (the non-ZK path always yields `None`). #[derive(Debug, Clone)] -pub struct ZkSumcheckProof, ProofTranscript: Transcript> { - /// Pedersen commitments to round polynomials (G1 curve elements) +pub struct ZkSumcheckReadback { + /// Pedersen commitments to round polynomials (G1 curve elements), one per round. pub round_commitments: Vec, - /// Polynomial degrees for each round (public info needed for R1CS construction) + /// Polynomial degree for each round (public info needed for R1CS construction). pub poly_degrees: Vec, - /// Pedersen commitments to output claims, chunked to fit generator count + /// Pedersen commitments to output claims, chunked to fit generator count. pub output_claims_commitments: Vec, - _marker: PhantomData<(F, ProofTranscript)>, -} - -impl, ProofTranscript: Transcript> CanonicalSerialize - for ZkSumcheckProof -{ - fn serialize_with_mode( - &self, - mut writer: W, - compress: ark_serialize::Compress, - ) -> Result<(), ark_serialize::SerializationError> { - self.round_commitments - .serialize_with_mode(&mut writer, compress)?; - self.poly_degrees - .serialize_with_mode(&mut writer, compress)?; - self.output_claims_commitments - .serialize_with_mode(writer, compress) - } - - fn serialized_size(&self, compress: ark_serialize::Compress) -> usize { - self.round_commitments.serialized_size(compress) - + self.poly_degrees.serialized_size(compress) - + self.output_claims_commitments.serialized_size(compress) - } -} - -impl, ProofTranscript: Transcript> ark_serialize::Valid - for ZkSumcheckProof -{ - fn check(&self) -> Result<(), ark_serialize::SerializationError> { - self.round_commitments.check()?; - self.poly_degrees.check()?; - self.output_claims_commitments.check() - } } -impl, ProofTranscript: Transcript> CanonicalDeserialize - for ZkSumcheckProof -{ - fn deserialize_with_mode( - mut reader: R, - compress: ark_serialize::Compress, - validate: ark_serialize::Validate, - ) -> Result { - let round_commitments = - Vec::::deserialize_with_mode(&mut reader, compress, validate)?; - let poly_degrees = Vec::::deserialize_with_mode(&mut reader, compress, validate)?; - let output_claims_commitments = - Vec::::deserialize_with_mode(reader, compress, validate)?; - Ok(Self { - round_commitments, - poly_degrees, - output_claims_commitments, - _marker: PhantomData, - }) - } -} - -impl, ProofTranscript: Transcript> - ZkSumcheckProof -{ - pub fn new( - round_commitments: Vec, - poly_degrees: Vec, - output_claims_commitments: Vec, - ) -> Self { - Self { - round_commitments, - poly_degrees, - output_claims_commitments, - _marker: PhantomData, - } - } +/// ZK sumcheck verification namespace: reads the prover-only round commitments, degrees, +/// and output-claim commitments back from the NARG (the proof struct carries no data). +#[cfg(feature = "zk")] +pub mod zk_sumcheck { + use super::*; - /// Verify ZK sumcheck by appending commitments to transcript and deriving challenges. - /// Does NOT evaluate polynomials - that's handled by BlindFold verification. - pub fn verify_transcript_only( - &self, + /// Verify ZK sumcheck by reading the per-round commitments back from the NARG and + /// deriving challenges. Does NOT evaluate polynomials — that's handled by BlindFold. + /// + /// Reads, in the exact order the prover wrote them in `prove_zk`: per round one + /// commitment (then squeezes that round's challenge), then the per-round `poly_degrees` + /// frame after the loop. The output-claim commitments are read one position later, by + /// the caller (`BatchedSumcheck::verify`), matching the prover. The read-back data is + /// returned so stage 8 can consume it. + pub fn verify_transcript_only>( num_rounds: usize, degree_bound: usize, - transcript: &mut ProofTranscript, - ) -> Result, ProofVerifyError> { - if self.round_commitments.len() != num_rounds { - return Err(ProofVerifyError::InvalidInputLength( - num_rounds, - self.round_commitments.len(), - )); + transcript: &mut impl VerifierFs, + ) -> Result<(Vec, Vec, Vec), ProofVerifyError> { + let mut r: Vec = Vec::with_capacity(num_rounds); + let mut round_commitments: Vec = Vec::with_capacity(num_rounds); + for _ in 0..num_rounds { + let commitment: C::G1 = transcript + .read_single() + .map_err(|_| ProofVerifyError::SumcheckVerificationError)?; + let r_i: F::Challenge = transcript.challenge_optimized(); + round_commitments.push(commitment); + r.push(r_i); } - if self.poly_degrees.len() != num_rounds { + + // After the round loop, read the per-round polynomial degrees (matching the prover's + // post-loop `write_slice(&poly_degrees)`). + let poly_degrees: Vec = transcript + .read_slice() + .map_err(|_| ProofVerifyError::SumcheckVerificationError)?; + if poly_degrees.len() != num_rounds { return Err(ProofVerifyError::InvalidInputLength( num_rounds, - self.poly_degrees.len(), + poly_degrees.len(), )); } - - for °ree in &self.poly_degrees { + for °ree in &poly_degrees { if degree > degree_bound { return Err(ProofVerifyError::InvalidInputLength(degree_bound, degree)); } } - let mut r: Vec = Vec::new(); - for commitment in &self.round_commitments { - transcript.append_commitment(b"sumcheck_commitment", commitment); - let r_i: F::Challenge = transcript.challenge_scalar_optimized::(); - r.push(r_i); - } - - Ok(r) - } -} - -#[derive(Debug, Clone)] -pub enum SumcheckInstanceProof, ProofTranscript: Transcript> { - /// Non-ZK: coefficients visible to verifier - Clear(ClearSumcheckProof), - /// ZK: only commitments visible, coefficients hidden in BlindFold - Zk(ZkSumcheckProof), -} - -impl, ProofTranscript: Transcript> CanonicalSerialize - for SumcheckInstanceProof -{ - fn serialize_with_mode( - &self, - mut writer: W, - compress: ark_serialize::Compress, - ) -> Result<(), ark_serialize::SerializationError> { - match self { - Self::Clear(proof) => { - 0u8.serialize_with_mode(&mut writer, compress)?; - proof.serialize_with_mode(writer, compress) - } - Self::Zk(proof) => { - 1u8.serialize_with_mode(&mut writer, compress)?; - proof.serialize_with_mode(writer, compress) - } - } - } - - fn serialized_size(&self, compress: ark_serialize::Compress) -> usize { - 1 + match self { - Self::Clear(proof) => proof.serialized_size(compress), - Self::Zk(proof) => proof.serialized_size(compress), - } - } -} - -impl, ProofTranscript: Transcript> ark_serialize::Valid - for SumcheckInstanceProof -{ - fn check(&self) -> Result<(), ark_serialize::SerializationError> { - match self { - Self::Clear(proof) => proof.check(), - Self::Zk(proof) => proof.check(), - } - } -} - -impl, ProofTranscript: Transcript> CanonicalDeserialize - for SumcheckInstanceProof -{ - fn deserialize_with_mode( - mut reader: R, - compress: ark_serialize::Compress, - validate: ark_serialize::Validate, - ) -> Result { - let variant = u8::deserialize_with_mode(&mut reader, compress, validate)?; - match variant { - 0 => { - let proof = ClearSumcheckProof::deserialize_with_mode(reader, compress, validate)?; - Ok(Self::Clear(proof)) - } - 1 => { - let proof = ZkSumcheckProof::deserialize_with_mode(reader, compress, validate)?; - Ok(Self::Zk(proof)) - } - _ => Err(ark_serialize::SerializationError::InvalidData), - } - } -} - -impl, ProofTranscript: Transcript> - SumcheckInstanceProof -{ - /// Create a standard (non-ZK) sumcheck proof. - pub fn new_standard(compressed_polys: Vec>) -> Self { - Self::Clear(ClearSumcheckProof::new(compressed_polys)) - } - - /// Create a ZK sumcheck proof with only commitments and polynomial degrees. - pub fn new_zk( - round_commitments: Vec, - poly_degrees: Vec, - output_claims_commitments: Vec, - ) -> Self { - Self::Zk(ZkSumcheckProof::new( - round_commitments, - poly_degrees, - output_claims_commitments, - )) - } - - /// Verify the sumcheck proof. - /// For Standard: evaluates polynomials and returns (final_claim, challenges). - /// For Zk: only derives challenges (BlindFold handles evaluation verification). - pub fn verify( - &self, - claim: F, - num_rounds: usize, - degree_bound: usize, - transcript: &mut ProofTranscript, - ) -> Result<(F, Vec), ProofVerifyError> { - match self { - Self::Clear(proof) => proof.verify(claim, num_rounds, degree_bound, transcript), - Self::Zk(proof) => { - if !cfg!(feature = "zk") { - return Err(ProofVerifyError::ZkFeatureRequired); - } - let challenges = - proof.verify_transcript_only(num_rounds, degree_bound, transcript)?; - Ok((F::zero(), challenges)) - } - } - } - - pub fn is_zk(&self) -> bool { - matches!(self, Self::Zk(_)) - } - - pub fn num_rounds(&self) -> usize { - match self { - Self::Clear(proof) => proof.compressed_polys.len(), - Self::Zk(proof) => proof.round_commitments.len(), - } + Ok((r, round_commitments, poly_degrees)) } } diff --git a/jolt-core/src/subprotocols/sumcheck_prover.rs b/jolt-core/src/subprotocols/sumcheck_prover.rs index 8f29bfda7a..8c99980102 100644 --- a/jolt-core/src/subprotocols/sumcheck_prover.rs +++ b/jolt-core/src/subprotocols/sumcheck_prover.rs @@ -1,15 +1,12 @@ use crate::poly::unipoly::UniPoly; use crate::subprotocols::sumcheck_verifier::SumcheckInstanceParams; -use crate::transcripts::Transcript; use crate::{ field::{JoltField, MaybeAllocative}, poly::opening_proof::ProverOpeningAccumulator, }; -pub trait SumcheckInstanceProver: - Send + Sync + MaybeAllocative -{ +pub trait SumcheckInstanceProver: Send + Sync + MaybeAllocative { fn get_params(&self) -> &dyn SumcheckInstanceParams; /// Returns the maximum degree of the sumcheck polynomial. fn degree(&self) -> usize { diff --git a/jolt-core/src/subprotocols/sumcheck_verifier.rs b/jolt-core/src/subprotocols/sumcheck_verifier.rs index ae7a5dc7c4..6b8b230aa9 100644 --- a/jolt-core/src/subprotocols/sumcheck_verifier.rs +++ b/jolt-core/src/subprotocols/sumcheck_verifier.rs @@ -3,16 +3,10 @@ use crate::poly::opening_proof::{ }; #[cfg(feature = "zk")] use crate::subprotocols::blindfold::{InputClaimConstraint, OutputClaimConstraint}; -use crate::transcripts::Transcript; use crate::field::JoltField; -pub trait SumcheckInstanceVerifier< - F: JoltField, - T: Transcript, - A: AbstractVerifierOpeningAccumulator, -> -{ +pub trait SumcheckInstanceVerifier> { fn get_params(&self) -> &dyn SumcheckInstanceParams; /// Returns the maximum degree of the sumcheck polynomial. fn degree(&self) -> usize { diff --git a/jolt-core/src/subprotocols/univariate_skip.rs b/jolt-core/src/subprotocols/univariate_skip.rs index 3a3a0946dc..87dab63c27 100644 --- a/jolt-core/src/subprotocols/univariate_skip.rs +++ b/jolt-core/src/subprotocols/univariate_skip.rs @@ -1,6 +1,3 @@ -use std::marker::PhantomData; - -use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; #[cfg(feature = "zk")] use rand_core::CryptoRngCore; @@ -15,7 +12,7 @@ use crate::poly::opening_proof::{AbstractVerifierOpeningAccumulator, ProverOpeni use crate::poly::unipoly::UniPoly; use crate::subprotocols::sumcheck_prover::SumcheckInstanceProver; use crate::subprotocols::sumcheck_verifier::SumcheckInstanceVerifier; -use crate::transcripts::Transcript; +use crate::transcript_msgs::{ProverFs, VerifierFs}; use crate::utils::errors::ProofVerifyError; /// Returns the interleaved symmetric univariate-skip target indices outside the base window. @@ -132,20 +129,19 @@ pub fn build_uniskip_first_round_poly< } /// Prove-only helper for a uni-skip first round instance (non-ZK mode). -/// Produces the proof object, the uni-skip challenge r0, and the next claim s1(r0). -pub fn prove_uniskip_round>( +/// Writes the first-round polynomial into the NARG and returns the uni-skip challenge r0. +pub fn prove_uniskip_round>( instance: &mut I, opening_accumulator: &mut ProverOpeningAccumulator, - transcript: &mut T, -) -> UniSkipFirstRoundProof { + transcript: &mut impl ProverFs, +) { let input_claim = instance.input_claim(opening_accumulator); let uni_poly = instance.compute_message(0, input_claim); - // Append full polynomial and derive r0 - transcript.append_scalars(b"uniskip_poly", &uni_poly.coeffs); - let r0: F::Challenge = transcript.challenge_scalar_optimized::(); + // Write the full first-round polynomial into the NARG and derive r0. + transcript.write_scalars(&uni_poly.coeffs); + let r0: F::Challenge = transcript.challenge_optimized(); instance.cache_openings(opening_accumulator, &[r0]); opening_accumulator.flush_to_transcript(transcript); - UniSkipFirstRoundProof::new(uni_poly) } /// ZK variant: commits to coefficients instead of revealing them. @@ -154,17 +150,16 @@ pub fn prove_uniskip_round, - T: Transcript, - I: SumcheckInstanceProver, + I: SumcheckInstanceProver, R: CryptoRngCore, >( instance: &mut I, opening_accumulator: &mut ProverOpeningAccumulator, blindfold_accumulator: &mut crate::subprotocols::blindfold::BlindFoldAccumulator, - transcript: &mut T, + transcript: &mut impl ProverFs, pedersen_gens: &PedersenGenerators, rng: &mut R, -) -> ZkUniSkipFirstRoundProof { +) { use crate::subprotocols::blindfold::UniSkipStageData; let input_claim = instance.input_claim(opening_accumulator); @@ -174,11 +169,19 @@ pub fn prove_uniskip_round_zk< let blinding = F::random(rng); let commitment = pedersen_gens.commit(&uni_poly.coeffs, &blinding); - transcript.append_commitment(b"sumcheck_commitment", &commitment); + // The first-round commitment is prover-only payload: written into the NARG (which also + // absorbs it) immediately before squeezing r0. The verifier reads it back at the SAME + // position in `verify_transcript`, keeping the write-commitment → squeeze-challenge + // interleave symmetric. + transcript.write_slice(std::slice::from_ref(&commitment)); - let r0: F::Challenge = transcript.challenge_scalar_optimized::(); + let r0: F::Challenge = transcript.challenge_optimized(); instance.cache_openings(opening_accumulator, &[r0]); + // After the challenge, write the public polynomial degree (needed by stage 8 for R1CS + // config), then the output-claim commitments — the verifier reads in this same order. + transcript.write_slice(std::slice::from_ref(&poly_degree)); + let output_claim_values = opening_accumulator.take_pending_claims(); let output_claim_ids = opening_accumulator.take_pending_claim_ids(); let oc_committed: Vec<_> = pedersen_gens.commit_chunked(&output_claim_values, rng); @@ -188,7 +191,7 @@ pub fn prove_uniskip_round_zk< .collect(); let output_claims_commitments: Vec<_> = oc_committed.iter().map(|(c, _)| *c).collect(); let output_claims_blindings: Vec<_> = oc_committed.iter().map(|(_, b)| *b).collect(); - transcript.append_commitments(b"output_claims_coms", &output_claims_commitments); + transcript.write_slice(&output_claims_commitments); let input_constraint = instance.get_params().input_claim_constraint(); let input_constraint_challenge_values = instance @@ -214,59 +217,67 @@ pub fn prove_uniskip_round_zk< output_claims_blindings, output_claims_commitments: output_claims_commitments.clone(), }); - - ZkUniSkipFirstRoundProof::new(commitment, poly_degree, output_claims_commitments) -} - -/// The sumcheck proof for a univariate skip round -/// Consists of the (single) univariate polynomial sent in that round, no omission of any coefficient -#[derive(CanonicalSerialize, CanonicalDeserialize, Debug, Clone)] -pub struct UniSkipFirstRoundProof { - pub uni_poly: UniPoly, - _marker: PhantomData, } -impl UniSkipFirstRoundProof { - pub fn new(uni_poly: UniPoly) -> Self { - Self { - uni_poly, - _marker: PhantomData, - } - } - - /// Verify only the univariate-skip first round. - /// Returns the challenge derived during verification. +/// Non-ZK univariate-skip first-round verification namespace. +/// +/// Under the NARG the full first-round polynomial lives in the NARG byte-string — +/// the prover writes it via `write_slice` and the verifier reads it back with +/// `read_slice`. This type carries no data; it only groups the non-ZK first-round +/// verification logic. The Fiat-Shamir mode is selected globally by `JoltProof::zk_mode`. +/// +/// ⚠️ ZK-MIGRATION NOTE (parallel to `clear_sumcheck`; see DEV-27): do NOT re-add a +/// `uni_poly` field. The first-round poly is in the NARG (Option B). The ZK path uses the +/// separate `zk_uni_skip_first_round` (Pedersen commitment + degree), NOT this path. +/// If the ZK migration ever needs the Standard first-round coeffs, read them from the NARG +/// (`read_slice`), do not restore a field. +pub mod uni_skip_first_round { + use super::*; + + /// Verify only the univariate-skip first round by reading the polynomial back + /// from the NARG. The checks (degree, symmetric-domain sum, evaluation) are + /// identical to the cleartext path; only the source of the polynomial changed. pub fn verify< + F: JoltField, const N: usize, const FIRST_ROUND_POLY_NUM_COEFFS: usize, A: AbstractVerifierOpeningAccumulator, >( - proof: &Self, - sumcheck_instance: &dyn SumcheckInstanceVerifier, + sumcheck_instance: &dyn SumcheckInstanceVerifier, opening_accumulator: &mut A, - transcript: &mut T, + transcript: &mut impl VerifierFs, ) -> Result { let degree_bound = sumcheck_instance.degree(); - // Degree check for the high-degree first polynomial - if proof.uni_poly.degree() > degree_bound { + + // Read the full first-round polynomial back from the NARG and derive r0. + let coeffs: Vec = transcript + .read_scalars() + .map_err(|_| ProofVerifyError::UniSkipVerificationError)?; + // The first-round polynomial has a fixed coefficient count; reject a frame of + // any other length before it reaches `check_sum_evals` (which indexes by + // `FIRST_ROUND_POLY_NUM_COEFFS` and assumes exactly that many coefficients). + if coeffs.len() != FIRST_ROUND_POLY_NUM_COEFFS { + return Err(ProofVerifyError::InvalidInputLength( + FIRST_ROUND_POLY_NUM_COEFFS, + coeffs.len(), + )); + } + let uni_poly = UniPoly::from_coeff(coeffs); + if uni_poly.degree() > degree_bound { return Err(ProofVerifyError::InvalidInputLength( degree_bound, - proof.uni_poly.degree(), + uni_poly.degree(), )); } - - // Append full polynomial and derive r0 - transcript.append_scalars(b"uniskip_poly", &proof.uni_poly.coeffs); - let r0 = transcript.challenge_scalar_optimized::(); + let r0 = transcript.challenge_optimized(); // Check symmetric-domain sum equals zero (initial claim), and compute next claim s1(r0) let input_claim = sumcheck_instance.input_claim(opening_accumulator); - let input_claim_ok = proof - .uni_poly - .check_sum_evals::(input_claim); + let input_claim_ok = + uni_poly.check_sum_evals::(input_claim); sumcheck_instance.cache_openings(opening_accumulator, &[r0]); - let expected_output = proof.uni_poly.evaluate(&r0); + let expected_output = uni_poly.evaluate(&r0); let claimed_output = sumcheck_instance.expected_output_claim(opening_accumulator, &[r0]); let output_claim_ok = claimed_output == expected_output; @@ -280,195 +291,71 @@ impl UniSkipFirstRoundProof { } } -/// ZK variant of uni-skip first round proof. -/// Contains only the Pedersen commitment to polynomial coefficients. -/// Actual verification is deferred to BlindFold R1CS. +/// ZK uni-skip values read back from the NARG during verification, threaded to stage 8 +/// (BlindFold) which can no longer read them from the (now data-free) proof struct. +/// Constructed only in ZK builds; the type exists in both so the uni-skip verify helpers +/// have a uniform signature (the non-ZK path always yields `None`). #[derive(Debug, Clone)] -pub struct ZkUniSkipFirstRoundProof, T: Transcript> { +pub struct ZkUniSkipReadback { pub commitment: C::G1, pub poly_degree: usize, - /// Pedersen commitments to output claims, chunked to fit generator count + /// Pedersen commitments to output claims, chunked to fit generator count. pub output_claims_commitments: Vec, - _marker: PhantomData<(F, T)>, } -impl, T: Transcript> ZkUniSkipFirstRoundProof { - pub fn new( - commitment: C::G1, - poly_degree: usize, - output_claims_commitments: Vec, - ) -> Self { - Self { - commitment, - poly_degree, - output_claims_commitments, - _marker: PhantomData, - } - } - - /// Verify transcript consistency only. - /// The actual polynomial verification (sum check + evaluation) is done by BlindFold. +/// ZK uni-skip first-round verification namespace: reads the prover-only commitment, +/// degree, and output-claim commitments back from the NARG (the proof carries no data). +#[cfg(feature = "zk")] +pub mod zk_uni_skip_first_round { + use super::*; + + /// Verify transcript consistency only by reading the first-round values back from the + /// NARG. The actual polynomial verification (sum check + evaluation) is done by BlindFold. + /// + /// Reads, in the exact order the prover wrote them in `prove_uniskip_round_zk`: the + /// commitment (then squeezes r0), the polynomial degree, then the output-claim + /// commitments. Returns the read-back data so stage 8 can consume it. pub fn verify_transcript< + F: JoltField, + C: JoltCurve, A: AbstractVerifierOpeningAccumulator, - I: SumcheckInstanceVerifier, + I: SumcheckInstanceVerifier, >( - &self, sumcheck_instance: &I, opening_accumulator: &mut A, - transcript: &mut T, - ) -> Result { + transcript: &mut impl VerifierFs, + ) -> Result<(F::Challenge, ZkUniSkipReadback), ProofVerifyError> { let degree_bound = sumcheck_instance.degree(); - if self.poly_degree > degree_bound { + + let commitment: C::G1 = transcript + .read_single() + .map_err(|_| ProofVerifyError::UniSkipVerificationError)?; + + let r0: F::Challenge = transcript.challenge_optimized(); + sumcheck_instance.cache_openings(opening_accumulator, &[r0]); + + let poly_degree: usize = transcript + .read_single() + .map_err(|_| ProofVerifyError::UniSkipVerificationError)?; + if poly_degree > degree_bound { return Err(ProofVerifyError::InvalidInputLength( degree_bound, - self.poly_degree, + poly_degree, )); } - transcript.append_commitment(b"sumcheck_commitment", &self.commitment); - - let r0: F::Challenge = transcript.challenge_scalar_optimized::(); - sumcheck_instance.cache_openings(opening_accumulator, &[r0]); - - transcript.append_commitments(b"output_claims_coms", &self.output_claims_commitments); + let output_claims_commitments: Vec = transcript + .read_slice() + .map_err(|_| ProofVerifyError::UniSkipVerificationError)?; opening_accumulator.take_pending_claims(); - Ok(r0) - } -} - -impl, T: Transcript> CanonicalSerialize - for ZkUniSkipFirstRoundProof -{ - fn serialize_with_mode( - &self, - mut writer: W, - compress: ark_serialize::Compress, - ) -> Result<(), ark_serialize::SerializationError> { - self.commitment.serialize_with_mode(&mut writer, compress)?; - self.poly_degree - .serialize_with_mode(&mut writer, compress)?; - self.output_claims_commitments - .serialize_with_mode(writer, compress) - } - - fn serialized_size(&self, compress: ark_serialize::Compress) -> usize { - self.commitment.serialized_size(compress) - + self.poly_degree.serialized_size(compress) - + self.output_claims_commitments.serialized_size(compress) - } -} - -impl, T: Transcript> CanonicalDeserialize - for ZkUniSkipFirstRoundProof -{ - fn deserialize_with_mode( - mut reader: R, - compress: ark_serialize::Compress, - validate: ark_serialize::Validate, - ) -> Result { - let commitment = C::G1::deserialize_with_mode(&mut reader, compress, validate)?; - let poly_degree = usize::deserialize_with_mode(&mut reader, compress, validate)?; - let output_claims_commitments = - Vec::::deserialize_with_mode(reader, compress, validate)?; - Ok(Self::new( - commitment, - poly_degree, - output_claims_commitments, + Ok(( + r0, + ZkUniSkipReadback { + commitment, + poly_degree, + output_claims_commitments, + }, )) } } - -impl, T: Transcript> ark_serialize::Valid - for ZkUniSkipFirstRoundProof -{ - fn check(&self) -> Result<(), ark_serialize::SerializationError> { - self.commitment.check()?; - self.output_claims_commitments.check() - } -} - -/// Unified proof enum for uni-skip first round (similar to SumcheckInstanceProof). -#[derive(Debug, Clone)] -pub enum UniSkipFirstRoundProofVariant, T: Transcript> { - Standard(UniSkipFirstRoundProof), - Zk(ZkUniSkipFirstRoundProof), -} - -impl, T: Transcript> UniSkipFirstRoundProofVariant { - pub fn is_zk(&self) -> bool { - matches!(self, Self::Zk(_)) - } - - /// Returns the polynomial degree for BlindFold R1CS configuration. - pub fn poly_degree(&self) -> usize { - match self { - Self::Standard(p) => p.uni_poly.degree(), - Self::Zk(p) => p.poly_degree, - } - } -} - -impl, T: Transcript> CanonicalSerialize - for UniSkipFirstRoundProofVariant -{ - fn serialize_with_mode( - &self, - mut writer: W, - compress: ark_serialize::Compress, - ) -> Result<(), ark_serialize::SerializationError> { - match self { - Self::Standard(proof) => { - 0u8.serialize_with_mode(&mut writer, compress)?; - proof.serialize_with_mode(writer, compress) - } - Self::Zk(proof) => { - 1u8.serialize_with_mode(&mut writer, compress)?; - proof.serialize_with_mode(writer, compress) - } - } - } - - fn serialized_size(&self, compress: ark_serialize::Compress) -> usize { - 1 + match self { - Self::Standard(proof) => proof.serialized_size(compress), - Self::Zk(proof) => proof.serialized_size(compress), - } - } -} - -impl, T: Transcript> CanonicalDeserialize - for UniSkipFirstRoundProofVariant -{ - fn deserialize_with_mode( - mut reader: R, - compress: ark_serialize::Compress, - validate: ark_serialize::Validate, - ) -> Result { - let variant = u8::deserialize_with_mode(&mut reader, compress, validate)?; - match variant { - 0 => { - let proof = - UniSkipFirstRoundProof::deserialize_with_mode(reader, compress, validate)?; - Ok(Self::Standard(proof)) - } - 1 => { - let proof = - ZkUniSkipFirstRoundProof::deserialize_with_mode(reader, compress, validate)?; - Ok(Self::Zk(proof)) - } - _ => Err(ark_serialize::SerializationError::InvalidData), - } - } -} - -impl, T: Transcript> ark_serialize::Valid - for UniSkipFirstRoundProofVariant -{ - fn check(&self) -> Result<(), ark_serialize::SerializationError> { - match self { - Self::Standard(p) => p.check(), - Self::Zk(p) => p.check(), - } - } -} diff --git a/jolt-core/src/transcript_msgs.rs b/jolt-core/src/transcript_msgs.rs new file mode 100644 index 0000000000..6c72d91514 --- /dev/null +++ b/jolt-core/src/transcript_msgs.rs @@ -0,0 +1,627 @@ +//! jolt-core's field-typed Fiat–Shamir vocabulary over the spongefish NARG. +//! +//! jolt-core proof code is generic over `F: JoltField` (concretely `ark_bn254::Fr`, +//! or `TrackedFr` in profiling builds). spongefish's built-in `Encoding`/`Decoding` +//! are implemented only for the concrete arkworks types, so they can't be used +//! *directly* on a generic `F`. As a **host bridge** we therefore move values through +//! the NARG as a length-prefixed `CanonicalSerialize` blob (reusing +//! [`jolt_transcript::BytesMsg`]). Byte sponges (Blake2b/Keccak) ship the anonymous +//! `BytesMsg` blob; the field-aligned Poseidon sponge (`U = Fr`) routes frames through +//! the typed `FieldFrameMsg`/`CommitmentsMsg`/`RawBytesMsg` wrappers, whose NARG bytes +//! are `BytesMsg`-identical but whose sponge absorption is typed by value kind (spec §4.2). +//! +//! Challenge width is selected **per sponge type** (matching legacy `transcripts/`), +//! via per-sponge [`FsChallenge`] impls — NOT via a Cargo feature, so enabling +//! `transcript-poseidon` cannot change what a Blake2b/Keccak-backed state derives: +//! - **Byte sponges (Blake2b/Keccak):** 128-bit +//! [`OptimizedChallenge::challenge_u128`]; `F::Challenge` is `MontU128Challenge`. They stay +//! 128-bit even under a hand-set `challenge-254-bit` — as in legacy `blake2b.rs`/`keccak.rs`. +//! - **Poseidon (`transcript-poseidon` → `challenge-254-bit`, maintainer decision on #1586):** +//! GENUINE full-field `Fr` squeezes (`verifier_message::`), `F::Challenge` is +//! `Mont254BitChallenge`. Poseidon's natural unit (128-bit truncation is costly for recursion); +//! restores legacy `transcripts/poseidon.rs` so Poseidon works end-to-end and never hits its +//! `unimplemented!()` `challenge_u128`. +//! +//! **What actually crosses the NARG:** prover-only payload — the sumcheck/uniskip round +//! polynomials via `write_scalars`/`read_scalars`, the witness-commitments and +//! untrusted-advice presence frames via `write_commitments`/`read_commitments`, and the +//! type-opaque ZK/BlindFold payloads (round-poly commitments, per-field BlindFold +//! values) via `write_slice`/`read_slice`. *Shared* values (flushed opening claims, +//! trusted/preprocessing commitments) are `absorb`'d ([`public_message`]); the dory +//! `joint_opening_proof` and — in non-ZK mode — the `opening_claims` stay **structured +//! proof fields** (see `JoltProof::narg` in `proof_serialization.rs` for the full +//! inventory). +//! +//! Three concerns, three traits: +//! - [`FsChallenge`] — squeezed verifier randomness; implemented per sponge type for +//! `ProverState`/`VerifierState`, so prover and verifier share it. +//! - [`ProverFs`] — `absorb` shared values ([`public_message`], not shipped) and +//! `write_slice` prover-only payload ([`prover_message`], into the NARG). +//! - [`VerifierFs`] — `absorb` the same shared values, and `read_slice` the prover +//! payload back from the NARG in order. +//! +//! [`public_message`]: spongefish::ProverState::public_message +//! [`prover_message`]: spongefish::ProverState::prover_message + +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use jolt_transcript::{ + serialize_slice, BytesMsg, OptimizedChallenge, ProverState, VerificationError, + VerificationResult, VerifierState, +}; + +/// Absorbing shared values is the field-agnostic [`jolt_transcript::FsAbsorb`] surface +/// (`absorb` = spongefish `public_message`); jolt-core re-exports it so the whole +/// transcript vocabulary lives behind `crate::transcript_msgs`, and there is a *single* +/// absorb implementation shared with the modular crates (no hand-kept second copy). +pub use jolt_transcript::FsAbsorb; +use rand::{CryptoRng, RngCore}; + +use crate::field::JoltField; + +// WIRE-FORMAT — RESOLVED (DEV-25 trigger #2 fired: the on-chain transpiler reader landed). +// Typed per-kind messages (`FieldFrameMsg`/`CommitmentsMsg`/`RawBytesMsg`) were introduced +// for the Poseidon (`U = Fr`) path, because the field-aligned sponge absorbs by value KIND; +// byte sponges keep the single anonymous `BytesMsg` path (see `impl_byte_sponge_fs!`). +// NARG wire bytes are identical on every path (8-byte LE length ‖ concatenated compressed +// serializations) — only Poseidon ABSORPTION is typed, so converting a call site never +// changes byte-sponge proof bytes. History: typed wrappers (DEV-12) → collapsed to +// `BytesMsg` (DEV-16) → partially reintroduced here. + +/// Squeezed field challenges, shared by both transcript roles. +/// +/// Implemented per sponge type for `ProverState`/`VerifierState` (Blake2b/Keccak: +/// 128-bit; Poseidon: full-field), so the prover and verifier derive challenges +/// identically and the width can never depend on feature unification. +/// +/// ## Plain vs optimized — the #1 silent Fiat–Shamir hazard +/// +/// (Describes the byte-sponge path — Blake2b/Keccak, the default build. Poseidon +/// leaves `challenge_u128` `unimplemented!()` and uses full-field `challenge-254-bit`; +/// see the module docs.) +/// +/// Both methods draw the *same* 128-bit value ([`OptimizedChallenge::challenge_u128`]), +/// but they produce **different field elements** — they are NOT interchangeable: +/// - [`challenge_field`](Self::challenge_field) → the plain element `v` (`from_u128`), +/// all 128 bits. +/// - [`challenge_optimized`](Self::challenge_optimized) → the fast-multiply +/// [`MontU128Challenge`](crate::field::challenge::MontU128Challenge) form, which +/// **masks the top 3 bits** (125-bit) and represents the element `v_masked · 2¹²⁸`. +/// So it differs from `challenge_field` in *value*, not merely in the returned type. +/// +/// Soundness therefore requires the prover and verifier to call the *same* method at a +/// given transcript position; mixing them silently diverges the challenge. This mirrors +/// the modular [`jolt_transcript::FsChallenge`], but **the names are inverted** there: +/// jolt-core's `challenge_field`/`challenge_optimized` map to that trait's +/// `challenge_scalar` (plain) / `challenge` (optimized) — and jolt-core's optimized form +/// additionally returns the distinct `F::Challenge` type, not `F`. Map by *semantics*, +/// never by like name. +pub trait FsChallenge { + /// Squeeze the **plain** field challenge `v` (`from_u128`), keeping all 128 bits. + /// Not interchangeable with [`challenge_optimized`](Self::challenge_optimized) — see the + /// trait docs. (Byte-sponge semantics; full-field `Fr` on the Poseidon path.) + fn challenge_field(&mut self) -> F; + + /// Squeeze the fast-multiply [`JoltField::Challenge`] form (125-bit, `v_masked · 2¹²⁸`) + /// — a *different* value than [`challenge_field`](Self::challenge_field). **Byte-sponge + /// only:** the Poseidon path has no masking, so it returns the same full `Fr` as + /// `challenge_field` (wrapped in `Mont254BitChallenge`). + fn challenge_optimized(&mut self) -> F::Challenge; + + /// `n` independent field challenges. + fn challenge_vec(&mut self, n: usize) -> Vec { + (0..n).map(|_| self.challenge_field()).collect() + } + + /// `n` independent optimized challenges. + fn challenge_optimized_vec(&mut self, n: usize) -> Vec { + (0..n).map(|_| self.challenge_optimized()).collect() + } + + /// Powers `(1, q, q², …, q^(n-1))` from a single squeezed `q`. + fn challenge_powers(&mut self, n: usize) -> Vec { + let q = self.challenge_field(); + let mut powers = Vec::with_capacity(n); + let mut cur = F::from_u64(1); + for _ in 0..n { + powers.push(cur); + cur *= q; + } + powers + } +} + +// `transcript-poseidon` forces `challenge-254-bit`, so `F::Challenge` is the +// `#[repr(transparent)]` `Mont254BitChallenge` the full-field transmute below relies on. +// Enforce that coupling (else the transmute could hit the 16-byte `MontU128Challenge`). +#[cfg(all(feature = "transcript-poseidon", not(feature = "challenge-254-bit")))] +compile_error!( + "transcript-poseidon requires challenge-254-bit (F::Challenge = Mont254BitChallenge); \ + the Poseidon full-field challenge transmute in transcript_msgs depends on it" +); + +/// Byte-sponge path (Blake2b/Keccak): 128-bit [`OptimizedChallenge::challenge_u128`]. +/// They stay 128-bit even under a hand-set `challenge-254-bit` (a 128-bit value in the +/// wider type) — dispatching on the *sponge type*, not a Cargo feature, preserves +/// legacy's per-sponge behaviour and keeps challenge derivation independent of feature +/// unification across the workspace. +impl FsChallenge + for ProverState +{ + fn challenge_field(&mut self) -> F { + F::from_u128(self.challenge_u128()) + } + + fn challenge_optimized(&mut self) -> F::Challenge { + F::Challenge::from(self.challenge_u128()) + } +} + +impl FsChallenge for VerifierState<'_, jolt_transcript::Blake2b512> { + fn challenge_field(&mut self) -> F { + F::from_u128(self.challenge_u128()) + } + + fn challenge_optimized(&mut self) -> F::Challenge { + F::Challenge::from(self.challenge_u128()) + } +} + +impl FsChallenge + for ProverState +{ + fn challenge_field(&mut self) -> F { + F::from_u128(self.challenge_u128()) + } + + fn challenge_optimized(&mut self) -> F::Challenge { + F::Challenge::from(self.challenge_u128()) + } +} + +impl FsChallenge for VerifierState<'_, jolt_transcript::Keccak> { + fn challenge_field(&mut self) -> F { + F::from_u128(self.challenge_u128()) + } + + fn challenge_optimized(&mut self) -> F::Challenge { + F::Challenge::from(self.challenge_u128()) + } +} + +/// Reconstruct `F` from a native BN254 `Fr` (runs per challenge / per read scalar). +/// +/// When `F` *is* `ark_bn254::Fr` (the production instantiation) this is the identity: +/// a single `transmute_copy` skips the `into_bigint → LE bytes → from_le_bytes_mod_order` +/// round-trip. For any other `F` (e.g. the `repr(Rust)` `TrackedFr` profiling newtype, +/// whose in-memory layout is NOT guaranteed to match `Fr`) it falls back to the canonical +/// 32-byte LE round-trip. `from_bytes` is `from_le_bytes_mod_order` and the bytes are +/// `< modulus`, so the round-trip is exact. The `TypeId` branch is on a monomorphized +/// `F`, so the compiler folds it to a single path per instantiation (no runtime cost). +fn native_to_field(native: ark_bn254::Fr) -> F { + use ark_ff::PrimeField; + if core::any::TypeId::of::() == core::any::TypeId::of::() { + // SAFETY: the `TypeId` guard proves `F` is exactly `ark_bn254::Fr` (both are + // `'static`), so this reinterprets `Fr → Fr` — an identity copy of identical size + // and layout. Equals the round-trip below: `into_bigint`/`from_le_bytes_mod_order` + // compose to the identity on a canonical `Fr` value. + return unsafe { core::mem::transmute_copy::(&native) }; + } + let limbs = native.into_bigint().0; + let mut le = [0u8; 32]; + for (i, limb) in limbs.iter().enumerate() { + le[i * 8..(i + 1) * 8].copy_from_slice(&limb.to_le_bytes()); + } + F::from_bytes(&le) +} + +/// The inverse of [`native_to_field`]: a native BN254 `Fr` unit carrying the value of `F` +/// (sumcheck/uni-skip round-poly coeffs — runs per element in the `write_scalars` hot path). +/// +/// When `F` *is* `ark_bn254::Fr` (the production instantiation) this is the identity: +/// a single `transmute_copy` replaces the `serialize_compressed → deserialize_compressed` +/// no-op round-trip. For any other `F` (e.g. the `repr(Rust)` `TrackedFr` profiling newtype) +/// it falls back to the canonical serialization round-trip, exact for every `F` this crate +/// instantiates (BN254-backed, `NUM_BYTES == 32`). The `TypeId` branch is on a monomorphized +/// `F`, so the compiler folds it to a single path per instantiation (no runtime cost). +#[expect( + clippy::expect_used, + reason = "32-byte canonical field serialization round-trips infallibly" +)] +fn field_to_native(value: &F) -> ark_bn254::Fr { + use ark_serialize::CanonicalDeserialize; + if core::any::TypeId::of::() == core::any::TypeId::of::() { + // SAFETY: the `TypeId` guard proves `F` is exactly `ark_bn254::Fr` (both are + // `'static`), so this reinterprets `Fr → Fr` — an identity copy of identical size + // and layout. Equals the round-trip below: `serialize_compressed` writes `Fr`'s + // canonical (non-Montgomery) LE bytes and `deserialize_compressed::` reads them + // back into the same in-memory `Fr`, i.e. the identity on the value. + return unsafe { core::mem::transmute_copy::(value) }; + } + let mut buf = [0u8; 32]; + value + .serialize_compressed(&mut buf[..]) + .expect("32-byte field element serializes into a 32-byte buffer"); + ark_bn254::Fr::deserialize_compressed(&buf[..]).expect("canonical field bytes parse as Fr") +} + +/// Reinterpret a full field element as its `F::Challenge` (Poseidon-only: there it's the +/// `#[repr(transparent)]` `Mont254BitChallenge`). One shared spot so the `unsafe` +/// prover/verifier reinterpretation can't drift. +fn wrap_full_field(f: F) -> F::Challenge { + // Turn a layout mismatch into a compile error — the check `transmute` makes but + // `transmute_copy` skips for generics (e.g. rejects the 16-byte `MontU128Challenge`). + // Instantiated only by the Poseidon impls below, so a build whose `F::Challenge` is + // `MontU128Challenge` only fails if it actually uses a Poseidon-backed transcript. + const { assert!(core::mem::size_of::() == core::mem::size_of::()) } + // SAFETY: `F::Challenge` is the `#[repr(transparent)]` `Mont254BitChallenge` newtype + // of `F` — identical size (asserted) and layout. Mirrors legacy `challenge_scalar_optimized`. + unsafe { core::mem::transmute_copy::(&f) } +} + +/// Poseidon path (`transcript-poseidon` → `challenge-254-bit`): the optimized challenge is a +/// GENUINE full-field `Fr` (Poseidon's natural unit), not a 128-bit truncation, so Poseidon +/// never hits its `unimplemented!()` `challenge_u128` (kept unimplemented per the maintainer's +/// decision on #1586). With the field-aligned `U = Fr` sponge the squeeze is **one native unit +/// = exactly one permute** ([`jolt_transcript::NativeChallenge`], identity decode) — exactly +/// uniform under the ideal-permutation model, with zero in-circuit decode cost. NB: here +/// `challenge_field` and `challenge_optimized` return the SAME value (no `v·2¹²⁸` masking). +impl FsChallenge for ProverState +where + F: JoltField, + R: RngCore + CryptoRng, +{ + fn challenge_field(&mut self) -> F { + native_to_field(ProverState::verifier_message::(self).0) + } + + fn challenge_optimized(&mut self) -> F::Challenge { + let f: F = self.challenge_field(); + wrap_full_field(f) + } +} + +impl FsChallenge for VerifierState<'_, jolt_transcript::PoseidonSponge> { + fn challenge_field(&mut self) -> F { + native_to_field(VerifierState::verifier_message::(self).0) + } + + fn challenge_optimized(&mut self) -> F::Challenge { + let f: F = self.challenge_field(); + wrap_full_field(f) + } +} + +/// Decode every `T` in a single self-delimiting frame. +/// +/// The frame body length (carried by [`BytesMsg`]'s prefix, which the sponge already +/// bounds to the remaining NARG) determines the count — so a sequence is read back +/// without shipping or trusting a separate length, and the per-round element count +/// may vary. This is also why we never deserialize a `Vec` directly: +/// `CanonicalDeserialize` for `Vec` reads its OWN length prefix from the NARG and +/// pre-allocates from it, so an adversarial proof could capacity-overflow panic / OOM. +/// Here every allocation is bounded by the actual frame bytes, which are bounded by the +/// actual proof. +fn read_all(body: &[u8]) -> VerificationResult> { + let mut cursor = body; + let mut out = Vec::new(); + while !cursor.is_empty() { + out.push(T::deserialize_compressed(&mut cursor).map_err(|_| VerificationError)?); + } + Ok(out) +} + +/// Prover-side message vocabulary over the spongefish NARG. +/// +/// The typed `write_scalars`/`write_commitments` variants exist because the +/// `Fr`-unit Poseidon sponge absorbs by value *kind* (spec §4.2) and +/// `write_slice` is type-opaque. Their NARG bytes are identical to +/// [`write_slice`](Self::write_slice) on every sponge — only what the sponge +/// absorbs differs on the Poseidon path — so converting a call site never +/// changes the proof bytes of the byte-sponge builds. +pub trait ProverFs: FsChallenge + FsAbsorb { + /// Write a sequence of prover-only values as one self-delimiting frame + /// (read back with [`VerifierFs::read_slice`]). No length prefix is shipped; the + /// frame is bounded by the NARG, so the per-round element count may vary. + /// On the Poseidon path the sponge absorbs the frame under the byte rule — + /// the right classification for type-opaque payloads (e.g. ZK G1 points, + /// whose Fq coordinates must never embed as native `Fr` units). + fn write_slice(&mut self, values: &[T]); + + /// [`write_slice`](Self::write_slice) for a frame of **field elements** + /// (sumcheck/uni-skip round polynomials). Poseidon absorbs the count-led + /// field frame `[Fr(2k+1), e₁, …, e_k]`; read back with + /// [`VerifierFs::read_scalars`]. + fn write_scalars(&mut self, values: &[F]) { + self.write_slice(values); + } + + /// [`write_slice`](Self::write_slice) for a frame of **commitments** + /// (witness commitments, the advice presence frame). Poseidon absorbs a + /// frame count unit `Fr(2k+1)` then per-commitment byte-rule groups (one + /// GT ↦ `[Fr(768), 13 chunks]`); an empty frame is the count-led + /// `[Fr(1)]`. Read back with [`VerifierFs::read_commitments`]. + fn write_commitments(&mut self, values: &[T]) { + self.write_slice(values); + } +} + +/// Byte-sponge `ProverFs`/`VerifierFs` impls. Per-sponge (like [`FsChallenge`]) +/// rather than blanket over `H: DuplexSpongeInterface`: coherence +/// cannot prove a foreign-sponge blanket disjoint from the concrete +/// `PoseidonSponge` (`U = Fr`) impls below, because the `U` projection lives in +/// a foreign crate. +macro_rules! impl_byte_sponge_fs { + ($sponge:ty) => { + impl ProverFs for ProverState<$sponge, R> + where + F: JoltField, + R: RngCore + CryptoRng, + { + fn write_slice(&mut self, values: &[T]) { + self.prover_message(&BytesMsg(serialize_slice(values))); + } + } + + impl VerifierFs for VerifierState<'_, $sponge> { + fn read_slice(&mut self) -> VerificationResult> { + let bytes = self.prover_message::()?; + read_all(&bytes.0) + } + } + }; +} + +/// Poseidon (`U = Fr`) NARG path: every frame ships the **same bytes** as the +/// byte-sponge path (`8-byte LE length ‖ concatenated compressed +/// serializations`), while the sponge absorbs the typed unit encoding of the +/// decoded values (spec §4.2). The seam is spongefish's +/// `prover_message`/`prover_message::()`, which couples "write/read these +/// NARG bytes" with "absorb `T`'s `Encoding<[H::U]>`" — so the typed message +/// types ([`jolt_transcript::FieldFrameMsg`] / [`jolt_transcript::CommitmentsMsg`] +/// / [`jolt_transcript::RawBytesMsg`]) carry a `BytesMsg`-identical NARG codec +/// next to their `Fr`-unit encoding, and prover/verifier absorption cannot +/// drift from the shipped bytes. +impl ProverFs for ProverState +where + F: JoltField, + R: RngCore + CryptoRng, +{ + fn write_slice(&mut self, values: &[T]) { + self.prover_message(&jolt_transcript::RawBytesMsg(serialize_slice(values))); + } + + fn write_scalars(&mut self, values: &[F]) { + let units: Vec = values.iter().map(field_to_native).collect(); + self.prover_message(&jolt_transcript::FieldFrameMsg(units)); + } + + fn write_commitments(&mut self, values: &[T]) { + self.prover_message(&jolt_transcript::CommitmentsMsg(values.to_vec())); + } +} + +/// Verifier-side message vocabulary over the spongefish NARG. +/// +/// The typed `read_scalars`/`read_commitments` variants mirror +/// [`ProverFs::write_scalars`]/[`ProverFs::write_commitments`]; a frame must be +/// read with the same kind it was written with or the Poseidon sponges diverge. +pub trait VerifierFs: FsChallenge + FsAbsorb { + /// Read every value in the next frame written by [`ProverFs::write_slice`]; the + /// count is the frame's (self-delimiting, so a varying per-round length is fine). + /// Bounded allocation — see [`read_all`]. + fn read_slice(&mut self) -> VerificationResult>; + + /// Read a frame that must contain exactly one value — the single-element analogue of + /// [`read_slice`]. Errors if the frame is empty or carries more than one value, closing + /// the silent-truncation gap of `read_slice()?.into_iter().next()`. + fn read_single(&mut self) -> VerificationResult { + match <[T; 1]>::try_from(self.read_slice::()?) { + Ok([value]) => Ok(value), + Err(_) => Err(VerificationError), + } + } + + /// Read back a [`ProverFs::write_scalars`] frame of field elements. + fn read_scalars(&mut self) -> VerificationResult> { + self.read_slice() + } + + /// Read back a [`ProverFs::write_commitments`] frame of commitments. + fn read_commitments( + &mut self, + ) -> VerificationResult> { + self.read_slice() + } +} + +impl_byte_sponge_fs!(jolt_transcript::Blake2b512); +impl_byte_sponge_fs!(jolt_transcript::Keccak); + +impl VerifierFs for VerifierState<'_, jolt_transcript::PoseidonSponge> { + fn read_slice(&mut self) -> VerificationResult> { + let bytes = self.prover_message::()?; + read_all(&bytes.0) + } + + fn read_scalars(&mut self) -> VerificationResult> { + let frame = self.prover_message::()?; + Ok(frame.0.into_iter().map(native_to_field).collect()) + } + + fn read_commitments( + &mut self, + ) -> VerificationResult> { + Ok(self + .prover_message::>()? + .0) + } +} + +#[cfg(test)] +#[expect(clippy::unwrap_used)] +mod tests { + use super::*; + use ark_bn254::Fr; + use ark_std::test_rng; + use jolt_transcript::{prover_transcript, verifier_transcript, Blake2b512, VerifierTranscript}; + + const SESSION: &[u8] = b"jolt-transcript-msgs-test/v1"; + type Bl = Blake2b512; + + /// A frame of values round-trips through the NARG and `check_eof` succeeds. + #[test] + fn write_then_read_round_trips() { + let mut r = test_rng(); + let scalars: Vec = (0..5).map(|_| Fr::random(&mut r)).collect(); + + let instance = [7u8; 32]; + let mut p = prover_transcript(SESSION, instance, Bl::default()); + ProverFs::::write_slice(&mut p, &scalars); + let narg = p.narg_string().to_vec(); + + let mut v = verifier_transcript(SESSION, instance, Bl::default(), &narg); + let read: Vec = VerifierFs::::read_slice(&mut v).unwrap(); + assert_eq!(read, scalars); + VerifierTranscript::::check_eof(v).unwrap(); + } + + /// Mirrors a batched non-ZK sumcheck (`BatchedSumcheck::prove`/`verify`): + /// shared input claims → `absorb` (recomputed by verifier; not in NARG) + /// batching coeffs → `challenge_vec` + /// per round: round poly → `write_slice` (prover-only, in NARG); challenge → `challenge_optimized` + /// flushed claims → `absorb` (shared; not in NARG) + /// Verifies the verifier reconstructs every prover-only value AND both sides + /// derive identical challenges, then `check_eof`. + #[test] + fn sumcheck_shaped_narg_round_trips_and_challenges_agree() { + let mut r = test_rng(); + let n_instances = 2usize; + let n_rounds = 4usize; + let input_claims: Vec = (0..n_instances).map(|_| Fr::random(&mut r)).collect(); + let round_polys: Vec> = (0..n_rounds) + .map(|i| (0..(2 + i)).map(|_| Fr::random(&mut r)).collect()) + .collect(); + let flushed_claims: Vec = (0..5).map(|_| Fr::random(&mut r)).collect(); + let instance = [0x5C; 32]; + + let mut p = prover_transcript(SESSION, instance, Bl::default()); + for c in &input_claims { + FsAbsorb::absorb(&mut p, c); + } + let p_batching = FsChallenge::::challenge_vec(&mut p, n_instances); + let mut p_round_challenges = Vec::with_capacity(n_rounds); + for poly in &round_polys { + ProverFs::::write_slice(&mut p, poly); + p_round_challenges.push(FsChallenge::::challenge_optimized(&mut p)); + } + // Flushed opening claims are SHARED (both sides hold them) → `absorb`, matching + // the real `flush_to_transcript` (opening_proof.rs); they are NOT in the NARG. + for c in &flushed_claims { + FsAbsorb::absorb(&mut p, c); + } + let narg = p.narg_string().to_vec(); + + let mut v = verifier_transcript(SESSION, instance, Bl::default(), &narg); + for c in &input_claims { + FsAbsorb::absorb(&mut v, c); + } + let v_batching = FsChallenge::::challenge_vec(&mut v, n_instances); + let mut v_round_challenges = Vec::with_capacity(n_rounds); + for expected in &round_polys { + // round-poly counts vary, so read the self-delimiting frame (like real sumcheck) + let read: Vec = VerifierFs::::read_slice(&mut v).unwrap(); + assert_eq!( + &read, expected, + "round poly reconstructed incorrectly from NARG" + ); + v_round_challenges.push(FsChallenge::::challenge_optimized(&mut v)); + } + // Verifier absorbs the same shared flushed claims (not read from the NARG). + for c in &flushed_claims { + FsAbsorb::absorb(&mut v, c); + } + VerifierTranscript::::check_eof(v).unwrap(); + + assert_eq!(p_batching, v_batching, "batching challenges diverged"); + assert_eq!( + p_round_challenges, v_round_challenges, + "round challenges diverged" + ); + } + + /// Trailing garbage must be rejected by `check_eof` (malleability guard). + #[test] + fn trailing_garbage_is_rejected_by_check_eof() { + let instance = [1u8; 32]; + let mut p = prover_transcript(SESSION, instance, Bl::default()); + ProverFs::::write_slice(&mut p, &[Fr::from(42u64)]); + let mut narg = p.narg_string().to_vec(); + narg.push(0xFF); + + let mut v = verifier_transcript(SESSION, instance, Bl::default(), &narg); + let _: Vec = VerifierFs::::read_slice(&mut v).unwrap(); + assert!(VerifierTranscript::::check_eof(v).is_err()); + } + + /// Reading fewer frames than the prover wrote cannot silently pass: the unconsumed + /// frame is caught by `check_eof`, so a desynced/short read order is rejected. + #[test] + fn under_reading_narg_is_rejected_by_check_eof() { + let mut r = test_rng(); + let frame_a: Vec = vec![Fr::random(&mut r)]; + let frame_b: Vec = (0..4).map(|_| Fr::random(&mut r)).collect(); + let instance = [0x0D; 32]; + + let mut p = prover_transcript(SESSION, instance, Bl::default()); + ProverFs::::write_slice(&mut p, &frame_a); + ProverFs::::write_slice(&mut p, &frame_b); + let narg = p.narg_string().to_vec(); + + // Verifier reads only the first frame, leaving frame_b unconsumed. + let mut v = verifier_transcript(SESSION, instance, Bl::default(), &narg); + let read_a: Vec = VerifierFs::::read_slice(&mut v).unwrap(); + assert_eq!(read_a, frame_a); + assert!( + VerifierTranscript::::check_eof(v).is_err(), + "an unconsumed NARG frame must be rejected" + ); + } + + /// Under `transcript-poseidon` the optimized challenge is a GENUINE full-field + /// `Fr`, not a 128-bit truncation, and prover/verifier must agree on it. Exercises + /// the Poseidon `FsChallenge` impls (the legacy-restored full-field path) on the + /// actual `PoseidonSponge`. + #[cfg(feature = "transcript-poseidon")] + #[test] + fn full_field_optimized_challenge_agrees_and_is_not_truncated() { + use ark_ff::PrimeField; + use jolt_transcript::PoseidonSponge; + let instance = [0x2E; 32]; + let n = 8usize; + + let mut p = prover_transcript(SESSION, instance, PoseidonSponge::default()); + let p_ch: Vec<::Challenge> = (0..n) + .map(|_| FsChallenge::::challenge_optimized(&mut p)) + .collect(); + let narg = p.narg_string().to_vec(); + + let mut v = verifier_transcript(SESSION, instance, PoseidonSponge::default(), &narg); + for expected in &p_ch { + let got = FsChallenge::::challenge_optimized(&mut v); + assert_eq!(*expected, got, "full-field optimized challenge diverged"); + } + VerifierTranscript::::check_eof(v).unwrap(); + + // A 128-bit-truncated path would leave the top two limbs (bits 128+) zero + // for every challenge; a genuine full-field squeeze sets them w.h.p. + let uses_high_bits = p_ch.iter().any(|c| { + let f: Fr = (*c).into(); + let limbs = f.into_bigint().0; + limbs[2] != 0 || limbs[3] != 0 + }); + assert!( + uses_high_bits, + "optimized challenge is 128-bit truncated, not full-field 254-bit" + ); + } +} diff --git a/jolt-core/src/transcripts/blake2b.rs b/jolt-core/src/transcripts/blake2b.rs deleted file mode 100644 index b25d892506..0000000000 --- a/jolt-core/src/transcripts/blake2b.rs +++ /dev/null @@ -1,268 +0,0 @@ -use super::transcript::Transcript; -use crate::field::JoltField; -use blake2::digest::consts::U32; -use blake2::{Blake2b, Digest}; - -type Blake2b256 = Blake2b; - -/// Represents the current state of the protocol's Fiat-Shamir transcript using Blake2b. -#[derive(Default, Clone)] -pub struct Blake2bTranscript { - /// 256-bit running state - pub state: [u8; 32], - /// We append an ordinal to each invocation of the hash - n_rounds: u32, - #[cfg(test)] - /// A complete history of the transcript's `state`; used for testing. - state_history: Vec<[u8; 32]>, - #[cfg(test)] - /// For a proof to be valid, the verifier's `state_history` should always match - /// the prover's. In testing, the Jolt verifier may be provided the prover's - /// `state_history` so that we can detect any deviations and the backtrace can - /// tell us where it happened. - expected_state_history: Option>, -} - -impl Blake2bTranscript { - /// Gives the hasher object with the running seed and index added - /// To load hash you must call finalize, after appending u8 vectors - fn hasher(&self) -> Blake2b256 { - let mut packed = [0_u8; 28].to_vec(); - packed.append(&mut self.n_rounds.to_be_bytes().to_vec()); - Blake2b256::new() - .chain_update(self.state) - .chain_update(&packed) - } - - // Loads arbitrary byte lengths using ceil(out/32) invocations of 32 byte randoms - // Discards top bits when the size is less than 32 bytes - fn challenge_bytes(&mut self, out: &mut [u8]) { - let mut remaining_len = out.len(); - let mut start = 0; - while remaining_len > 32 { - self.challenge_bytes32(&mut out[start..start + 32]); - start += 32; - remaining_len -= 32; - } - // We load a full 32 byte random region - let mut full_rand = vec![0_u8; 32]; - self.challenge_bytes32(&mut full_rand); - // Then only clone the first bits of this random region to perfectly fill out - out[start..start + remaining_len].clone_from_slice(&full_rand[0..remaining_len]); - } - - // Loads exactly 32 bytes from the transcript by hashing the seed with the round constant - fn challenge_bytes32(&mut self, out: &mut [u8]) { - assert_eq!(32, out.len()); - let rand: [u8; 32] = self.hasher().finalize().into(); - out.clone_from_slice(rand.as_slice()); - self.update_state(rand); - } - - fn update_state(&mut self, new_state: [u8; 32]) { - self.state = new_state; - self.n_rounds += 1; - #[cfg(test)] - { - if let Some(expected_state_history) = &self.expected_state_history { - assert!( - new_state == expected_state_history[self.n_rounds as usize], - "Fiat-Shamir transcript mismatch" - ); - } - self.state_history.push(new_state); - } - } -} - -impl Transcript for Blake2bTranscript { - fn new(label: &'static [u8]) -> Self { - // Hash in the label - assert!(label.len() < 33); - let hasher = if label.len() == 32 { - Blake2b256::new().chain_update(label) - } else { - let zeros = vec![0_u8; 32 - label.len()]; - Blake2b256::new().chain_update(label).chain_update(zeros) - }; - let out = hasher.finalize(); - - Self { - state: out.into(), - n_rounds: 0, - #[cfg(test)] - state_history: vec![out.into()], - #[cfg(test)] - expected_state_history: None, - } - } - - #[cfg(test)] - /// Compare this transcript to `other` and panic if/when they deviate. - /// Typically used to compare the verifier's transcript to the prover's. - fn compare_to(&mut self, other: Self) { - self.expected_state_history = Some(other.state_history); - } - - // === Internal raw methods (EVM-compatible serialization) === - - fn raw_append_label(&mut self, label: &'static [u8]) { - // Labels must fit into one EVM word, right-padded with zeros - // (matches Solidity's bytes32 string casting) - assert!(label.len() < 33); - let hasher = if label.len() == 32 { - self.hasher().chain_update(label) - } else { - let mut packed = label.to_vec(); - packed.append(&mut vec![0_u8; 32 - label.len()]); - self.hasher().chain_update(packed) - }; - self.update_state(hasher.finalize().into()); - } - - fn raw_append_bytes(&mut self, bytes: &[u8]) { - // Add the message and label - let hasher = self.hasher().chain_update(bytes); - self.update_state(hasher.finalize().into()); - } - - fn raw_append_u64(&mut self, x: u64) { - // Allocate into a 32 byte region (left-padded for EVM uint256 compatibility) - let mut packed = [0_u8; 24].to_vec(); - packed.append(&mut x.to_be_bytes().to_vec()); - let hasher = self.hasher().chain_update(packed.clone()); - self.update_state(hasher.finalize().into()); - } - - fn raw_append_scalar(&mut self, scalar: &F) { - let mut buf = vec![]; - scalar.serialize_uncompressed(&mut buf).unwrap(); - // Serialize uncompressed gives the scalar in LE byte order which is not - // a natural representation in the EVM for scalar math so we reverse - // to get an EVM compatible version. - buf = buf.into_iter().rev().collect(); - self.raw_append_bytes(&buf); - } - - // === Challenge generation methods === - - fn challenge_u128(&mut self) -> u128 { - let mut buf = vec![0u8; 16]; - self.challenge_bytes(&mut buf); - buf = buf.into_iter().rev().collect(); - u128::from_be_bytes(buf.try_into().unwrap()) - } - - fn challenge_scalar(&mut self) -> F { - // Under the hood all Fr are 128 bits for performance - self.challenge_scalar_128_bits() - } - - fn challenge_scalar_128_bits(&mut self) -> F { - let mut buf = vec![0u8; 16]; - self.challenge_bytes(&mut buf); - - buf = buf.into_iter().rev().collect(); - F::from_bytes(&buf) - } - - fn challenge_vector(&mut self, len: usize) -> Vec { - (0..len) - .map(|_i| self.challenge_scalar()) - .collect::>() - } - - // Compute powers of scalar q : (1, q, q^2, ..., q^(len-1)) - fn challenge_scalar_powers(&mut self, len: usize) -> Vec { - let q: F = self.challenge_scalar(); - let mut q_powers = vec![F::one(); len]; - for i in 1..len { - q_powers[i] = q_powers[i - 1] * q; - } - q_powers - } - - fn challenge_scalar_optimized(&mut self) -> F::Challenge { - // The smaller challenge which is then converted into a - // MontU128Challenge - let challenge_scalar: u128 = self.challenge_u128(); - F::Challenge::from(challenge_scalar) - } - - fn challenge_vector_optimized(&mut self, len: usize) -> Vec { - (0..len) - .map(|_i| self.challenge_scalar_optimized::()) - .collect::>() - } - - fn challenge_scalar_powers_optimized(&mut self, len: usize) -> Vec { - // This is still different from challenge_scalar_powers as inside the for loop - // we use an optimised multiplication every time we compute the powers. - let q: F::Challenge = self.challenge_scalar_optimized::(); - let mut q_powers = vec![::one(); len]; - for i in 1..len { - q_powers[i] = q * q_powers[i - 1]; // this is optimised - } - q_powers - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ark_bn254::Fr; - use std::collections::HashSet; - - #[test] - fn test_challenge_scalar_128_bits() { - let mut transcript = Blake2bTranscript::new(b"test_128_bit_scalar"); - let mut scalars = HashSet::new(); - - for i in 0..10000 { - let scalar: Fr = transcript.challenge_scalar_128_bits(); - - let num_bits = scalar.num_bits(); - assert!( - num_bits <= 128, - "Scalar at iteration {i} has {num_bits} bits, expected <= 128", - ); - - assert!( - scalars.insert(scalar), - "Duplicate scalar found at iteration {i}", - ); - } - } - - #[test] - fn test_challenge_special_trivial() { - use ark_std::UniformRand; - let mut rng = ark_std::test_rng(); - let mut transcript1 = Blake2bTranscript::new(b"test_trivial_challenge"); - - let challenge = transcript1.challenge_scalar_optimized::(); - // The same challenge as a full fat Fr element - let challenge_regular: Fr = challenge.into(); - - let field_elements: Vec = (0..10).map(|_| Fr::rand(&mut rng)).collect(); - - for (i, &field_elem) in field_elements.iter().enumerate() { - let result_challenge = field_elem * challenge; - let result_regular = field_elem * challenge_regular; - - assert_eq!( - result_challenge, result_regular, - "Multiplication mismatch at index {i}" - ); - } - - let field_elem = Fr::rand(&mut rng); - #[allow(clippy::op_ref)] - let result_ref = field_elem * &challenge; - let result_regular = field_elem * challenge; - assert_eq!( - result_ref, result_regular, - "Reference multiplication mismatch" - ); - } -} diff --git a/jolt-core/src/transcripts/keccak.rs b/jolt-core/src/transcripts/keccak.rs deleted file mode 100644 index ffc83464fb..0000000000 --- a/jolt-core/src/transcripts/keccak.rs +++ /dev/null @@ -1,234 +0,0 @@ -use super::transcript::Transcript; -use crate::field::JoltField; -use sha3::{Digest, Keccak256}; - -/// Represents the current state of the protocol's Fiat-Shamir transcript. -#[derive(Default, Clone)] -pub struct KeccakTranscript { - /// Ethereum-compatible 256-bit running state - pub state: [u8; 32], - /// We append an ordinal to each invocation of the hash - n_rounds: u32, - #[cfg(test)] - /// A complete history of the transcript's `state`; used for testing. - state_history: Vec<[u8; 32]>, - #[cfg(test)] - /// For a proof to be valid, the verifier's `state_history` should always match - /// the prover's. In testing, the Jolt verifier may be provided the prover's - /// `state_history` so that we can detect any deviations and the backtrace can - /// tell us where it happened. - expected_state_history: Option>, -} - -impl KeccakTranscript { - /// Gives the hasher object with the running seed and index added - /// To load hash you must call finalize, after appending u8 vectors - fn hasher(&self) -> Keccak256 { - let mut packed = [0_u8; 28].to_vec(); - packed.append(&mut self.n_rounds.to_be_bytes().to_vec()); - // Note we add the extra memory here to improve the ease of eth integrations - Keccak256::new() - .chain_update(self.state) - .chain_update(&packed) - } - - // Loads arbitrary byte lengths using ceil(out/32) invocations of 32 byte randoms - // Discards top bits when the size is less than 32 bytes - fn challenge_bytes(&mut self, out: &mut [u8]) { - let mut remaining_len = out.len(); - let mut start = 0; - while remaining_len > 32 { - self.challenge_bytes32(&mut out[start..start + 32]); - start += 32; - remaining_len -= 32; - } - // We load a full 32 byte random region - let mut full_rand = vec![0_u8; 32]; - self.challenge_bytes32(&mut full_rand); - // Then only clone the first bits of this random region to perfectly fill out - out[start..start + remaining_len].clone_from_slice(&full_rand[0..remaining_len]); - } - - // Loads exactly 32 bytes from the transcript by hashing the seed with the round constant - fn challenge_bytes32(&mut self, out: &mut [u8]) { - assert_eq!(32, out.len()); - let rand: [u8; 32] = self.hasher().finalize().into(); - out.clone_from_slice(rand.as_slice()); - self.update_state(rand); - } - - fn update_state(&mut self, new_state: [u8; 32]) { - self.state = new_state; - self.n_rounds += 1; - #[cfg(test)] - { - if let Some(expected_state_history) = &self.expected_state_history { - assert!( - new_state == expected_state_history[self.n_rounds as usize], - "Fiat-Shamir transcript mismatch" - ); - } - self.state_history.push(new_state); - } - } -} - -impl Transcript for KeccakTranscript { - fn new(label: &'static [u8]) -> Self { - // Hash in the label - assert!(label.len() < 33); - let hasher = if label.len() == 32 { - Keccak256::new().chain_update(label) - } else { - let zeros = vec![0_u8; 32 - label.len()]; - Keccak256::new().chain_update(label).chain_update(zeros) - }; - let out = hasher.finalize(); - - Self { - state: out.into(), - n_rounds: 0, - #[cfg(test)] - state_history: vec![out.into()], - #[cfg(test)] - expected_state_history: None, - } - } - - #[cfg(test)] - /// Compare this transcript to `other` and panic if/when they deviate. - /// Typically used to compare the verifier's transcript to the prover's. - fn compare_to(&mut self, other: Self) { - self.expected_state_history = Some(other.state_history); - } - - // === Internal raw methods (EVM-compatible serialization) === - - fn raw_append_label(&mut self, label: &'static [u8]) { - // Labels must fit into one EVM word, right-padded with zeros - // (matches Solidity's bytes32 string casting) - assert!(label.len() < 33); - let hasher = if label.len() == 32 { - self.hasher().chain_update(label) - } else { - let mut packed = label.to_vec(); - packed.append(&mut vec![0_u8; 32 - label.len()]); - self.hasher().chain_update(packed) - }; - self.update_state(hasher.finalize().into()); - } - - fn raw_append_bytes(&mut self, bytes: &[u8]) { - // Add the message and label - let hasher = self.hasher().chain_update(bytes); - self.update_state(hasher.finalize().into()); - } - - fn raw_append_u64(&mut self, x: u64) { - // Allocate into a 32 byte region (left-padded for EVM uint256 compatibility) - let mut packed = [0_u8; 24].to_vec(); - packed.append(&mut x.to_be_bytes().to_vec()); - let hasher = self.hasher().chain_update(packed.clone()); - self.update_state(hasher.finalize().into()); - } - - fn raw_append_scalar(&mut self, scalar: &F) { - let mut buf = vec![]; - scalar.serialize_uncompressed(&mut buf).unwrap(); - // Serialize uncompressed gives the scalar in LE byte order which is not - // a natural representation in the EVM for scalar math so we reverse - // to get an EVM compatible version. - buf = buf.into_iter().rev().collect(); - self.raw_append_bytes(&buf); - } - - // === Challenge generation methods === - - fn challenge_u128(&mut self) -> u128 { - let mut buf = vec![0u8; 16]; - self.challenge_bytes(&mut buf); - buf = buf.into_iter().rev().collect(); - u128::from_be_bytes(buf.try_into().unwrap()) - } - - fn challenge_scalar(&mut self) -> F { - // Under the hood all Fr are 128 bits for performance - self.challenge_scalar_128_bits() - } - - fn challenge_scalar_128_bits(&mut self) -> F { - let mut buf = vec![0u8; 16]; - self.challenge_bytes(&mut buf); - - buf = buf.into_iter().rev().collect(); - F::from_bytes(&buf) - } - - fn challenge_vector(&mut self, len: usize) -> Vec { - (0..len) - .map(|_i| self.challenge_scalar()) - .collect::>() - } - - // Compute powers of scalar q : (1, q, q^2, ..., q^(len-1)) - fn challenge_scalar_powers(&mut self, len: usize) -> Vec { - let q: F = self.challenge_scalar(); - let mut q_powers = vec![F::one(); len]; - for i in 1..len { - q_powers[i] = q_powers[i - 1] * q; - } - q_powers - } - - // New methods that return F::Challenge - fn challenge_scalar_optimized(&mut self) -> F::Challenge { - let mut buf = vec![0u8; 16]; - self.challenge_bytes(&mut buf); - - buf = buf.into_iter().rev().collect(); - F::Challenge::from(u128::from_be_bytes(buf.try_into().unwrap())) - } - - fn challenge_vector_optimized(&mut self, len: usize) -> Vec { - (0..len) - .map(|_i| self.challenge_scalar_optimized::()) - .collect::>() - } - - fn challenge_scalar_powers_optimized(&mut self, len: usize) -> Vec { - let q: F::Challenge = self.challenge_scalar_optimized::(); - let mut q_powers = vec![::one(); len]; - for i in 1..len { - q_powers[i] = q * q_powers[i - 1]; - } - q_powers - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ark_bn254::Fr; - use std::collections::HashSet; - - #[test] - fn test_challenge_scalar_128_bits() { - let mut transcript = KeccakTranscript::new(b"test_128_bit_scalar"); - let mut scalars = HashSet::new(); - - for i in 0..10000 { - let scalar: Fr = transcript.challenge_scalar_128_bits(); - - let num_bits = scalar.num_bits(); - assert!( - num_bits <= 128, - "Scalar at iteration {i} has {num_bits} bits, expected <= 128", - ); - - assert!( - scalars.insert(scalar), - "Duplicate scalar found at iteration {i}", - ); - } - } -} diff --git a/jolt-core/src/transcripts/mod.rs b/jolt-core/src/transcripts/mod.rs deleted file mode 100644 index 63e1bff73e..0000000000 --- a/jolt-core/src/transcripts/mod.rs +++ /dev/null @@ -1,11 +0,0 @@ -mod blake2b; -mod keccak; -#[cfg(feature = "transcript-poseidon")] -mod poseidon; -mod transcript; - -pub use blake2b::Blake2bTranscript; -pub use keccak::KeccakTranscript; -#[cfg(feature = "transcript-poseidon")] -pub use poseidon::PoseidonTranscript; -pub use transcript::Transcript; diff --git a/jolt-core/src/transcripts/poseidon.rs b/jolt-core/src/transcripts/poseidon.rs deleted file mode 100644 index b85e1156de..0000000000 --- a/jolt-core/src/transcripts/poseidon.rs +++ /dev/null @@ -1,710 +0,0 @@ -use super::transcript::Transcript; -use crate::field::JoltField; -use ark_bn254::Fr; -use ark_ff::PrimeField; -use ark_serialize::CanonicalSerialize; -use ark_std::Zero; -use light_poseidon::{Poseidon, PoseidonHasher}; - -/// Poseidon hash width: 3 field elements (state, n_rounds, data). -const POSEIDON_WIDTH: usize = 3; - -/// Bytes per field element chunk (BN254 Fr = 32 bytes). -const BYTES_PER_CHUNK: usize = 32; - -/// Fiat-Shamir transcript using Poseidon hash for BN254. -/// -/// Uses width-3 Poseidon (3 field element inputs) with explicit domain separation -/// via `n_rounds` counter. Each hash: `hash(state, n_rounds, data)`. -/// -/// Parameters: BN254 Fr, circom-compatible (light-poseidon). -#[derive(Default, Clone)] -pub struct PoseidonTranscript { - /// 256-bit running state - pub state: [u8; 32], - /// We append an ordinal to each invocation of the hash - pub n_rounds: u32, - #[cfg(test)] - /// A complete history of the transcript's `state`; used for testing. - state_history: Vec<[u8; 32]>, - #[cfg(test)] - /// For a proof to be valid, the verifier's `state_history` should always match - /// the prover's. In testing, the Jolt verifier may be provided the prover's - /// `state_history` so that we can detect any deviations and the backtrace can - /// tell us where it happened. - expected_state_history: Option>, -} - -impl PoseidonTranscript { - /// Create a new Poseidon hasher instance with BN254 circom-compatible parameters. - fn hasher() -> Poseidon { - Poseidon::::new_circom(POSEIDON_WIDTH).expect("Failed to initialize Poseidon for Fr") - } - - /// Hash exactly 32 bytes using Poseidon and update state. - /// Includes n_rounds for domain separation, matching Blake2b/Keccak semantics. - /// - /// # Panics - /// Panics if `bytes.len() != 32`. - fn hash_bytes32_and_update(&mut self, bytes: &[u8]) { - assert_eq!( - bytes.len(), - BYTES_PER_CHUNK, - "hash_bytes32_and_update requires exactly 32 bytes" - ); - - let mut poseidon = Self::hasher(); - - // Convert state bytes to Fr (LE format, matching arkworks). - let state_f = Fr::from_le_bytes_mod_order(&self.state); - let round_f = Fr::from(self.n_rounds as u64); - let input_f = Fr::from_le_bytes_mod_order(bytes); - - let output = poseidon - .hash(&[state_f, round_f, input_f]) - .expect("Poseidon hash failed"); - - let mut new_state = [0u8; 32]; - output.serialize_uncompressed(&mut new_state[..]).unwrap(); - - // serialize_uncompressed gives LE bytes, keep as is (no reverse) - self.update_state(new_state); - } - - /// Loads arbitrary byte lengths using ceil(out/32) invocations of 32 byte randoms. - /// Discards top bits when the size is less than 32 bytes. - fn challenge_bytes(&mut self, out: &mut [u8]) { - let mut remaining_len = out.len(); - let mut start = 0; - while remaining_len > BYTES_PER_CHUNK { - self.challenge_bytes32(&mut out[start..start + BYTES_PER_CHUNK]); - start += BYTES_PER_CHUNK; - remaining_len -= BYTES_PER_CHUNK; - } - // We load a full 32 byte random region - let mut full_rand = [0u8; 32]; - self.challenge_bytes32(&mut full_rand); - // Then only copy the first bytes of this random region to perfectly fill out - out[start..start + remaining_len].copy_from_slice(&full_rand[..remaining_len]); - } - - /// Loads exactly 32 bytes from the transcript by hashing the seed with the round constant - fn challenge_bytes32(&mut self, out: &mut [u8]) { - assert_eq!(BYTES_PER_CHUNK, out.len()); - let mut poseidon = Self::hasher(); - let state_f = Fr::from_le_bytes_mod_order(&self.state); - let round_f = Fr::from(self.n_rounds as u64); - let zero = Fr::zero(); - let output = poseidon - .hash(&[state_f, round_f, zero]) - .expect("Poseidon hash failed"); - - // serialize_uncompressed gives LE bytes, keep as is (no reverse) - output.serialize_uncompressed(&mut out[..]).unwrap(); - self.update_state(out.try_into().unwrap()); - } - - fn update_state(&mut self, new_state: [u8; 32]) { - self.state = new_state; - self.n_rounds += 1; - #[cfg(test)] - { - if let Some(expected_state_history) = &self.expected_state_history { - assert!( - (self.n_rounds as usize) < expected_state_history.len(), - "Fiat-Shamir transcript mismatch: n_rounds {} exceeds expected history length {}", - self.n_rounds, - expected_state_history.len() - ); - assert!( - new_state == expected_state_history[self.n_rounds as usize], - "Fiat-Shamir transcript mismatch at round {}", - self.n_rounds - ); - } - self.state_history.push(new_state); - } - } -} - -impl Transcript for PoseidonTranscript { - fn new(label: &'static [u8]) -> Self { - // Hash in the label - assert!(label.len() <= BYTES_PER_CHUNK); - let mut poseidon = Self::hasher(); - - // from_le_bytes_mod_order works with any length; trailing zeros in LE don't change value - let label_f = Fr::from_le_bytes_mod_order(label); - - let zero = Fr::zero(); - let initial_state = poseidon - .hash(&[label_f, zero, zero]) - .expect("Poseidon hash failed"); - - let mut state = [0u8; 32]; - initial_state - .serialize_uncompressed(&mut state[..]) - .unwrap(); - - Self { - state, - n_rounds: 0, - #[cfg(test)] - state_history: vec![state], - #[cfg(test)] - expected_state_history: None, - } - } - - #[cfg(test)] - /// Compare this transcript to `other` and panic if/when they deviate. - /// Typically used to compare the verifier's transcript to the prover's. - fn compare_to(&mut self, other: Self) { - self.expected_state_history = Some(other.state_history); - } - - // === Internal raw_append_* methods === - - fn raw_append_label(&mut self, label: &'static [u8]) { - // Labels must fit into one hash chunk - assert!(label.len() <= BYTES_PER_CHUNK); - let mut packed = [0u8; BYTES_PER_CHUNK]; - packed[..label.len()].copy_from_slice(label); - self.hash_bytes32_and_update(&packed); - } - - fn raw_append_bytes(&mut self, bytes: &[u8]) { - // Poseidon has fixed arity (3 field elements), so we chunk the input. - // First chunk: hash(state, n_rounds, chunk) - includes domain separation. - // Subsequent chunks: hash(prev, 0, chunk) - chained without redundant n_rounds. - let mut poseidon = Self::hasher(); - let state_f = Fr::from_le_bytes_mod_order(&self.state); - let round_f = Fr::from(self.n_rounds as u64); - let zero = Fr::zero(); - - let mut chunks = bytes.chunks(BYTES_PER_CHUNK); - - // First hash: includes n_rounds for domain separation - // from_le_bytes_mod_order handles any length; no padding needed - let first_chunk_f = chunks - .next() - .map(Fr::from_le_bytes_mod_order) - .unwrap_or(zero); - let mut current = poseidon - .hash(&[state_f, round_f, first_chunk_f]) - .expect("Poseidon hash failed"); - - // Remaining chunks: chain without n_rounds - for chunk in chunks { - let chunk_f = Fr::from_le_bytes_mod_order(chunk); - current = poseidon - .hash(&[current, zero, chunk_f]) - .expect("Poseidon hash failed"); - } - - let mut new_state = [0u8; 32]; - current.serialize_uncompressed(&mut new_state[..]).unwrap(); - - self.update_state(new_state); - } - - fn raw_append_u64(&mut self, x: u64) { - // Pack as native LE: from_le_bytes_mod_order(packed) = x directly - let mut packed = [0u8; BYTES_PER_CHUNK]; - packed[..8].copy_from_slice(&x.to_le_bytes()); - self.hash_bytes32_and_update(&packed); - } - - fn raw_append_scalar(&mut self, scalar: &JF) { - let mut buf = vec![]; - scalar.serialize_uncompressed(&mut buf).unwrap(); - // LE bytes of scalar → from_le_bytes_mod_order = scalar itself. - // No byte reversal needed (Groth16 circuit, not EVM). - self.raw_append_bytes(&buf); - } - - // === Public API (overrides) === - - /// Override: skip buf.reverse() from the trait default (EVM compat not needed for Groth16) - fn append_serializable(&mut self, label: &'static [u8], data: &T) { - let mut buf = vec![]; - data.serialize_uncompressed(&mut buf).unwrap(); - self.raw_append_label_with_len(label, buf.len() as u64); - // LE bytes directly, no byte reversal - self.raw_append_bytes(&buf); - } - - // === Challenge generation methods === - - fn challenge_u128(&mut self) -> u128 { - let mut buf = [0u8; 16]; - self.challenge_bytes(&mut buf); - // LE bytes directly, no reversal - u128::from_le_bytes(buf) - } - - fn challenge_scalar(&mut self) -> JF { - // Full 32-byte hash output = full Fr challenge (no truncation). - // challenge_bytes(32) → challenge_bytes32 → one hash invocation. - // from_le_bytes_mod_order(serialize_le(Fr)) = Fr (identity). - let mut buf = vec![0u8; 32]; - self.challenge_bytes(&mut buf); - JF::from_bytes(&buf) - } - - fn challenge_scalar_128_bits(&mut self) -> JF { - unimplemented!("128-bit challenges are unsupported for PoseidonTranscript"); - } - - fn challenge_vector(&mut self, len: usize) -> Vec { - (0..len) - .map(|_i| self.challenge_scalar()) - .collect::>() - } - - // Compute powers of scalar q : (1, q, q^2, ..., q^(len-1)) - fn challenge_scalar_powers(&mut self, len: usize) -> Vec { - let q: JF = self.challenge_scalar(); - let mut q_powers = vec![JF::one(); len]; - for i in 1..len { - q_powers[i] = q_powers[i - 1] * q; - } - q_powers - } - - fn challenge_scalar_optimized(&mut self) -> JF::Challenge { - // Full Fr challenge via challenge_scalar, then wrap in Challenge type. - // Mont254BitChallenge is a newtype of F → same memory layout → transmute is safe. - let scalar: JF = self.challenge_scalar(); - unsafe { std::mem::transmute_copy::(&scalar) } - } - - fn challenge_vector_optimized(&mut self, len: usize) -> Vec { - (0..len) - .map(|_| self.challenge_scalar_optimized::()) - .collect() - } - - fn challenge_scalar_powers_optimized(&mut self, len: usize) -> Vec { - // This is still different from challenge_scalar_powers as inside the for loop - // we use an optimised multiplication every time we compute the powers. - let q: JF::Challenge = self.challenge_scalar_optimized::(); - let mut q_powers = vec![::one(); len]; - for i in 1..len { - q_powers[i] = q * q_powers[i - 1]; // this is optimised - } - q_powers - } -} - -#[cfg(test)] -mod tests { - use super::*; - use ark_bn254::Fr; - use std::collections::HashSet; - - type TestTranscript = PoseidonTranscript; - - // ============================================================================ - // Append Methods Tests - // ============================================================================ - - #[test] - fn test_append_bytes_chunking() { - // Test that chunking (>32 bytes) is handled correctly - - // Test 1: Single chunk (exactly 32 bytes) - let mut t1 = TestTranscript::new(b"chunk_test"); - let data_32 = [0xABu8; 32]; - t1.append_bytes(b"data", &data_32); - let state_32 = t1.state; - - // Test 2: Two chunks (33 bytes = 32 + 1) - let mut t2 = TestTranscript::new(b"chunk_test"); - let data_33 = [0xABu8; 33]; - t2.append_bytes(b"data", &data_33); - let state_33 = t2.state; - - // Adding one byte should produce different state (different chunk boundary) - assert_ne!( - state_32, state_33, - "32-byte and 33-byte inputs should differ" - ); - - // Test 3: Determinism for multi-chunk input - let mut t3 = TestTranscript::new(b"chunk_test"); - let data_100 = [0xCDu8; 100]; // 100 bytes = 4 chunks (32+32+32+4) - t3.append_bytes(b"data", &data_100); - - let mut t4 = TestTranscript::new(b"chunk_test"); - t4.append_bytes(b"data", &data_100); - assert_eq!(t3.state, t4.state, "Multi-chunk should be deterministic"); - - // Test 4: Append order matters (chunks processed sequentially) - let mut t5 = TestTranscript::new(b"chunk_test"); - t5.append_bytes(b"data", &data_100[..50]); - t5.append_bytes(b"data", &data_100[50..]); - - // Splitting across append_bytes calls should produce different result - assert_ne!( - t3.state, t5.state, - "Single append vs split append should differ" - ); - - // Test 5: Different data in last chunk - let mut t6 = TestTranscript::new(b"chunk_test"); - let mut data_100_diff = data_100; - data_100_diff[99] = 0xFF; // Change last byte - t6.append_bytes(b"data", &data_100_diff); - assert_ne!(t3.state, t6.state, "Different data should differ"); - } - - #[test] - fn test_append_scalar_batch_vs_single() { - // Test that batch and single appends produce different states - // (batch uses label_with_len, single uses label repeatedly) - use ark_std::UniformRand; - let mut rng = ark_std::test_rng(); - - let scalars: Vec = (0..3).map(|_| Fr::rand(&mut rng)).collect(); - - // Batch: append_scalars calls label_with_len(label, count) once - let mut t_batch = TestTranscript::new(b"batch_test"); - t_batch.append_scalars(b"s", &scalars); - - // Sequential: append_scalar calls label(label) for each scalar - let mut t_sequential = TestTranscript::new(b"batch_test"); - for scalar in &scalars { - t_sequential.append_scalar(b"s", scalar); - } - - assert_ne!( - t_batch.state, t_sequential.state, - "Batch and sequential appends should differ (different label handling)" - ); - - // Test edge cases: zero and max field element are handled correctly - let mut t_zero = TestTranscript::new(b"edge_test"); - t_zero.append_scalar(b"s", &Fr::from(0u64)); - - let mut t_max = TestTranscript::new(b"edge_test"); - let max_scalar = -Fr::from(1u64); // p-1 in Fr - t_max.append_scalar(b"s", &max_scalar); - - assert_ne!( - t_zero.state, t_max.state, - "Zero and max field element should produce different states" - ); - - // Batch determinism - let mut t_batch2 = TestTranscript::new(b"batch_test"); - t_batch2.append_scalars(b"s", &scalars); - assert_eq!( - t_batch.state, t_batch2.state, - "Batch should be deterministic" - ); - } - - #[test] - fn test_append_point_batch_vs_single() { - // Test infinity handling and batch vs single behavior - use crate::curve::Bn254G1; - use ark_bn254::G1Projective; - use ark_std::UniformRand; - - let mut rng = ark_std::test_rng(); - let infinity = Bn254G1::default(); - - // Test 1: Infinity should not panic and should update state - let mut t1 = TestTranscript::new(b"infinity_test"); - t1.append_commitment(b"pt", &infinity); - assert_eq!(t1.n_rounds, 2, "Label + point = 2 rounds"); - - // Test 2: Infinity should be deterministic - let mut t2 = TestTranscript::new(b"infinity_test"); - t2.append_commitment(b"pt", &infinity); - assert_eq!(t1.state, t2.state, "Infinity should be deterministic"); - - // Test 3: Infinity differs from non-infinity - let mut t3 = TestTranscript::new(b"infinity_test"); - let non_infinity = Bn254G1(G1Projective::rand(&mut rng)); - t3.append_commitment(b"pt", &non_infinity); - assert_ne!(t1.state, t3.state, "Infinity vs non-infinity should differ"); - - // Test 4: Batch and sequential appends differ (different label handling) - let points: Vec = (0..3) - .map(|_| Bn254G1(G1Projective::rand(&mut rng))) - .collect(); - - let mut t_batch = TestTranscript::new(b"batch_test"); - t_batch.append_commitments(b"pts", &points); - - let mut t_sequential = TestTranscript::new(b"batch_test"); - for point in &points { - t_sequential.append_commitment(b"pts", point); - } - - assert_ne!( - t_batch.state, t_sequential.state, - "Batch and sequential appends should differ (different label handling)" - ); - - // Batch determinism - let mut t_batch2 = TestTranscript::new(b"batch_test"); - t_batch2.append_commitments(b"pts", &points); - assert_eq!( - t_batch.state, t_batch2.state, - "Batch should be deterministic" - ); - } - - // ============================================================================ - // Challenge Methods Tests - // ============================================================================ - - #[test] - fn test_challenge_u128() { - let mut transcript1 = TestTranscript::new(b"u128_test"); - let mut transcript2 = TestTranscript::new(b"u128_test"); - - let c1 = transcript1.challenge_u128(); - let c2 = transcript2.challenge_u128(); - - assert_eq!(c1, c2, "Deterministic challenge_u128"); - assert_ne!(c1, 0, "Challenge should not be zero"); - } - - #[test] - fn test_challenge_scalar_full_field() { - // Poseidon returns full Fr challenges (no 128-bit truncation) - let mut transcript = TestTranscript::new(b"test_full_field_scalar"); - let mut scalars = HashSet::new(); - - for i in 0..1000 { - let scalar: Fr = transcript.challenge_scalar(); - assert!( - scalars.insert(scalar), - "Duplicate scalar found at iteration {i}", - ); - } - // Verify we got distinct scalars (basic sanity check) - assert_eq!(scalars.len(), 1000); - } - - #[test] - fn test_challenge_vector_operations() { - // Test regular and optimized challenge vector generation - - // Test 1: Determinism - let mut transcript1 = TestTranscript::new(b"vector_test"); - let mut transcript2 = TestTranscript::new(b"vector_test"); - - let challenges1: Vec = transcript1.challenge_vector(5); - let challenges2: Vec = transcript2.challenge_vector(5); - - assert_eq!(challenges1.len(), 5); - assert_eq!( - challenges1, challenges2, - "challenge_vector should be deterministic" - ); - - // Test 2: Collision resistance (challenges are distinct) - let unique_challenges: HashSet = challenges1.iter().copied().collect(); - assert_eq!( - unique_challenges.len(), - challenges1.len(), - "All challenges should be distinct" - ); - - // Test 3: Optimized version determinism - let mut t3 = TestTranscript::new(b"opt_vector_test"); - let mut t4 = TestTranscript::new(b"opt_vector_test"); - - let challenges3: Vec<::Challenge> = t3.challenge_vector_optimized::(5); - let challenges4: Vec<::Challenge> = t4.challenge_vector_optimized::(5); - - assert_eq!(challenges3.len(), 5); - assert_eq!( - challenges3, challenges4, - "challenge_vector_optimized should be deterministic" - ); - } - - #[test] - fn test_challenge_scalar_powers_operations() { - // Test regular and optimized challenge_scalar_powers - - // Test 1: Regular powers verification - let mut transcript = TestTranscript::new(b"powers_test"); - let powers: Vec = transcript.challenge_scalar_powers(5); - - assert_eq!(powers.len(), 5); - assert_eq!(powers[0], Fr::from(1u64), "First power should be 1"); - - // Verify powers[i] = powers[i-1] * q (powers[1] is the base) - for i in 2..powers.len() { - assert_eq!( - powers[i], - powers[i - 1] * powers[1], - "Power at index {i} should equal powers[{i}-1] * base" - ); - } - - // Test 2: Optimized powers should also satisfy power property - let mut t2 = TestTranscript::new(b"opt_powers_test"); - let opt_powers: Vec = t2.challenge_scalar_powers_optimized(5); - - assert_eq!(opt_powers.len(), 5); - assert_eq!(opt_powers[0], Fr::from(1u64), "First power should be 1"); - - // Verify power property holds for optimized version - for i in 2..opt_powers.len() { - assert_eq!( - opt_powers[i], - opt_powers[i - 1] * opt_powers[1], - "Optimized power at index {i} should equal opt_powers[{i}-1] * base" - ); - } - - // Test 3: Equivalence between regular and optimized (same transcript state) - let mut t3 = TestTranscript::new(b"equiv_test"); - let mut t4 = TestTranscript::new(b"equiv_test"); - - let regular_powers: Vec = t3.challenge_scalar_powers(5); - let optimized_powers: Vec = t4.challenge_scalar_powers_optimized(5); - - assert_eq!( - regular_powers, optimized_powers, - "Regular and optimized powers should be equivalent" - ); - } - - #[test] - fn test_challenge_optimized_arithmetic_equivalence() { - // Verify that challenge_scalar_optimized produces a Truncate128 type - // that is arithmetically equivalent to a full Fr element via transmute - use ark_std::UniformRand; - let mut rng = ark_std::test_rng(); - let mut transcript1 = TestTranscript::new(b"test_trivial_challenge"); - - let challenge = transcript1.challenge_scalar_optimized::(); - // The same challenge as a full fat Fr element - let challenge_regular: Fr = challenge.into(); - - let field_elements: Vec = (0..10).map(|_| Fr::rand(&mut rng)).collect(); - - for (i, &field_elem) in field_elements.iter().enumerate() { - let result_challenge = field_elem * challenge; - let result_regular = field_elem * challenge_regular; - - assert_eq!( - result_challenge, result_regular, - "Multiplication mismatch at index {i}" - ); - } - - let field_elem = Fr::rand(&mut rng); - #[allow(clippy::op_ref)] - let result_ref = field_elem * &challenge; - let result_regular = field_elem * challenge; - assert_eq!( - result_ref, result_regular, - "Reference multiplication mismatch" - ); - } - - // ============================================================================ - // Property Tests - // ============================================================================ - - #[test] - fn test_deterministic_challenges() { - // Same inputs should produce same challenges across multiple test cases - let test_cases = vec![ - (b"test1" as &[u8], 123u64, b"data1" as &[u8]), - (b"test2" as &[u8], 456u64, b"data2" as &[u8]), - (b"test3" as &[u8], 789u64, b"longer test data here" as &[u8]), - ]; - - for (label, num, data) in test_cases { - let mut transcript1 = TestTranscript::new(label); - transcript1.append_u64(b"num", num); - transcript1.append_bytes(b"data", data); - let challenge1: Fr = transcript1.challenge_scalar(); - - let mut transcript2 = TestTranscript::new(label); - transcript2.append_u64(b"num", num); - transcript2.append_bytes(b"data", data); - let challenge2: Fr = transcript2.challenge_scalar(); - - assert_eq!( - challenge1, - challenge2, - "Determinism failed for label={:?}", - std::str::from_utf8(label).unwrap() - ); - } - } - - #[test] - fn test_append_order_sensitivity() { - // Verify that append order matters (critical for Fiat-Shamir security) - let scalar_a = Fr::from(111u64); - let scalar_b = Fr::from(222u64); - - // Append A then B - let mut t1 = TestTranscript::new(b"order_test"); - t1.append_scalar(b"s", &scalar_a); - t1.append_scalar(b"s", &scalar_b); - - // Append B then A - let mut t2 = TestTranscript::new(b"order_test"); - t2.append_scalar(b"s", &scalar_b); - t2.append_scalar(b"s", &scalar_a); - - assert_ne!( - t1.state, t2.state, - "Append order should affect transcript state" - ); - - // Also test with different append types - let mut t3 = TestTranscript::new(b"mixed_order_test"); - t3.append_u64(b"num", 123); - t3.append_bytes(b"data", b"test"); - - let mut t4 = TestTranscript::new(b"mixed_order_test"); - t4.append_bytes(b"data", b"test"); - t4.append_u64(b"num", 123); - - assert_ne!( - t3.state, t4.state, - "Append order should matter across different types" - ); - } - - #[test] - fn test_label_sensitivity() { - // Verify that different labels produce different states - let scalar = Fr::from(12345u64); - - let mut t1 = TestTranscript::new(b"label_test"); - t1.append_scalar(b"label1", &scalar); - - let mut t2 = TestTranscript::new(b"label_test"); - t2.append_scalar(b"label2", &scalar); - - assert_ne!( - t1.state, t2.state, - "Different labels should produce different states" - ); - - // Test initialization label sensitivity - let t3 = TestTranscript::new(b"init_label_1"); - let t4 = TestTranscript::new(b"init_label_2"); - - assert_ne!( - t3.state, t4.state, - "Different initialization labels should produce different states" - ); - } -} diff --git a/jolt-core/src/transcripts/transcript.rs b/jolt-core/src/transcripts/transcript.rs deleted file mode 100644 index 0f9756468a..0000000000 --- a/jolt-core/src/transcripts/transcript.rs +++ /dev/null @@ -1,147 +0,0 @@ -use crate::curve::JoltGroupElement; -use crate::field::JoltField; -use ark_serialize::CanonicalSerialize; -use std::borrow::Borrow; - -/// Maximum label length when packed with a length/count. -/// 32 bytes total - 8 bytes for u64 length = 24 bytes for label. -const MAX_LABEL_LEN_WITH_LENGTH: usize = 24; - -pub trait Transcript: Default + Clone + Sync + Send + 'static { - fn new(label: &'static [u8]) -> Self; - #[cfg(test)] - fn compare_to(&mut self, other: Self); - - // === Internal methods (implementors provide these) === - // These preserve EVM-compatible serialization logic - - #[doc(hidden)] - fn raw_append_label(&mut self, label: &'static [u8]); - - /// Pack label (right-padded, 24 bytes) and length (big-endian, 8 bytes) into 32 bytes. - /// Used only for length/count prefixes in variable-length methods. - #[doc(hidden)] - fn raw_append_label_with_len(&mut self, label: &'static [u8], len: u64) { - assert!( - label.len() <= MAX_LABEL_LEN_WITH_LENGTH, - "Label too long for packed format: {} > {}", - label.len(), - MAX_LABEL_LEN_WITH_LENGTH - ); - let mut packed = [0u8; 32]; - packed[..label.len()].copy_from_slice(label); - // Zero-pad label portion (already zeroed) - // Append length as big-endian in last 8 bytes - packed[24..32].copy_from_slice(&len.to_be_bytes()); - self.raw_append_bytes(&packed); - } - - #[doc(hidden)] - fn raw_append_bytes(&mut self, bytes: &[u8]); - - #[doc(hidden)] - fn raw_append_u64(&mut self, x: u64); - - #[doc(hidden)] - fn raw_append_scalar(&mut self, scalar: &F); - - // === Public API - Labels required === - - /// Append a domain-separation label with no associated data. - fn append_label(&mut self, label: &'static [u8]) { - self.raw_append_label(label); - } - - /// Append raw bytes with a label. - /// Variable-length: label and length packed into single 32-byte word. - fn append_bytes(&mut self, label: &'static [u8], bytes: &[u8]) { - self.raw_append_label_with_len(label, bytes.len() as u64); - self.raw_append_bytes(bytes); - } - - /// Append a u64 value with a label. - /// Two separate 32-byte words: label (right-padded) + value (left-padded for EVM uint256). - fn append_u64(&mut self, label: &'static [u8], x: u64) { - self.raw_append_label(label); - self.raw_append_u64(x); - } - - /// Append a scalar field element with a label. - /// Fixed-size: no length prefix needed. - fn append_scalar(&mut self, label: &'static [u8], scalar: &F) { - self.raw_append_label(label); - self.raw_append_scalar(scalar); - } - - /// Append a curve point with a label (compressed serialization). - /// Fixed-size: no length prefix needed. - fn append_commitment(&mut self, label: &'static [u8], point: &G) { - self.raw_append_label(label); - let mut bytes = Vec::new(); - point - .serialize_compressed(&mut bytes) - .expect("JoltGroupElement serialization should not fail"); - self.raw_append_bytes(&bytes); - } - - /// Append a serializable value with a label. - /// Variable-length: label and length packed into single 32-byte word. - fn append_serializable(&mut self, label: &'static [u8], data: &T) { - let mut buf = vec![]; - data.serialize_uncompressed(&mut buf).unwrap(); - self.raw_append_label_with_len(label, buf.len() as u64); - // Reverse for EVM big-endian compatibility - buf.reverse(); - self.raw_append_bytes(&buf); - } - - /// Append a slice of scalars with a label. - /// Variable-length: label and count packed into single 32-byte word. - fn append_scalars(&mut self, label: &'static [u8], scalars: &[impl Borrow]) { - self.raw_append_label_with_len(label, scalars.len() as u64); - for s in scalars { - self.raw_append_scalar(s.borrow()); - } - } - - /// Append a slice of curve points with a label (compressed serialization). - /// Variable-length: label and count packed into single 32-byte word. - fn append_commitments(&mut self, label: &'static [u8], points: &[G]) { - self.raw_append_label_with_len(label, points.len() as u64); - for p in points { - let mut bytes = Vec::new(); - p.serialize_compressed(&mut bytes) - .expect("JoltGroupElement serialization should not fail"); - self.raw_append_bytes(&bytes); - } - } - - /// Append a slice of `CanonicalSerialize` points with a label (compressed serialization). - /// Same layout as `append_commitments` but works with any serializable type (e.g. arkworks affine points). - fn append_commitments_serializable( - &mut self, - label: &'static [u8], - points: &[T], - ) { - self.raw_append_label_with_len(label, points.len() as u64); - for p in points { - let mut bytes = Vec::new(); - p.serialize_compressed(&mut bytes) - .expect("Point serialization should not fail"); - self.raw_append_bytes(&bytes); - } - } - - // === Challenge generation methods (signatures unchanged) === - - fn challenge_u128(&mut self) -> u128; - fn challenge_scalar(&mut self) -> F; - fn challenge_scalar_128_bits(&mut self) -> F; - fn challenge_vector(&mut self, len: usize) -> Vec; - /// Compute powers of scalar q : (1, q, q^2, ..., q^(len-1)) - fn challenge_scalar_powers(&mut self, len: usize) -> Vec; - /// Optimized method that returns F::Challenge - fn challenge_scalar_optimized(&mut self) -> F::Challenge; - fn challenge_vector_optimized(&mut self, len: usize) -> Vec; - fn challenge_scalar_powers_optimized(&mut self, len: usize) -> Vec; -} diff --git a/jolt-core/src/utils/errors.rs b/jolt-core/src/utils/errors.rs index 30a01d90c7..4b9b29d9f0 100644 --- a/jolt-core/src/utils/errors.rs +++ b/jolt-core/src/utils/errors.rs @@ -44,6 +44,8 @@ pub enum ProofVerifyError { SerializationError, #[error("ZK proof received but `zk` feature is not enabled")] ZkFeatureRequired, + #[error("Proof zk_mode flag does not match this verifier build's `zk` feature")] + ZkModeMismatch, #[error("BlindFold verification failed: {0}")] BlindFoldError(String), #[error("Bytecode type mismatch: {0}")] diff --git a/jolt-core/src/zkvm/bytecode/read_raf_checking.rs b/jolt-core/src/zkvm/bytecode/read_raf_checking.rs index 8b104d05d5..d817ee05b9 100644 --- a/jolt-core/src/zkvm/bytecode/read_raf_checking.rs +++ b/jolt-core/src/zkvm/bytecode/read_raf_checking.rs @@ -35,7 +35,7 @@ use crate::{ sumcheck_prover::SumcheckInstanceProver, sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}, }, - transcripts::Transcript, + transcript_msgs::FsChallenge, utils::{math::Math, small_scalar::SmallScalar, thread::unsafe_allocate_zero_vec}, zkvm::{ bytecode::BytecodePreprocessing, @@ -73,8 +73,8 @@ const N_STAGES: usize = 5; /// of degree `d + 1` (cubic only when `d = 2`). /// /// Challenge notation: -/// - γ: the stage-folding scalar with powers `params.gamma_powers = transcript.challenge_scalar_powers(8)` (7 stage-folding terms plus `γ_entry` at index 7 for the entry-point constraint). -/// - β_s: per-stage scalars used *within* Val_s encodings (`stage{s}_gammas = transcript.challenge_scalar_powers(...)`), +/// - γ: the stage-folding scalar with powers `params.gamma_powers = transcript.challenge_powers(8)` (7 stage-folding terms plus `γ_entry` at index 7 for the entry-point constraint). +/// - β_s: per-stage scalars used *within* Val_s encodings (`stage{s}_gammas = transcript.challenge_powers(...)`), /// sampled separately for each stage. /// /// Mathematical claim: @@ -334,9 +334,7 @@ impl BytecodeReadRafAddressSumcheckProver { } } -impl SumcheckInstanceProver - for BytecodeReadRafAddressSumcheckProver -{ +impl SumcheckInstanceProver for BytecodeReadRafAddressSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -633,9 +631,7 @@ impl BytecodeReadRafCycleSumcheckProver { } } -impl SumcheckInstanceProver - for BytecodeReadRafCycleSumcheckProver -{ +impl SumcheckInstanceProver for BytecodeReadRafCycleSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -658,7 +654,7 @@ impl SumcheckInstanceProver } fn compute_message(&mut self, _round: usize, _previous_claim: F) -> UniPoly { - let degree = >::degree(self); + let degree = >::degree(self); let out_len = self.gruen_eq_polys[0].E_out_current().len(); let in_len = self.gruen_eq_polys[0].E_in_current().len(); @@ -822,11 +818,10 @@ impl BytecodeReadRafAddressSumcheckVerifier { n_cycle_vars: usize, one_hot_params: &OneHotParams, opening_accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { - let params = BytecodeReadRafSumcheckParams::gen( + let params = BytecodeReadRafSumcheckParams::gen_verifier( program, - None, n_cycle_vars, one_hot_params, opening_accumulator, @@ -842,8 +837,8 @@ impl BytecodeReadRafAddressSumcheckVerifier { } } -impl> - SumcheckInstanceVerifier for BytecodeReadRafAddressSumcheckVerifier +impl> SumcheckInstanceVerifier + for BytecodeReadRafAddressSumcheckVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params @@ -911,8 +906,8 @@ impl BytecodeReadRafCycleSumcheckVerifier { } } -impl> - SumcheckInstanceVerifier for BytecodeReadRafCycleSumcheckVerifier +impl> SumcheckInstanceVerifier + for BytecodeReadRafCycleSumcheckVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params @@ -950,16 +945,21 @@ impl> .1 }); + // Full mode: evaluate all five stage Vals in one regrouped pass sharing a + // single eq(r,·) table (spec §5.2); Committed mode reads virtual openings. + let stage_vals: Option<[F; N_STAGES]> = (self.params.program_mode + != ProgramMode::Committed) + .then(|| self.params.stage_val_evals(&r_address_prime.r)); let stage_val_claim = |stage: usize| { - if self.params.program_mode == ProgramMode::Committed { + if let Some(vals) = &stage_vals { + vals[stage] + } else { accumulator .get_virtual_polynomial_opening( VirtualPolynomial::BytecodeValStage(stage), SumcheckId::BytecodeReadRafAddressPhase, ) .1 - } else { - self.params.val_polys[stage].evaluate(&r_address_prime.r) } }; let int_poly_contrib_by_stage = [ @@ -1170,16 +1170,17 @@ impl SumcheckInstanceParams for BytecodeReadRafCyclePhaseParams return challenge_values; } - // Prover stores bound values before clearing polys; verifier evaluates directly. + // Prover stores bound values before clearing polys; verifier evaluates directly + // (regrouped per spec §5.2 — value-identical). let stage_values: [F; N_STAGES] = if let Some(bound_val_polys) = &self.bound_val_polys { array::from_fn(|index| { bound_val_polys[index] * EqPolynomial::::mle(&self.r_cycles[index], &r_cycle_prime.r) }) } else { + let vals = self.stage_val_evals(&r_address_prime.r); array::from_fn(|index| { - self.val_polys[index].evaluate(&r_address_prime.r) - * EqPolynomial::::mle(&self.r_cycles[index], &r_cycle_prime.r) + vals[index] * EqPolynomial::::mle(&self.r_cycles[index], &r_cycle_prime.r) }) }; let int_poly = self @@ -1562,6 +1563,29 @@ impl SumcheckInstanceParams for BytecodeReadRafAddressPhasePara } } +/// Verifier-side inputs for the regrouped stage-Val evaluation +/// (specs/transpiler-optimization-spec.md §5.2). +/// +/// The verifier never materializes the γ-combined `val_polys` (whose per-row +/// coefficients are challenge-dependent). Instead it keeps the raw bytecode table +/// (protocol constants) plus the two `eq(r_register, ·)` tables, and evaluates +/// `Val_s(r) = Σ_j β_s^j·(Σ_k c_{s,j,k}·eq(r,k))` by linearity — exact field algebra, +/// value-identical to `val_polys[s].evaluate(r)` on every path. In the transpiled +/// circuit the inner Σ_k products are const×var (free) since every c is a bytecode +/// constant; the challenge-dependent register columns of stages 4/5 are salvaged by a +/// second-level regroup over register values (register operands ARE bytecode +/// constants), leaving only the per-β and per-touched-register products as real muls. +#[derive(Allocative, Clone)] +pub struct VerifierValEvalData { + /// Full bytecode table; rows are protocol constants. + #[allocative(skip)] + bytecode: Arc, + /// eq(r_register, ·) over the stage-4 `RdWa` opening (RegistersReadWriteChecking). + eq_r_register_4: Vec, + /// eq(r_register, ·) over the stage-5 `RdWa` opening (RegistersValEvaluation). + eq_r_register_5: Vec, +} + #[derive(Allocative, Clone)] pub struct BytecodeReadRafSumcheckParams { pub program_mode: ProgramMode, @@ -1606,6 +1630,9 @@ pub struct BytecodeReadRafSumcheckParams { pub cycle_initial_round_claims: Option<[F; N_STAGES]>, /// Prover-cached entry cycle claim after address binding. pub cycle_initial_entry_claim: Option, + /// Verifier-side data for the regrouped `Val_s(r_address)` evaluation (Full + /// program mode only; `None` for the prover and in Committed mode). + pub verifier_val_data: Option>, } impl BytecodeReadRafSumcheckParams { @@ -1619,6 +1646,8 @@ impl BytecodeReadRafSumcheckParams { ] } + /// Prover-side params: materializes the combined `val_polys`, which the address + /// phase binds round-by-round. #[tracing::instrument(skip_all, name = "BytecodeReadRafSumcheckParams::gen")] pub fn gen( program: &ProgramPreprocessing, @@ -1626,21 +1655,64 @@ impl BytecodeReadRafSumcheckParams { n_cycle_vars: usize, one_hot_params: &OneHotParams, opening_accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, + ) -> Self { + Self::gen_impl( + program, + materialized_program, + n_cycle_vars, + one_hot_params, + opening_accumulator, + transcript, + true, + ) + } + + /// Verifier-side params: identical Fiat-Shamir interaction to [`Self::gen`], but + /// the combined `val_polys` are NOT materialized — the verifier only ever needs + /// `Val_s(r_address)`, which `stage_val_evals` computes by the §5.2 linearity + /// regroup from [`VerifierValEvalData`] (value-identical, and free of the + /// challenge-dependent per-row coefficient products in the transpiled circuit). + fn gen_verifier( + program: &ProgramPreprocessing, + n_cycle_vars: usize, + one_hot_params: &OneHotParams, + opening_accumulator: &dyn OpeningAccumulator, + transcript: &mut impl FsChallenge, + ) -> Self { + Self::gen_impl( + program, + None, + n_cycle_vars, + one_hot_params, + opening_accumulator, + transcript, + false, + ) + } + + fn gen_impl( + program: &ProgramPreprocessing, + materialized_program: Option<&FullProgramPreprocessing>, + n_cycle_vars: usize, + one_hot_params: &OneHotParams, + opening_accumulator: &dyn OpeningAccumulator, + transcript: &mut impl FsChallenge, + materialize_val_polys: bool, ) -> Self { let program_mode = if program.is_committed() { ProgramMode::Committed } else { ProgramMode::Full }; - let gamma_powers = transcript.challenge_scalar_powers(8); + let gamma_powers = transcript.challenge_powers(8); // Generate all stage-specific gamma powers upfront (order must match verifier) - let stage1_gammas: Vec = transcript.challenge_scalar_powers(2 + NUM_CIRCUIT_FLAGS); - let stage2_gammas: Vec = transcript.challenge_scalar_powers(4); - let stage3_gammas: Vec = transcript.challenge_scalar_powers(9); - let stage4_gammas: Vec = transcript.challenge_scalar_powers(3); - let stage5_gammas: Vec = transcript.challenge_scalar_powers(2 + NUM_LOOKUP_TABLES); + let stage1_gammas: Vec = transcript.challenge_powers(2 + NUM_CIRCUIT_FLAGS); + let stage2_gammas: Vec = transcript.challenge_powers(4); + let stage3_gammas: Vec = transcript.challenge_powers(9); + let stage4_gammas: Vec = transcript.challenge_powers(3); + let stage5_gammas: Vec = transcript.challenge_powers(2 + NUM_LOOKUP_TABLES); // Compute rv_claims (these don't iterate bytecode, just query opening accumulator) let rv_claim_1 = Self::compute_rv_claim_1(opening_accumulator, &stage1_gammas); @@ -1661,6 +1733,7 @@ impl BytecodeReadRafSumcheckParams { }, ProgramMode::Committed => materialized_program, }; + let mut verifier_val_data = None; let val_polys = if let Some(program) = program_source { let r_register_4 = opening_accumulator .get_virtual_polynomial_opening( @@ -1682,16 +1755,25 @@ impl BytecodeReadRafSumcheckParams { let eq_r_register_5 = EqPolynomial::::evals(&r_register_5[..(REGISTER_COUNT as usize).log_2()]); - Self::compute_val_polys( - &program.bytecode.bytecode, - &eq_r_register_4, - &eq_r_register_5, - &stage1_gammas, - &stage2_gammas, - &stage3_gammas, - &stage4_gammas, - &stage5_gammas, - ) + if materialize_val_polys { + Self::compute_val_polys( + &program.bytecode.bytecode, + &eq_r_register_4, + &eq_r_register_5, + &stage1_gammas, + &stage2_gammas, + &stage3_gammas, + &stage4_gammas, + &stage5_gammas, + ) + } else { + verifier_val_data = Some(VerifierValEvalData { + bytecode: Arc::clone(&program.bytecode), + eq_r_register_4, + eq_r_register_5, + }); + array::from_fn(|_| MultilinearPolynomial::from(vec![F::zero()])) + } } else { array::from_fn(|_| MultilinearPolynomial::from(vec![F::zero()])) }; @@ -1777,7 +1859,160 @@ impl BytecodeReadRafSumcheckParams { bound_f_entry: None, cycle_initial_round_claims: None, cycle_initial_entry_claim: None, + verifier_val_data, + } + } + + /// `[Val_s(r_address)]` for all five stages (Full program mode). + /// + /// Verifier params evaluate by the §5.2 linearity regroup over + /// [`VerifierValEvalData`]; params without that data (prover-built, e.g. in + /// `#[cfg(test)]` paths) fall back to evaluating the materialized `val_polys`. + /// Both compute the identical field value — exact algebra, no division. + fn stage_val_evals(&self, r_address: &[F::Challenge]) -> [F; N_STAGES] { + if let Some(data) = &self.verifier_val_data { + self.stage_val_evals_regrouped(data, r_address) + } else { + array::from_fn(|stage| self.val_polys[stage].evaluate(r_address)) + } + } + + /// Regrouped evaluation: `Val_s(r) = Σ_j β_s^j·(Σ_k c_{s,j,k}·eq(r,k))`, one + /// shared `eq(r,·)` table across all five stages. The stage-4 columns and the + /// stage-5 rd column have challenge-dependent `eq(r_register, ·)` coefficients, + /// so they get a second-level regroup by register value: + /// `Σ_k eq_reg[reg(k)]·eq(r,k) = Σ_reg eq_reg[reg]·(Σ_{k: reg(k)=reg} eq(r,k))`. + /// Skips are keyed on bytecode constants only (zero/absent operands and unset + /// flags contribute exactly zero), so native and symbolic runs take identical + /// branches and the result is value-identical to `val_polys[s].evaluate(r)`. + fn stage_val_evals_regrouped( + &self, + data: &VerifierValEvalData, + r_address: &[F::Challenge], + ) -> [F; N_STAGES] { + let bytecode = &data.bytecode.bytecode; + let eq_table: Vec = EqPolynomial::evals(r_address); + + let n_registers = REGISTER_COUNT as usize; + let mut s1_addr = F::zero(); + let mut s1_imm = F::zero(); + let mut s1_flags = vec![F::zero(); NUM_CIRCUIT_FLAGS]; + let mut s2 = [F::zero(); 4]; + let mut s3_imm = F::zero(); + let mut s3_addr = F::zero(); + let mut s3_flags = [F::zero(); 7]; + let mut s4_rd = vec![F::zero(); n_registers]; + let mut s4_rs1 = vec![F::zero(); n_registers]; + let mut s4_rs2 = vec![F::zero(); n_registers]; + let mut s5_rd = vec![F::zero(); n_registers]; + let mut s5_raf = F::zero(); + let mut s5_tables = vec![F::zero(); NUM_LOOKUP_TABLES]; + + // zip_eq (not zip): a length mismatch between the padded bytecode and the + // eq table must panic in release builds too, not silently truncate the sum. + for (instruction, &eq_k) in zip_eq(bytecode.iter(), eq_table.iter()) { + let instr = *instruction; + let circuit_flags = instruction.circuit_flags(); + let instr_flags = instruction.instruction_flags(); + + // Stage 1: unexpanded_pc + β·imm + Σ β^{2+t}·circuit_flag_t + if instr.address != 0 { + s1_addr += eq_k.mul_u64(instr.address as u64); + } + if instr.operands.imm != 0 { + s1_imm += instr.operands.imm.field_mul(eq_k); + } + for (sum, flag) in s1_flags.iter_mut().zip(circuit_flags.iter()) { + if *flag { + *sum += eq_k; + } + } + + // Stage 2: jump + β·branch + β²·write_lookup_to_rd + β³·virtual + if circuit_flags[CircuitFlags::Jump] { + s2[0] += eq_k; + } + if instr_flags[InstructionFlags::Branch] { + s2[1] += eq_k; + } + if circuit_flags[CircuitFlags::WriteLookupOutputToRD] { + s2[2] += eq_k; + } + if circuit_flags[CircuitFlags::VirtualInstruction] { + s2[3] += eq_k; + } + + // Stage 3: imm + β·unexpanded_pc + β²..β⁸ over seven flags + if instr.operands.imm != 0 { + s3_imm += instr.operands.imm.field_mul(eq_k); + } + if instr.address != 0 { + s3_addr += eq_k.mul_u64(instr.address as u64); + } + if instr_flags[InstructionFlags::LeftOperandIsRs1Value] { + s3_flags[0] += eq_k; + } + if instr_flags[InstructionFlags::LeftOperandIsPC] { + s3_flags[1] += eq_k; + } + if instr_flags[InstructionFlags::RightOperandIsRs2Value] { + s3_flags[2] += eq_k; + } + if instr_flags[InstructionFlags::RightOperandIsImm] { + s3_flags[3] += eq_k; + } + if instr_flags[InstructionFlags::IsNoop] { + s3_flags[4] += eq_k; + } + if circuit_flags[CircuitFlags::VirtualInstruction] { + s3_flags[5] += eq_k; + } + if circuit_flags[CircuitFlags::IsFirstInSequence] { + s3_flags[6] += eq_k; + } + + // Stage 4: register-grouped rd/rs1/rs2 indicator sums + if let Some(r) = instr.operands.rd { + s4_rd[r as usize] += eq_k; + } + if let Some(r) = instr.operands.rs1 { + s4_rs1[r as usize] += eq_k; + } + if let Some(r) = instr.operands.rs2 { + s4_rs2[r as usize] += eq_k; + } + + // Stage 5: register-grouped rd + β·raf_flag + Σ β^{2+i}·table_flag_i + if let Some(r) = instr.operands.rd { + s5_rd[r as usize] += eq_k; + } + if !circuit_flags.is_interleaved_operands() { + s5_raf += eq_k; + } + if let Some(table) = InstructionLookup::::lookup_table(instruction) { + s5_tables[LookupTables::::enum_index(&table)] += eq_k; + } } + + let dot = |weights: &[F], sums: &[F]| -> F { + zip_eq(weights.iter(), sums.iter()) + .map(|(w, s)| *w * *s) + .sum::() + }; + + let val1 = + s1_addr + self.stage1_gammas[1] * s1_imm + dot(&self.stage1_gammas[2..], &s1_flags); + let val2 = dot(&self.stage2_gammas, &s2); + let val3 = + s3_imm + self.stage3_gammas[1] * s3_addr + dot(&self.stage3_gammas[2..], &s3_flags); + let val4 = self.stage4_gammas[0] * dot(&data.eq_r_register_4, &s4_rd) + + self.stage4_gammas[1] * dot(&data.eq_r_register_4, &s4_rs1) + + self.stage4_gammas[2] * dot(&data.eq_r_register_4, &s4_rs2); + let val5 = dot(&data.eq_r_register_5, &s5_rd) + + self.stage5_gammas[1] * s5_raf + + dot(&self.stage5_gammas[2..], &s5_tables); + + [val1, val2, val3, val4, val5] } /// Fused computation of all Val polynomials in a single parallel pass over bytecode. @@ -2120,3 +2355,186 @@ impl BytecodeReadRafSumcheckParams { sum } } + +#[cfg(test)] +#[expect(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::zkvm::config::OneHotConfig; + use ark_bn254::Fr; + use ark_std::test_rng; + use jolt_riscv::{JoltInstructionKind, NormalizedOperands, RV64IMAC_JOLT}; + use rand_core::RngCore; + + fn row( + kind: JoltInstructionKind, + address: usize, + operands: NormalizedOperands, + virtual_sequence_remaining: Option, + is_first_in_sequence: bool, + ) -> JoltInstructionRow { + JoltInstructionRow { + instruction_kind: kind, + address, + operands, + virtual_sequence_remaining, + is_first_in_sequence, + is_compressed: false, + } + } + + fn ops(rd: Option, rs1: Option, rs2: Option, imm: i128) -> NormalizedOperands { + NormalizedOperands { rs1, rs2, rd, imm } + } + + fn rand_frs(n: usize, rng: &mut impl RngCore) -> Vec { + (0..n).map(|_| ::random(rng)).collect() + } + + fn rand_challenges(n: usize, rng: &mut impl RngCore) -> Vec<::Challenge> { + (0..n) + .map(|_| ::Challenge::random(rng)) + .collect() + } + + /// Property gate for the §5.2 linearity regroup: for random per-stage gammas, + /// random `eq(r_register, ·)` tables, and a random `r_address`, the verifier's + /// `stage_val_evals_regrouped` must equal the materialized + /// `compute_val_polys(..)[s].evaluate(r_address)` for every stage — including + /// the register-grouped stage-4 rd/rs1/rs2 and stage-5 rd columns. + #[test] + fn stage_val_evals_regrouped_matches_materialized_val_polys() { + let mut rng = test_rng(); + + // Small synthetic bytecode exercising every column: address/imm (stages + // 1, 3), jump/branch/write-to-rd flags (stage 2), operand-source flags + // (stage 3), rd/rs1/rs2 register columns (stage 4), rd + raf + + // lookup-table columns (stage 5), a two-row inline sequence + // (VirtualInstruction / IsFirstInSequence), and the noop padding rows + // `preprocess` inserts (IsNoop, absent operands). + let base = 0x8000_0000usize; + let rows = vec![ + row( + JoltInstructionKind::ADDI, + base, + ops(Some(5), Some(6), None, 42), + None, + false, + ), + row( + JoltInstructionKind::MUL, + base + 4, + ops(Some(7), Some(8), Some(9), 0), + None, + false, + ), + row( + JoltInstructionKind::BEQ, + base + 8, + ops(None, Some(10), Some(11), -8), + None, + false, + ), + row( + JoltInstructionKind::JAL, + base + 12, + ops(Some(1), None, None, 16), + None, + false, + ), + row( + JoltInstructionKind::SLTU, + base + 16, + ops(Some(12), Some(13), Some(14), 0), + None, + false, + ), + row( + JoltInstructionKind::XOR, + base + 20, + ops(Some(15), Some(16), Some(17), 0), + Some(1), + true, + ), + row( + JoltInstructionKind::ADDI, + base + 20, + ops(Some(15), Some(15), None, 1), + Some(0), + false, + ), + ]; + let bytecode = + Arc::new(BytecodePreprocessing::preprocess(rows, base as u64, RV64IMAC_JOLT).unwrap()); + let K = bytecode.bytecode.len(); + let log_K = K.log_2(); + + let stage1_gammas = rand_frs(2 + NUM_CIRCUIT_FLAGS, &mut rng); + let stage2_gammas = rand_frs(4, &mut rng); + let stage3_gammas = rand_frs(9, &mut rng); + let stage4_gammas = rand_frs(3, &mut rng); + let stage5_gammas = rand_frs(2 + NUM_LOOKUP_TABLES, &mut rng); + + let log_registers = (REGISTER_COUNT as usize).log_2(); + let eq_r_register_4 = EqPolynomial::::evals(&rand_challenges(log_registers, &mut rng)); + let eq_r_register_5 = EqPolynomial::::evals(&rand_challenges(log_registers, &mut rng)); + + let val_polys = BytecodeReadRafSumcheckParams::::compute_val_polys( + &bytecode.bytecode, + &eq_r_register_4, + &eq_r_register_5, + &stage1_gammas, + &stage2_gammas, + &stage3_gammas, + &stage4_gammas, + &stage5_gammas, + ); + + let r_address = rand_challenges(log_K, &mut rng); + + let params = BytecodeReadRafSumcheckParams:: { + program_mode: ProgramMode::Full, + gamma_powers: vec![Fr::zero(); 8], + stage1_gammas, + stage2_gammas, + stage3_gammas, + stage4_gammas, + stage5_gammas, + input_claim: Fr::zero(), + one_hot_params: OneHotParams::from_config(&OneHotConfig::new(16), K, 1 << 16), + K, + log_K, + log_T: 16, + d: 1, + val_polys, + rv_claims: [Fr::zero(); N_STAGES], + raf_claim: Fr::zero(), + raf_shift_claim: Fr::zero(), + int_poly: IdentityPolynomial::new(log_K), + r_cycles: array::from_fn(|_| Vec::new()), + bound_val_polys: None, + bound_int_poly: None, + entry_gamma: Fr::zero(), + entry_bytecode_index: 0, + bound_f_entry: None, + cycle_initial_round_claims: None, + cycle_initial_entry_claim: None, + verifier_val_data: Some(VerifierValEvalData { + bytecode: Arc::clone(&bytecode), + eq_r_register_4, + eq_r_register_5, + }), + }; + + let regrouped = params + .stage_val_evals_regrouped(params.verifier_val_data.as_ref().unwrap(), &r_address); + for (stage, regrouped_eval) in regrouped.iter().enumerate() { + assert_eq!( + *regrouped_eval, + params.val_polys[stage].evaluate(&r_address), + "stage {} regrouped Val(r_address) diverges from the materialized val poly", + stage + 1, + ); + } + } +} diff --git a/jolt-core/src/zkvm/claim_reductions/advice.rs b/jolt-core/src/zkvm/claim_reductions/advice.rs index b577c9d4ec..b139b77572 100644 --- a/jolt-core/src/zkvm/claim_reductions/advice.rs +++ b/jolt-core/src/zkvm/claim_reductions/advice.rs @@ -15,7 +15,6 @@ use crate::poly::unipoly::UniPoly; use crate::subprotocols::blindfold::{InputClaimConstraint, OutputClaimConstraint, ValueSource}; use crate::subprotocols::sumcheck_prover::SumcheckInstanceProver; use crate::subprotocols::sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}; -use crate::transcripts::Transcript; use crate::zkvm::claim_reductions::{ permute_precommitted_polys, precommitted_eq_evals_with_scaling, precommitted_skip_round_scale, PrecommittedClaimReduction, PrecommittedPhase, PrecommittedSchedulingReference, @@ -310,7 +309,7 @@ impl AdviceClaimReductionProver { } } -impl SumcheckInstanceProver for AdviceClaimReductionProver { +impl SumcheckInstanceProver for AdviceClaimReductionProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { self.core.params() } @@ -394,8 +393,8 @@ impl AdviceClaimReductionVerifier { } } -impl> - SumcheckInstanceVerifier for AdviceClaimReductionVerifier +impl> SumcheckInstanceVerifier + for AdviceClaimReductionVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params diff --git a/jolt-core/src/zkvm/claim_reductions/bytecode.rs b/jolt-core/src/zkvm/claim_reductions/bytecode.rs index 7c0c292f7f..716bbd7f44 100644 --- a/jolt-core/src/zkvm/claim_reductions/bytecode.rs +++ b/jolt-core/src/zkvm/claim_reductions/bytecode.rs @@ -18,7 +18,7 @@ use crate::poly::unipoly::UniPoly; use crate::subprotocols::blindfold::{InputClaimConstraint, OutputClaimConstraint, ValueSource}; use crate::subprotocols::sumcheck_prover::SumcheckInstanceProver; use crate::subprotocols::sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}; -use crate::transcripts::Transcript; +use crate::transcript_msgs::FsChallenge; use crate::utils::math::Math; use crate::zkvm::bytecode::chunks::{committed_lanes, total_lanes, BYTECODE_LANE_LAYOUT}; use crate::zkvm::claim_reductions::{ @@ -57,7 +57,7 @@ impl BytecodeClaimReductionParams { bytecode_chunk_count: usize, scheduling_reference: PrecommittedSchedulingReference, accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { assert!( bytecode_len.is_multiple_of(bytecode_chunk_count), @@ -66,7 +66,7 @@ impl BytecodeClaimReductionParams { let log_bytecode_chunk_size = (bytecode_len / bytecode_chunk_count).log_2(); let log_bytecode_len = bytecode_len.log_2(); - let eta: F = transcript.challenge_scalar(); + let eta: F = transcript.challenge_field(); let mut eta_powers = [F::one(); NUM_VAL_STAGES]; for i in 1..NUM_VAL_STAGES { eta_powers[i] = eta_powers[i - 1] * eta; @@ -337,7 +337,7 @@ impl BytecodeClaimReductionProver { } } -impl SumcheckInstanceProver for BytecodeClaimReductionProver { +impl SumcheckInstanceProver for BytecodeClaimReductionProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { self.core.params() } @@ -411,8 +411,8 @@ impl BytecodeClaimReductionVerifier { } } -impl> - SumcheckInstanceVerifier for BytecodeClaimReductionVerifier +impl> SumcheckInstanceVerifier + for BytecodeClaimReductionVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params diff --git a/jolt-core/src/zkvm/claim_reductions/hamming_weight.rs b/jolt-core/src/zkvm/claim_reductions/hamming_weight.rs index f3dfad3d5a..41378e71d9 100644 --- a/jolt-core/src/zkvm/claim_reductions/hamming_weight.rs +++ b/jolt-core/src/zkvm/claim_reductions/hamming_weight.rs @@ -110,7 +110,7 @@ use crate::subprotocols::blindfold::{ #[cfg(feature = "prover")] use crate::subprotocols::sumcheck_prover::SumcheckInstanceProver; use crate::subprotocols::sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}; -use crate::transcripts::Transcript; +use crate::transcript_msgs::FsChallenge; #[cfg(feature = "prover")] use crate::zkvm::prover::JoltProverPreprocessing; use crate::zkvm::{ @@ -165,7 +165,7 @@ impl HammingWeightClaimReductionParams { pub fn new( one_hot_params: &OneHotParams, accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { let instruction_d = one_hot_params.instruction_d; let bytecode_d = one_hot_params.bytecode_d; @@ -185,14 +185,8 @@ impl HammingWeightClaimReductionParams { polynomial_types.push(CommittedPolynomial::RamRa(i)); } - // Sample batching challenge γ and compute powers (3 claims per ra_i) - let gamma: F = transcript.challenge_scalar(); - let mut gamma_powers = Vec::with_capacity(3 * N); - let mut power = F::one(); - for _ in 0..(3 * N) { - gamma_powers.push(power); - power *= gamma; - } + // Sample batching challenge γ and compute powers [1, γ, …, γ^(3N-1)] (3 claims per ra_i) + let gamma_powers = transcript.challenge_powers(3 * N); // Fetch r_addr_bool and r_cycle from Booleanity opening point. // The claims from Booleanity are at (ρ_addr, ρ_cycle) where both are sumcheck challenges. @@ -483,9 +477,7 @@ impl HammingWeightClaimReductionProver { } #[cfg(feature = "prover")] -impl SumcheckInstanceProver - for HammingWeightClaimReductionProver -{ +impl SumcheckInstanceProver for HammingWeightClaimReductionProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -594,7 +586,7 @@ impl HammingWeightClaimReductionVerifier { pub fn new( one_hot_params: &OneHotParams, accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { let params = HammingWeightClaimReductionParams::new(one_hot_params, accumulator, transcript); @@ -602,8 +594,8 @@ impl HammingWeightClaimReductionVerifier { } } -impl> - SumcheckInstanceVerifier for HammingWeightClaimReductionVerifier +impl> SumcheckInstanceVerifier + for HammingWeightClaimReductionVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params diff --git a/jolt-core/src/zkvm/claim_reductions/increments.rs b/jolt-core/src/zkvm/claim_reductions/increments.rs index 033591cde7..d0420166c7 100644 --- a/jolt-core/src/zkvm/claim_reductions/increments.rs +++ b/jolt-core/src/zkvm/claim_reductions/increments.rs @@ -67,7 +67,7 @@ use crate::poly::unipoly::UniPoly; use crate::subprotocols::blindfold::{InputClaimConstraint, OutputClaimConstraint}; use crate::subprotocols::sumcheck_prover::SumcheckInstanceProver; use crate::subprotocols::sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}; -use crate::transcripts::Transcript; +use crate::transcript_msgs::FsChallenge; use crate::utils::accumulation::MedAccumS; use crate::utils::math::{s64_from_diff_u64s, Math}; use crate::utils::thread::unsafe_allocate_zero_vec; @@ -90,9 +90,9 @@ impl IncClaimReductionSumcheckParams { pub fn new( trace_len: usize, accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { - let gamma: F = transcript.challenge_scalar(); + let gamma: F = transcript.challenge_field(); let gamma_sqr = gamma.square(); let gamma_cub = gamma_sqr * gamma; @@ -237,9 +237,7 @@ impl IncClaimReductionSumcheckProver { } } -impl SumcheckInstanceProver - for IncClaimReductionSumcheckProver -{ +impl SumcheckInstanceProver for IncClaimReductionSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -285,7 +283,7 @@ impl SumcheckInstanceProver panic!("Should finish sumcheck on phase 2"); }; - let opening_point = SumcheckInstanceProver::::get_params(self) + let opening_point = SumcheckInstanceProver::::get_params(self) .normalize_opening_point(sumcheck_challenges); let ram_inc_claim = state.ram_inc.final_sumcheck_claim(); @@ -679,15 +677,15 @@ impl IncClaimReductionSumcheckVerifier { pub fn new>( trace_len: usize, accumulator: &A, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { let params = IncClaimReductionSumcheckParams::new(trace_len, accumulator, transcript); Self { params } } } -impl> - SumcheckInstanceVerifier for IncClaimReductionSumcheckVerifier +impl> SumcheckInstanceVerifier + for IncClaimReductionSumcheckVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params @@ -696,7 +694,7 @@ impl> fn expected_output_claim(&self, accumulator: &A, sumcheck_challenges: &[F::Challenge]) -> F { let [gamma, gamma_sqr, _] = self.params.gamma_powers; - let opening_point = SumcheckInstanceVerifier::::get_params(self) + let opening_point = SumcheckInstanceVerifier::::get_params(self) .normalize_opening_point(sumcheck_challenges); // Compute eq evaluations at final point @@ -722,7 +720,7 @@ impl> } fn cache_openings(&self, accumulator: &mut A, sumcheck_challenges: &[F::Challenge]) { - let opening_point = SumcheckInstanceVerifier::::get_params(self) + let opening_point = SumcheckInstanceVerifier::::get_params(self) .normalize_opening_point(sumcheck_challenges); accumulator.append_dense( diff --git a/jolt-core/src/zkvm/claim_reductions/instruction_lookups.rs b/jolt-core/src/zkvm/claim_reductions/instruction_lookups.rs index edca310f4c..0045a5e6d5 100644 --- a/jolt-core/src/zkvm/claim_reductions/instruction_lookups.rs +++ b/jolt-core/src/zkvm/claim_reductions/instruction_lookups.rs @@ -20,7 +20,7 @@ use crate::poly::unipoly::UniPoly; use crate::subprotocols::blindfold::{InputClaimConstraint, OutputClaimConstraint}; use crate::subprotocols::sumcheck_prover::SumcheckInstanceProver; use crate::subprotocols::sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}; -use crate::transcripts::Transcript; +use crate::transcript_msgs::FsChallenge; use crate::utils::math::Math; use crate::utils::thread::unsafe_allocate_zero_vec; use crate::zkvm::instruction::LookupQuery; @@ -45,9 +45,9 @@ impl InstructionLookupsClaimReductionSumcheckParams { pub fn new( trace_len: usize, accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { - let gamma = transcript.challenge_scalar::(); + let gamma = transcript.challenge_field(); let gamma_sqr = gamma.square(); let gamma_cub = gamma_sqr * gamma; let gamma_quart = gamma_sqr.square(); @@ -208,9 +208,7 @@ impl InstructionLookupsClaimReductionSumcheckProver { } } -impl SumcheckInstanceProver - for InstructionLookupsClaimReductionSumcheckProver -{ +impl SumcheckInstanceProver for InstructionLookupsClaimReductionSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -264,7 +262,7 @@ impl SumcheckInstanceProver panic!("Should finish sumcheck on phase 2"); }; - let opening_point = SumcheckInstanceProver::::get_params(self) + let opening_point = SumcheckInstanceProver::::get_params(self) .normalize_opening_point(sumcheck_challenges); let lookup_output_claim = state.lookup_output_poly.final_sumcheck_claim(); @@ -618,7 +616,7 @@ impl InstructionLookupsClaimReductionSumcheckVerifier { pub fn new>( trace_len: usize, accumulator: &A, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { let params = InstructionLookupsClaimReductionSumcheckParams::new(trace_len, accumulator, transcript); @@ -626,15 +624,15 @@ impl InstructionLookupsClaimReductionSumcheckVerifier { } } -impl> - SumcheckInstanceVerifier for InstructionLookupsClaimReductionSumcheckVerifier +impl> SumcheckInstanceVerifier + for InstructionLookupsClaimReductionSumcheckVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } fn expected_output_claim(&self, accumulator: &A, sumcheck_challenges: &[F::Challenge]) -> F { - let opening_point = SumcheckInstanceVerifier::::get_params(self) + let opening_point = SumcheckInstanceVerifier::::get_params(self) .normalize_opening_point(sumcheck_challenges); let (r_spartan, _) = accumulator.get_virtual_polynomial_opening( @@ -672,7 +670,7 @@ impl> } fn cache_openings(&self, accumulator: &mut A, sumcheck_challenges: &[F::Challenge]) { - let opening_point = SumcheckInstanceVerifier::::get_params(self) + let opening_point = SumcheckInstanceVerifier::::get_params(self) .normalize_opening_point(sumcheck_challenges); accumulator.append_virtual( diff --git a/jolt-core/src/zkvm/claim_reductions/program_image.rs b/jolt-core/src/zkvm/claim_reductions/program_image.rs index d5833e3d3d..38a4068983 100644 --- a/jolt-core/src/zkvm/claim_reductions/program_image.rs +++ b/jolt-core/src/zkvm/claim_reductions/program_image.rs @@ -21,7 +21,6 @@ use crate::poly::unipoly::UniPoly; use crate::subprotocols::blindfold::{InputClaimConstraint, OutputClaimConstraint, ValueSource}; use crate::subprotocols::sumcheck_prover::SumcheckInstanceProver; use crate::subprotocols::sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}; -use crate::transcripts::Transcript; use crate::utils::math::Math; use crate::zkvm::claim_reductions::{ permute_precommitted_polys, precommitted_skip_round_scale, PrecommittedClaimReduction, @@ -338,9 +337,7 @@ impl ProgramImageClaimReductionProver { } } -impl SumcheckInstanceProver - for ProgramImageClaimReductionProver -{ +impl SumcheckInstanceProver for ProgramImageClaimReductionProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { self.core.params() } @@ -399,8 +396,8 @@ impl ProgramImageClaimReductionVerifier { } } -impl> - SumcheckInstanceVerifier for ProgramImageClaimReductionVerifier +impl> SumcheckInstanceVerifier + for ProgramImageClaimReductionVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params diff --git a/jolt-core/src/zkvm/claim_reductions/ram_ra.rs b/jolt-core/src/zkvm/claim_reductions/ram_ra.rs index ef5f630414..34b90f5d6f 100644 --- a/jolt-core/src/zkvm/claim_reductions/ram_ra.rs +++ b/jolt-core/src/zkvm/claim_reductions/ram_ra.rs @@ -64,7 +64,7 @@ use crate::{ sumcheck_prover::SumcheckInstanceProver, sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}, }, - transcripts::Transcript, + transcript_msgs::FsChallenge, utils::{math::Math, thread::unsafe_allocate_zero_vec}, zkvm::{config::OneHotParams, ram::remap_address, witness::VirtualPolynomial}, }; @@ -110,9 +110,7 @@ impl RamRaClaimReductionSumcheckProver { } } -impl SumcheckInstanceProver - for RamRaClaimReductionSumcheckProver -{ +impl SumcheckInstanceProver for RamRaClaimReductionSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -608,7 +606,7 @@ impl RaReductionParams { trace_len: usize, one_hot_params: &OneHotParams, opening_accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { let log_K = one_hot_params.ram_k.log_2(); let log_T = trace_len.log_2(); @@ -633,7 +631,7 @@ impl RaReductionParams { debug_assert_eq!(r_address_raf, r_address_val); // Sample γ for combining claims - let gamma: F = transcript.challenge_scalar(); + let gamma: F = transcript.challenge_field(); let gamma_squared = gamma * gamma; Self { @@ -724,7 +722,7 @@ impl RamRaClaimReductionSumcheckVerifier { trace_len: usize, one_hot_params: &OneHotParams, opening_accumulator: &A, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { let params = RaReductionParams::new(trace_len, one_hot_params, opening_accumulator, transcript); @@ -732,8 +730,8 @@ impl RamRaClaimReductionSumcheckVerifier { } } -impl> - SumcheckInstanceVerifier for RamRaClaimReductionSumcheckVerifier +impl> SumcheckInstanceVerifier + for RamRaClaimReductionSumcheckVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params diff --git a/jolt-core/src/zkvm/claim_reductions/registers.rs b/jolt-core/src/zkvm/claim_reductions/registers.rs index 1e17e23ad3..153e0253a6 100644 --- a/jolt-core/src/zkvm/claim_reductions/registers.rs +++ b/jolt-core/src/zkvm/claim_reductions/registers.rs @@ -21,7 +21,7 @@ use crate::subprotocols::blindfold::{ }; use crate::subprotocols::sumcheck_prover::SumcheckInstanceProver; use crate::subprotocols::sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}; -use crate::transcripts::Transcript; +use crate::transcript_msgs::FsChallenge; use crate::utils::math::Math; use crate::utils::thread::unsafe_allocate_zero_vec; use crate::zkvm::witness::VirtualPolynomial; @@ -43,9 +43,9 @@ impl RegistersClaimReductionSumcheckParams { pub fn new( trace_len: usize, accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { - let gamma = transcript.challenge_scalar::(); + let gamma = transcript.challenge_field(); let gamma_sqr = gamma.square(); let (r_spartan, _) = accumulator.get_virtual_polynomial_opening( VirtualPolynomial::LookupOutput, @@ -170,9 +170,7 @@ impl RegistersClaimReductionSumcheckProver { } } -impl SumcheckInstanceProver - for RegistersClaimReductionSumcheckProver -{ +impl SumcheckInstanceProver for RegistersClaimReductionSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -224,7 +222,7 @@ impl SumcheckInstanceProver panic!("Should finish sumcheck on phase 2"); }; - let opening_point = SumcheckInstanceProver::::get_params(self) + let opening_point = SumcheckInstanceProver::::get_params(self) .normalize_opening_point(sumcheck_challenges); let rd_write_value_claim = state.rd_write_value_poly.final_sumcheck_claim(); @@ -483,22 +481,22 @@ impl RegistersClaimReductionSumcheckVerifier { pub fn new>( trace_len: usize, accumulator: &A, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { let params = RegistersClaimReductionSumcheckParams::new(trace_len, accumulator, transcript); Self { params } } } -impl> - SumcheckInstanceVerifier for RegistersClaimReductionSumcheckVerifier +impl> SumcheckInstanceVerifier + for RegistersClaimReductionSumcheckVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } fn expected_output_claim(&self, accumulator: &A, sumcheck_challenges: &[F::Challenge]) -> F { - let opening_point = SumcheckInstanceVerifier::::get_params(self) + let opening_point = SumcheckInstanceVerifier::::get_params(self) .normalize_opening_point(sumcheck_challenges); let (r_spartan, _) = accumulator.get_virtual_polynomial_opening( @@ -526,7 +524,7 @@ impl> } fn cache_openings(&self, accumulator: &mut A, sumcheck_challenges: &[F::Challenge]) { - let opening_point = SumcheckInstanceVerifier::::get_params(self) + let opening_point = SumcheckInstanceVerifier::::get_params(self) .normalize_opening_point(sumcheck_challenges); accumulator.append_virtual( diff --git a/jolt-core/src/zkvm/instruction_lookups/ra_virtual.rs b/jolt-core/src/zkvm/instruction_lookups/ra_virtual.rs index 0328f42af2..ca9c1a5436 100644 --- a/jolt-core/src/zkvm/instruction_lookups/ra_virtual.rs +++ b/jolt-core/src/zkvm/instruction_lookups/ra_virtual.rs @@ -28,7 +28,7 @@ use crate::{ sumcheck_prover::SumcheckInstanceProver, sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}, }, - transcripts::Transcript, + transcript_msgs::FsChallenge, zkvm::{ config::OneHotParams, instruction::LookupQuery, @@ -58,7 +58,7 @@ impl InstructionRaSumcheckParams { pub fn new( one_hot_params: &OneHotParams, opening_accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { // Extract the full r_address from the virtual ra_i openings. let mut r_address = Vec::new(); @@ -86,7 +86,7 @@ impl InstructionRaSumcheckParams { ); let (_, r_cycle) = r.split_at(ra_virtual_log_k_chunk); - let gamma_powers = transcript.challenge_scalar_powers(n_virtual_ra_polys); + let gamma_powers = transcript.challenge_powers(n_virtual_ra_polys); Self { r_cycle, one_hot_params: one_hot_params.clone(), @@ -247,7 +247,7 @@ impl InstructionRaSumcheckProver { } } -impl SumcheckInstanceProver for InstructionRaSumcheckProver { +impl SumcheckInstanceProver for InstructionRaSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -355,7 +355,7 @@ impl RaSumcheckVerifier { pub fn new>( one_hot_params: &OneHotParams, opening_accumulator: &A, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { let params = InstructionRaSumcheckParams::new(one_hot_params, opening_accumulator, transcript); @@ -363,8 +363,8 @@ impl RaSumcheckVerifier { } } -impl> - SumcheckInstanceVerifier for RaSumcheckVerifier +impl> SumcheckInstanceVerifier + for RaSumcheckVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params diff --git a/jolt-core/src/zkvm/instruction_lookups/read_raf_checking.rs b/jolt-core/src/zkvm/instruction_lookups/read_raf_checking.rs index 987fba2242..a162490da1 100644 --- a/jolt-core/src/zkvm/instruction_lookups/read_raf_checking.rs +++ b/jolt-core/src/zkvm/instruction_lookups/read_raf_checking.rs @@ -34,7 +34,7 @@ use crate::{ sumcheck_prover::SumcheckInstanceProver, sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}, }, - transcripts::Transcript, + transcript_msgs::FsChallenge, utils::{ expanding_table::ExpandingTable, lookup_bits::LookupBits, @@ -125,9 +125,9 @@ impl InstructionReadRafSumcheckParams { n_cycle_vars: usize, one_hot_params: &OneHotParams, opening_accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { - let gamma = transcript.challenge_scalar::(); + let gamma = transcript.challenge_field(); let gamma_sqr = gamma.square(); let phases = config::get_instruction_sumcheck_phases(n_cycle_vars); let (r_reduction, _) = opening_accumulator.get_virtual_polynomial_opening( @@ -841,9 +841,7 @@ impl InstructionReadRafSumcheckProver { } } -impl SumcheckInstanceProver - for InstructionReadRafSumcheckProver -{ +impl SumcheckInstanceProver for InstructionReadRafSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -1304,7 +1302,7 @@ impl InstructionReadRafSumcheckVerifier { n_cycle_vars: usize, one_hot_params: &OneHotParams, opening_accumulator: &A, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { let params = InstructionReadRafSumcheckParams::new( n_cycle_vars, @@ -1316,8 +1314,8 @@ impl InstructionReadRafSumcheckVerifier { } } -impl> - SumcheckInstanceVerifier for InstructionReadRafSumcheckVerifier +impl> SumcheckInstanceVerifier + for InstructionReadRafSumcheckVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params @@ -1433,9 +1431,10 @@ mod tests { use super::*; use crate::poly::opening_proof::VerifierOpeningAccumulator; use crate::subprotocols::sumcheck::BatchedSumcheck; - use crate::transcripts::Blake2bTranscript; + use crate::transcript_msgs::FsChallenge; use ark_bn254::Fr; use ark_std::Zero; + use jolt_transcript::Blake2b512; use rand::{rngs::StdRng, RngCore, SeedableRng}; use strum::IntoEnumIterator; use tracer::instruction::Cycle; @@ -1524,16 +1523,14 @@ mod tests { .collect(), ); - let prover_transcript = &mut Blake2bTranscript::new(&[]); + let prover_transcript = + &mut jolt_transcript::prover_transcript(&[], [0u8; 32], Blake2b512::default()); let mut prover_opening_accumulator = ProverOpeningAccumulator::new(trace.len().log_2()); - let verifier_transcript = &mut Blake2bTranscript::new(&[]); let mut verifier_opening_accumulator = VerifierOpeningAccumulator::new(trace.len().log_2(), false); let r_cycle: Vec<::Challenge> = - prover_transcript.challenge_vector_optimized::(LOG_T); - let _r_cycle: Vec<::Challenge> = - verifier_transcript.challenge_vector_optimized::(LOG_T); + FsChallenge::::challenge_optimized_vec(prover_transcript, LOG_T); let eq_r_cycle = EqPolynomial::::evals(&r_cycle); let mut rv_claim = Fr::zero(); @@ -1592,12 +1589,20 @@ mod tests { let mut prover_sumcheck = InstructionReadRafSumcheckProver::initialize(params, Arc::clone(&trace)); - let (proof, r_sumcheck, _initial_claim) = BatchedSumcheck::prove( + let (r_sumcheck, _initial_claim) = BatchedSumcheck::prove( vec![&mut prover_sumcheck], &mut prover_opening_accumulator, prover_transcript, ); + // The verifier replays the prover's NARG: build its transcript over the + // prover-produced byte-string and re-derive the same `r_cycle` in order. + let narg = prover_transcript.narg_string().to_vec(); + let mut verifier_transcript = + jolt_transcript::verifier_transcript(&[], [0u8; 32], Blake2b512::default(), &narg); + let _r_cycle: Vec<::Challenge> = + FsChallenge::::challenge_optimized_vec(&mut verifier_transcript, LOG_T); + // Take claims for (key, (_, value)) in &prover_opening_accumulator.openings { let empty_point = OpeningPoint::::new(vec![]); @@ -1631,18 +1636,24 @@ mod tests { trace.len().log_2(), &one_hot_params, &verifier_opening_accumulator, - verifier_transcript, + &mut verifier_transcript, ); let r_sumcheck_verif = BatchedSumcheck::verify_standard( - &proof, vec![&verifier_sumcheck], &mut verifier_opening_accumulator, - verifier_transcript, + &mut verifier_transcript, ) .unwrap(); assert_eq!(r_sumcheck, r_sumcheck_verif); + + // Malleability guard: the verifier must have consumed the prover's entire + // NARG. Without this, a prover that wrote extra/trailing round-poly frames + // would still pass (verify reads its expected frames in order) — this catches + // a write/read count desync that the challenge-equality assert cannot. + jolt_transcript::VerifierTranscript::::check_eof(verifier_transcript) + .expect("verifier must fully consume the NARG (no trailing prover messages)"); } #[test] diff --git a/jolt-core/src/zkvm/mod.rs b/jolt-core/src/zkvm/mod.rs index 19ea553d4a..825890f156 100644 --- a/jolt-core/src/zkvm/mod.rs +++ b/jolt-core/src/zkvm/mod.rs @@ -11,13 +11,12 @@ use crate::{ OpeningAccumulator, OpeningId, OpeningPoint, ProverOpeningAccumulator, SumcheckId, BIG_ENDIAN, }, - transcripts::Transcript, utils::errors::ProofVerifyError, zkvm::claim_reductions::AdviceKind, }; // Compile-time error if multiple transcript features are enabled -// When none of the transcript features are enabled, Jolt defaults to `Blake2bTranscript` +// When none of the transcript features are enabled, Jolt defaults to the Blake2b512 sponge #[cfg(any( all(feature = "transcript-poseidon", feature = "transcript-keccak"), all(feature = "transcript-poseidon", feature = "transcript-blake2b"), @@ -30,18 +29,21 @@ use crate::{ ))] compile_error!("Cannot enable multiple transcript features simultaneously. Please choose exactly one of: 'transcript-poseidon', 'transcript-keccak', or 'transcript-blake2b'."); +/// The spongefish sponge RV64IMAC's prover/verifier/proof are instantiated over +/// (the phantom `H` of the proof). Cfg-selected, defaulting to Blake2b. #[cfg(any( feature = "transcript-blake2b", not(any(feature = "transcript-poseidon", feature = "transcript-keccak")) ))] -use crate::transcripts::Blake2bTranscript; +pub type RV64IMACSponge = jolt_transcript::Blake2b512; #[cfg(feature = "transcript-keccak")] -use crate::transcripts::KeccakTranscript; +pub type RV64IMACSponge = jolt_transcript::Keccak; #[cfg(feature = "transcript-poseidon")] -use crate::transcripts::PoseidonTranscript; +pub type RV64IMACSponge = jolt_transcript::PoseidonSponge; use ark_bn254::Fr; use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; use eyre::Result; +use jolt_transcript::DuplexSpongeInterface; use proof_serialization::JoltProof; #[cfg(feature = "prover")] use prover::JoltCpuProver; @@ -66,6 +68,11 @@ pub mod registers; pub mod spartan; #[cfg(all(test, feature = "host"))] mod trace_row_parity; +/// Symbolic verifier used to transpile the on-chain verifier (gated behind the +/// `transpiler` feature): a generic mirror of `JoltVerifier` over the accumulator +/// and the spongefish `VerifierFs` surface, non-ZK stages 1–7 only. See +/// `specs/on-chain-solidity-verifier-plan.md` Part II. +#[cfg(feature = "transpiler")] pub mod transpilable_verifier; pub mod verifier; pub mod witness; @@ -298,20 +305,40 @@ macro_rules! pprof_scope { } #[allow(dead_code)] -pub struct ProverDebugInfo +pub struct ProverDebugInfo where F: JoltField, - ProofTranscript: Transcript, + H: DuplexSpongeInterface, PCS: CommitmentScheme, { - pub(crate) transcript: ProofTranscript, pub(crate) opening_accumulator: ProverOpeningAccumulator, pub(crate) prover_setup: PCS::ProverSetup, + /// Per-ZK-stage, per-round sumcheck polynomial degrees captured by the prover for the + /// `blindfold_r1cs_satisfaction` test (the round polys now live in the NARG, not the + /// proof struct). Only populated in test builds under the `zk` feature. + #[cfg(all(test, feature = "zk"))] + pub(crate) zk_round_degrees: Vec>, + pub(crate) _marker: std::marker::PhantomData H>, } -/// Absorb public instance data into the transcript for Fiat-Shamir. -#[allow(clippy::too_many_arguments)] -pub fn fiat_shamir_preamble( +/// The 32-byte Fiat-Shamir `instance` binding the public statement +/// (`Blake2b(CanonicalSerialize(statement))`, per @mmaker's #1455 mandate). +/// +/// The transcript is constructed with this digest as its spongefish `instance`, +/// which replaced the legacy per-param `fiat_shamir_preamble` scatter-absorbs +/// (deleted — see DEV-38): the statement is bound once at the domain-separator +/// boundary instead of being scattered across absorbs. +/// +/// **O1 (soundness-critical):** the digest must be byte-identical on prover and +/// verifier. Both sides call this one helper over the same statement params (the +/// verifier recomputes them from the proof's public tail), serialized in a fixed +/// order. +#[expect(clippy::too_many_arguments)] +#[expect( + clippy::expect_used, + reason = "CanonicalSerialize into a Vec is infallible" +)] +pub fn fiat_shamir_instance( program_io: &JoltDevice, ram_K: usize, trace_length: usize, @@ -320,92 +347,51 @@ pub fn fiat_shamir_preamble( one_hot_config: &OneHotConfig, dory_layout: DoryLayout, preprocessing_digest: &[u8; 32], - transcript: &mut impl Transcript, -) { - transcript.append_bytes(b"preprocessing_digest", preprocessing_digest); - transcript.append_u64(b"max_input_size", program_io.memory_layout.max_input_size); - transcript.append_u64(b"max_output_size", program_io.memory_layout.max_output_size); - transcript.append_u64(b"heap_size", program_io.memory_layout.heap_size); - transcript.append_bytes(b"inputs", &program_io.inputs); - transcript.append_bytes(b"outputs", &program_io.outputs); - transcript.append_u64(b"panic", program_io.panic as u64); - transcript.append_u64(b"ram_K", ram_K as u64); - transcript.append_u64(b"trace_length", trace_length as u64); - transcript.append_u64(b"entry_address", entry_address); - transcript.append_u64( - b"ram_rw_phase1_num_rounds", - rw_config.ram_rw_phase1_num_rounds as u64, - ); - transcript.append_u64( - b"ram_rw_phase2_num_rounds", - rw_config.ram_rw_phase2_num_rounds as u64, - ); - transcript.append_u64( - b"registers_rw_phase1_num_rounds", - rw_config.registers_rw_phase1_num_rounds as u64, - ); - transcript.append_u64( - b"registers_rw_phase2_num_rounds", - rw_config.registers_rw_phase2_num_rounds as u64, - ); - transcript.append_u64(b"log_k_chunk", one_hot_config.log_k_chunk as u64); - transcript.append_u64( - b"lookups_ra_virtual_log_k_chunk", - one_hot_config.lookups_ra_virtual_log_k_chunk as u64, - ); - transcript.append_u64(b"dory_layout", dory_layout as u64); -} +) -> [u8; 32] { + use ark_serialize::CanonicalSerialize; + use blake2::digest::consts::U32; + use blake2::{Blake2b, Digest}; -#[cfg(all(feature = "prover", feature = "transcript-poseidon"))] -pub type RV64IMACProver<'a> = - JoltCpuProver<'a, Fr, Bn254Curve, DoryCommitmentScheme, PoseidonTranscript>; -#[cfg(feature = "transcript-poseidon")] -pub type RV64IMACVerifier<'a> = - JoltVerifier<'a, Fr, Bn254Curve, DoryCommitmentScheme, PoseidonTranscript>; -#[cfg(feature = "transcript-poseidon")] -pub type RV64IMACProof = JoltProof; + fn push(buf: &mut Vec, value: &T) { + value + .serialize_compressed(buf) + .expect("CanonicalSerialize into a Vec is infallible"); + } -#[cfg(all(feature = "prover", feature = "transcript-keccak"))] -pub type RV64IMACProver<'a> = - JoltCpuProver<'a, Fr, Bn254Curve, DoryCommitmentScheme, KeccakTranscript>; -#[cfg(feature = "transcript-keccak")] -pub type RV64IMACVerifier<'a> = - JoltVerifier<'a, Fr, Bn254Curve, DoryCommitmentScheme, KeccakTranscript>; -#[cfg(feature = "transcript-keccak")] -pub type RV64IMACProof = JoltProof; + let mut buf = Vec::new(); + push(&mut buf, &preprocessing_digest.to_vec()); + push(&mut buf, &program_io.memory_layout.max_input_size); + push(&mut buf, &program_io.memory_layout.max_output_size); + push(&mut buf, &program_io.memory_layout.heap_size); + push(&mut buf, &program_io.inputs); + push(&mut buf, &program_io.outputs); + push(&mut buf, &(program_io.panic as u64)); + push(&mut buf, &(ram_K as u64)); + push(&mut buf, &(trace_length as u64)); + push(&mut buf, &entry_address); + push(&mut buf, &(rw_config.ram_rw_phase1_num_rounds as u64)); + push(&mut buf, &(rw_config.ram_rw_phase2_num_rounds as u64)); + push(&mut buf, &(rw_config.registers_rw_phase1_num_rounds as u64)); + push(&mut buf, &(rw_config.registers_rw_phase2_num_rounds as u64)); + push(&mut buf, &(one_hot_config.log_k_chunk as u64)); + push( + &mut buf, + &(one_hot_config.lookups_ra_virtual_log_k_chunk as u64), + ); + push(&mut buf, &(dory_layout as u64)); -#[cfg(all( - feature = "prover", - not(any( - feature = "transcript-poseidon", - feature = "transcript-keccak", - feature = "transcript-blake2b" - )) -))] -pub type RV64IMACProver<'a> = - JoltCpuProver<'a, Fr, Bn254Curve, DoryCommitmentScheme, Blake2bTranscript>; -#[cfg(not(any( - feature = "transcript-poseidon", - feature = "transcript-keccak", - feature = "transcript-blake2b" -)))] -pub type RV64IMACVerifier<'a> = - JoltVerifier<'a, Fr, Bn254Curve, DoryCommitmentScheme, Blake2bTranscript>; -#[cfg(not(any( - feature = "transcript-poseidon", - feature = "transcript-keccak", - feature = "transcript-blake2b" -)))] -pub type RV64IMACProof = JoltProof; + Blake2b::::digest(&buf).into() +} -#[cfg(all(feature = "prover", feature = "transcript-blake2b"))] +// The per-sponge variance lives entirely in `RV64IMACSponge` (cfg-gated above), so +// these aliases are sponge-agnostic. `RV64IMACProver` needs the `prover` feature; the +// verifier/proof are available in any build. +#[cfg(feature = "prover")] pub type RV64IMACProver<'a> = - JoltCpuProver<'a, Fr, Bn254Curve, DoryCommitmentScheme, Blake2bTranscript>; -#[cfg(feature = "transcript-blake2b")] + JoltCpuProver<'a, Fr, Bn254Curve, DoryCommitmentScheme, RV64IMACSponge>; pub type RV64IMACVerifier<'a> = - JoltVerifier<'a, Fr, Bn254Curve, DoryCommitmentScheme, Blake2bTranscript>; -#[cfg(feature = "transcript-blake2b")] -pub type RV64IMACProof = JoltProof; + JoltVerifier<'a, Fr, Bn254Curve, DoryCommitmentScheme, RV64IMACSponge>; +pub type RV64IMACProof = JoltProof; pub trait Serializable: CanonicalSerialize + CanonicalDeserialize + Sized { /// Gets the byte size of the serialized data @@ -424,8 +410,11 @@ pub trait Serializable: CanonicalSerialize + CanonicalDeserialize + Sized { /// Reads data from a file fn from_file>(path: P) -> Result { - let file = File::open(path.into())?; - Ok(Self::deserialize_compressed(file)?) + // Route through `deserialize_from_bytes` so the trailing-byte (malleability) guard + // applies uniformly. A streaming `deserialize_compressed(file)` cannot detect + // trailing garbage after the struct. + let bytes = std::fs::read(path.into())?; + Self::deserialize_from_bytes(&bytes) } /// Serializes the data to a byte vector @@ -437,8 +426,16 @@ pub trait Serializable: CanonicalSerialize + CanonicalDeserialize + Sized { /// Deserializes data from a byte vector fn deserialize_from_bytes(bytes: &[u8]) -> Result { - let cursor = Cursor::new(bytes); - Ok(Self::deserialize_compressed(cursor)?) + let mut cursor = Cursor::new(bytes); + let value = Self::deserialize_compressed(&mut cursor)?; + // MAL-1 (malleability guard): reject trailing bytes. `CanonicalDeserialize` stops after + // the struct without consuming or checking the remainder, so without this check + // `valid_proof || garbage` would deserialize to the same proof. The inner `check_eof` + // (verifier success path) only seals the NARG byte-string, not this outer envelope. + if (cursor.position() as usize) != bytes.len() { + eyre::bail!("trailing bytes after serialized proof (malleability guard)"); + } + Ok(value) } } diff --git a/jolt-core/src/zkvm/proof_serialization.rs b/jolt-core/src/zkvm/proof_serialization.rs index f98c00b7c4..f19c316930 100644 --- a/jolt-core/src/zkvm/proof_serialization.rs +++ b/jolt-core/src/zkvm/proof_serialization.rs @@ -1,6 +1,9 @@ #[cfg(not(feature = "zk"))] use std::collections::BTreeMap; use std::io::{Read, Write}; +use std::marker::PhantomData; + +use jolt_transcript::DuplexSpongeInterface; use ark_serialize::{ CanonicalDeserialize, CanonicalSerialize, Compress, SerializationError, Valid, Validate, @@ -10,8 +13,11 @@ use strum::EnumCount; #[cfg(not(feature = "zk"))] use crate::poly::opening_proof::{OpeningPoint, Openings}; -#[cfg(feature = "zk")] -use crate::subprotocols::blindfold::BlindFoldProof; +use crate::zkvm::{ + config::{OneHotConfig, ReadWriteConfig}, + instruction::{CircuitFlags, InstructionFlags}, + witness::{CommittedPolynomial, VirtualPolynomial}, +}; use crate::{ curve::JoltCurve, field::JoltField, @@ -19,18 +25,6 @@ use crate::{ commitment::{commitment_scheme::CommitmentScheme, dory::DoryLayout}, opening_proof::{OpeningId, PolynomialId, SumcheckId}, }, - utils::errors::ProofVerifyError, -}; -use crate::{ - subprotocols::{ - sumcheck::SumcheckInstanceProof, univariate_skip::UniSkipFirstRoundProofVariant, - }, - transcripts::Transcript, - zkvm::{ - config::{OneHotConfig, ReadWriteConfig}, - instruction::{CircuitFlags, InstructionFlags}, - witness::{CommittedPolynomial, VirtualPolynomial}, - }, }; #[derive(CanonicalSerialize, CanonicalDeserialize)] @@ -38,55 +32,38 @@ pub struct JoltProof< F: JoltField, C: JoltCurve, PCS: CommitmentScheme, - FS: Transcript, + H: DuplexSpongeInterface, > { - pub commitments: Vec, - pub stage1_uni_skip_first_round_proof: UniSkipFirstRoundProofVariant, - pub stage1_sumcheck_proof: SumcheckInstanceProof, - pub stage2_uni_skip_first_round_proof: UniSkipFirstRoundProofVariant, - pub stage2_sumcheck_proof: SumcheckInstanceProof, - pub stage3_sumcheck_proof: SumcheckInstanceProof, - pub stage4_sumcheck_proof: SumcheckInstanceProof, - pub stage5_sumcheck_proof: SumcheckInstanceProof, - pub stage6a_sumcheck_proof: SumcheckInstanceProof, - pub stage6b_sumcheck_proof: SumcheckInstanceProof, - pub stage7_sumcheck_proof: SumcheckInstanceProof, - #[cfg(feature = "zk")] - pub blindfold_proof: BlindFoldProof, pub joint_opening_proof: PCS::Proof, - pub untrusted_advice_commitment: Option, #[cfg(not(feature = "zk"))] pub opening_claims: Claims, + /// Spongefish NARG byte-string: the prover-only proof payload the verifier replays via + /// `prover_message`/`read_slice`. It carries: the witness-polynomial commitments frame; + /// the untrusted-advice presence frame (empty or length-1); per-sumcheck/uniskip Clear + /// round polynomials (non-ZK), or in ZK mode the round + output-claim commitments plus + /// `poly_degrees`; and every prover-only BlindFold value (instances, cross-terms, Spartan/ + /// inner round polys, folded eval outputs/blindings, and Hyrax openings). + /// + /// Two things are deliberately NOT in the NARG (MAL-1-sealed hard limits): the Dory + /// `joint_opening_proof` (carried structurally above), and — in non-ZK mode — the + /// `opening_claims` (shared values `absorb`'d on both sides, carried structurally above). + pub narg: Vec, + /// Single global Fiat-Shamir mode flag: `true` iff the proof was produced in ZK mode. + /// All nine per-stage sumcheck/uniskip protocols always share one mode, so this replaces + /// the former nine data-free per-stage mode markers (and `verify_zk_consistency`). The + /// verifier reads it to select, per stage, which NARG frames to consume (Clear round + /// polynomials vs ZK commitments). It does NOT change any NARG read/write order — only + /// the dispatch SELECTOR source moved here from the per-stage markers. + pub zk_mode: bool, pub trace_length: usize, pub ram_K: usize, pub rw_config: ReadWriteConfig, pub one_hot_config: OneHotConfig, pub dory_layout: DoryLayout, -} - -impl, PCS: CommitmentScheme, FS: Transcript> - JoltProof -{ - /// Verifies all sumcheck and uniskip proofs use the same ZK variant. - /// Returns the ZK mode if consistent, or an error if any stage disagrees. - pub fn verify_zk_consistency(&self) -> Result { - let zk_mode = self.stage1_sumcheck_proof.is_zk(); - - let consistent = self.stage1_uni_skip_first_round_proof.is_zk() == zk_mode - && self.stage2_uni_skip_first_round_proof.is_zk() == zk_mode - && self.stage2_sumcheck_proof.is_zk() == zk_mode - && self.stage3_sumcheck_proof.is_zk() == zk_mode - && self.stage4_sumcheck_proof.is_zk() == zk_mode - && self.stage5_sumcheck_proof.is_zk() == zk_mode - && self.stage6a_sumcheck_proof.is_zk() == zk_mode - && self.stage6b_sumcheck_proof.is_zk() == zk_mode - && self.stage7_sumcheck_proof.is_zk() == zk_mode; - - if !consistent { - return Err(ProofVerifyError::SumcheckVerificationError); - } - Ok(zk_mode) - } + /// Compile-time transcript-link: a proof produced under sponge `H` can only be + /// verified under sponge `H`. Serializes to zero bytes; `fn() -> (C, H)` keeps the + /// proof `Send + Sync` without bounding `C`/`H`. + pub _marker: PhantomData (C, H)>, } #[cfg(not(feature = "zk"))] diff --git a/jolt-core/src/zkvm/prover.rs b/jolt-core/src/zkvm/prover.rs index effdd88f98..6f1de01cd8 100644 --- a/jolt-core/src/zkvm/prover.rs +++ b/jolt-core/src/zkvm/prover.rs @@ -2,6 +2,9 @@ use crate::poly::opening_proof::OpeningId; #[cfg(feature = "zk")] use crate::zkvm::stage8_opening_ids; +use jolt_transcript::{prover_transcript, DuplexSpongeInterface, ProverState, TranscriptInit}; +use rand::rngs::StdRng; +use std::marker::PhantomData; #[cfg(not(target_arch = "wasm32"))] use std::time::Instant; use std::{ @@ -54,11 +57,10 @@ use crate::{ BooleanitySumcheckParams, }, streaming_schedule::LinearOnlySchedule, - sumcheck::{BatchedSumcheck, SumcheckInstanceProof}, + sumcheck::BatchedSumcheck, sumcheck_prover::SumcheckInstanceProver, - univariate_skip::UniSkipFirstRoundProofVariant, }, - transcripts::Transcript, + transcript_msgs::{FsAbsorb, FsChallenge, ProverFs}, utils::{math::Math, thread::drop_in_background_thread}, zkvm::{ bytecode::{ @@ -112,7 +114,7 @@ use crate::{ bytecode::read_raf_checking::{ BytecodeReadRafAddressSumcheckProver, BytecodeReadRafCycleSumcheckProver, }, - compute_final_opening_point, fiat_shamir_preamble, + compute_final_opening_point, fiat_shamir_instance, instruction_lookups::{ ra_virtual::InstructionRaSumcheckProver as LookupsRaSumcheckProver, read_raf_checking::InstructionReadRafSumcheckProver, @@ -157,9 +159,9 @@ use crate::poly::commitment::pedersen::PedersenGenerators; use crate::poly::lagrange_poly::LagrangeHelper; #[cfg(feature = "zk")] use crate::subprotocols::blindfold::{ - pedersen_generator_count_for_r1cs, BakedPublicInputs, BlindFoldProof, BlindFoldProver, - BlindFoldWitness, ExtraConstraintWitness, FinalOutputWitness, RelaxedR1CSInstance, - RoundWitness, StageConfig, StageWitness, VerifierR1CSBuilder, + pedersen_generator_count_for_r1cs, BakedPublicInputs, BlindFoldProver, BlindFoldWitness, + ExtraConstraintWitness, FinalOutputWitness, RelaxedR1CSInstance, RoundWitness, StageConfig, + StageWitness, VerifierR1CSBuilder, }; #[cfg(feature = "zk")] use crate::subprotocols::blindfold::{InputClaimConstraint, OutputClaimConstraint, ValueSource}; @@ -181,7 +183,7 @@ pub struct JoltCpuProver< F: JoltField, C: JoltCurve, PCS: StreamingCommitmentScheme, - ProofTranscript: Transcript, + H: DuplexSpongeInterface, > { pub preprocessing: &'a JoltProverPreprocessing, pub program_io: JoltDevice, @@ -200,7 +202,7 @@ pub struct JoltCpuProver< program_image_reduction_prover: Option>, pub unpadded_trace_len: usize, pub padded_trace_len: usize, - pub transcript: ProofTranscript, + pub transcript: ProverState, pub opening_accumulator: ProverOpeningAccumulator, pub spartan_key: UniformSpartanKey, pub initial_ram_state: Vec, @@ -211,6 +213,11 @@ pub struct JoltCpuProver< pub rw_config: ReadWriteConfig, #[cfg(feature = "zk")] blindfold_accumulator: crate::subprotocols::blindfold::BlindFoldAccumulator, + /// Per-ZK-stage, per-round sumcheck polynomial degrees, captured during `prove_blindfold` + /// for the `blindfold_r1cs_satisfaction` test (the round polys themselves now live in the + /// NARG, not the proof struct). Empty outside tests. + #[cfg(all(test, feature = "zk"))] + zk_round_degrees: Vec>, #[cfg(not(feature = "zk"))] _curve: std::marker::PhantomData, } @@ -220,8 +227,10 @@ impl< F: JoltField, C: JoltCurve, PCS: StreamingCommitmentScheme + ZkEvalCommitment, - ProofTranscript: Transcript, - > JoltCpuProver<'a, F, C, PCS, ProofTranscript> + H: TranscriptInit + Default, + > JoltCpuProver<'a, F, C, PCS, H> +where + ProverState: ProverFs, { #[allow(clippy::too_many_arguments)] pub fn gen_from_elf( @@ -366,7 +375,9 @@ impl< ) .next_power_of_two() as usize; - let transcript = ProofTranscript::new(b"Jolt"); + // Placeholder transcript: it is never used before `prove()` rebuilds it with the + // real `instance = Blake2b(statement)` (A.1), once the dory layout is known. + let transcript = prover_transcript(b"Jolt", [0u8; 32], H::default()); let opening_accumulator = ProverOpeningAccumulator::new(trace.len().log_2()); let spartan_key = UniformSpartanKey::new(trace.len()); @@ -418,6 +429,8 @@ impl< pedersen_generators, #[cfg(feature = "zk")] blindfold_accumulator: crate::subprotocols::blindfold::BlindFoldAccumulator::new(), + #[cfg(all(test, feature = "zk"))] + zk_round_degrees: Vec::new(), #[cfg(not(feature = "zk"))] _curve: std::marker::PhantomData, } @@ -425,18 +438,18 @@ impl< #[allow(clippy::type_complexity)] #[tracing::instrument(skip_all)] - pub fn prove( - mut self, - ) -> ( - JoltProof, - Option>, - ) { + pub fn prove(mut self) -> (JoltProof, Option>) { let _pprof_prove = pprof_scope!("prove"); #[cfg(not(target_arch = "wasm32"))] let start = Instant::now(); + // A.1: bind the public statement into the transcript `instance` + // (`Blake2b(statement)`, @mmaker's #1455 mandate) rather than scatter-absorbing it. + // The placeholder transcript built in `gen_from_trace` is unused before this point, + // so rebuilding it here — now that the dory layout is known — is sound. The verifier + // recomputes the identical instance from the proof's public tail (O1). let preprocessing_digest = self.preprocessing.shared.digest(); - fiat_shamir_preamble( + let instance = fiat_shamir_instance( &self.program_io, self.one_hot_params.ram_k, self.trace.len(), @@ -445,16 +458,19 @@ impl< &self.one_hot_params.to_config(), DoryGlobals::get_layout(), &preprocessing_digest, - &mut self.transcript, ); + self.transcript = prover_transcript(b"Jolt", instance, H::default()); tracing::info!( "bytecode size: {}", self.preprocessing.shared.bytecode_size() ); - let (commitments, mut opening_proof_hints) = self.generate_and_commit_witness_polynomials(); - let untrusted_advice_commitment = self.generate_and_commit_untrusted_advice(); + let mut opening_proof_hints = self.generate_and_commit_witness_polynomials(); + // Side effects: commits untrusted advice, stores its polynomial + hint, and + // writes the presence frame to the transcript. The commitment is no longer + // carried in the proof (it travels through the NARG presence frame instead). + self.generate_and_commit_untrusted_advice(); self.generate_and_commit_trusted_advice(); // Add advice hints for batched Stage 8 opening @@ -478,37 +494,28 @@ impl< if let Some(bytecode_commitments) = self.preprocessing.shared.program.bytecode_commitments() { for commitment in &bytecode_commitments.commitments { - self.transcript - .append_serializable(b"bytecode_chunk_commit", commitment); + self.transcript.absorb_commitment(commitment); } } if let Some(program_commitments) = self.preprocessing.shared.program.program_commitments() { - self.transcript.append_serializable( - b"program_image_commitment", - &program_commitments.program_image_commitment, - ); + self.transcript + .absorb_commitment(&program_commitments.program_image_commitment); } - let (stage1_uni_skip_first_round_proof, stage1_sumcheck_proof, r_stage1) = - self.prove_stage1(); - let (stage2_uni_skip_first_round_proof, stage2_sumcheck_proof, r_stage2) = - self.prove_stage2(); - let (stage3_sumcheck_proof, r_stage3) = self.prove_stage3(); - let (stage4_sumcheck_proof, r_stage4) = self.prove_stage4(); - let (stage5_sumcheck_proof, r_stage5) = self.prove_stage5(); - let (stage6a_sumcheck_proof, bytecode_read_raf_params, booleanity_cycle_input) = - self.prove_stage6a(); - let (stage6b_sumcheck_proof, r_stage6) = - self.prove_stage6b(bytecode_read_raf_params, booleanity_cycle_input); - let (stage7_sumcheck_proof, r_stage7) = self.prove_stage7(); - - let _sumcheck_challenges = [ - r_stage1, r_stage2, r_stage3, r_stage4, r_stage5, r_stage6, r_stage7, - ]; + self.prove_stage1(); + self.prove_stage2(); + self.prove_stage3(); + self.prove_stage4(); + self.prove_stage5(); + let (bytecode_read_raf_params, booleanity_cycle_input) = self.prove_stage6a(); + self.prove_stage6b(bytecode_read_raf_params, booleanity_cycle_input); + self.prove_stage7(); let joint_opening_proof = self.prove_stage8(opening_proof_hints); + // BlindFold writes all its prover-only values into the NARG (Option B). The returned + // proof is a data-free marker, so it is dropped rather than stored in `JoltProof`. #[cfg(feature = "zk")] - let blindfold_proof = self.prove_blindfold(&joint_opening_proof); + self.prove_blindfold(&joint_opening_proof); #[cfg(not(feature = "zk"))] let opening_claims = @@ -527,38 +534,33 @@ impl< ); } + // The NARG byte-string is the prover-only proof payload (sumcheck/uniskip round + // polynomials) written via `prover_message` across all stages. + let narg = self.transcript.narg_string().to_vec(); + #[cfg(test)] let debug_info = Some(ProverDebugInfo { - transcript: self.transcript.clone(), opening_accumulator: self.opening_accumulator.clone(), prover_setup: self.preprocessing.generators.clone(), + #[cfg(feature = "zk")] + zk_round_degrees: self.zk_round_degrees.clone(), + _marker: PhantomData, }); #[cfg(not(test))] let debug_info = None; let proof = JoltProof { - commitments, - untrusted_advice_commitment, - stage1_uni_skip_first_round_proof, - stage1_sumcheck_proof, - stage2_uni_skip_first_round_proof, - stage2_sumcheck_proof, - stage3_sumcheck_proof, - stage4_sumcheck_proof, - stage5_sumcheck_proof, - stage6a_sumcheck_proof, - stage6b_sumcheck_proof, - stage7_sumcheck_proof, - #[cfg(feature = "zk")] - blindfold_proof, joint_opening_proof, #[cfg(not(feature = "zk"))] opening_claims, + zk_mode: cfg!(feature = "zk"), trace_length: self.trace.len(), ram_K: self.one_hot_params.ram_k, rw_config: self.rw_config.clone(), one_hot_config: self.one_hot_params.to_config(), dory_layout: DoryGlobals::get_layout(), + narg, + _marker: PhantomData, }; #[cfg(not(target_arch = "wasm32"))] @@ -575,18 +577,17 @@ impl< (proof, debug_info) } + /// Returns `(challenges, initial_batched_claim)`. The per-stage round polynomials / + /// commitments are written into the NARG; the proof's single global `zk_mode` flag tells + /// the verifier which read path to take, so no per-stage marker is returned or stored. fn prove_batched_sumcheck( &mut self, - instances: Vec<&mut dyn SumcheckInstanceProver>, - ) -> ( - SumcheckInstanceProof, - Vec, - F, - ) { + instances: Vec<&mut dyn SumcheckInstanceProver>, + ) -> (Vec, F) { #[cfg(feature = "zk")] { let mut rng = rand::thread_rng(); - BatchedSumcheck::prove_zk::( + BatchedSumcheck::prove_zk::( instances, &mut self.opening_accumulator, &mut self.blindfold_accumulator, @@ -597,23 +598,19 @@ impl< } #[cfg(not(feature = "zk"))] { - let (proof, r, claim) = BatchedSumcheck::prove( + BatchedSumcheck::prove( instances, &mut self.opening_accumulator, &mut self.transcript, - ); - (SumcheckInstanceProof::Clear(proof), r, claim) + ) } } - fn prove_uniskip( - &mut self, - instance: &mut impl SumcheckInstanceProver, - ) -> UniSkipFirstRoundProofVariant { + fn prove_uniskip(&mut self, instance: &mut impl SumcheckInstanceProver) { #[cfg(feature = "zk")] { let mut rng = rand::thread_rng(); - let zk_proof = prove_uniskip_round_zk::( + prove_uniskip_round_zk::( instance, &mut self.opening_accumulator, &mut self.blindfold_accumulator, @@ -621,26 +618,21 @@ impl< &self.pedersen_generators, &mut rng, ); - UniSkipFirstRoundProofVariant::Zk(zk_proof) } #[cfg(not(feature = "zk"))] { - let proof = prove_uniskip_round( + prove_uniskip_round( instance, &mut self.opening_accumulator, &mut self.transcript, ); - UniSkipFirstRoundProofVariant::Standard(proof) } } #[tracing::instrument(skip_all, name = "generate_and_commit_witness_polynomials")] fn generate_and_commit_witness_polynomials( &mut self, - ) -> ( - Vec, - HashMap, - ) { + ) -> HashMap { let main_total_vars = self.main_total_vars(); let trace = Arc::clone(&self.trace); let _guard = DoryGlobals::initialize_main_with_log_embedding( @@ -744,18 +736,22 @@ impl< (commitments, hint_map) }; - // Append commitments to transcript - for commitment in &commitments { - self.transcript - .append_serializable(b"commitment", commitment); - } + // Append commitments to transcript. ONE self-delimiting NARG frame (not N + // separate absorbs) — the verifier reads it back with a single `read_commitments` + // (on the Poseidon path the sponge absorbs per-GT byte-rule groups; the NARG + // bytes are unchanged). + self.transcript.write_commitments(&commitments); - (commitments, hint_map) + hint_map } - fn generate_and_commit_untrusted_advice(&mut self) -> Option { + fn generate_and_commit_untrusted_advice(&mut self) { if self.program_io.untrusted_advice.is_empty() { - return None; + // ALWAYS emit a presence frame (here: empty) so the verifier reconstructs the + // Option deterministically from a single `read_commitments` at a fixed transcript + // position — both the None and Some branches write exactly one frame here. + self.transcript.write_commitments::(&[]); + return; } // Commit untrusted advice in its dedicated Dory context, using a preprocessing-only @@ -778,13 +774,12 @@ impl< DoryGlobals::initialize_context(1, advice_len, DoryContext::UntrustedAdvice, None); let _ctx = DoryGlobals::with_context(DoryContext::UntrustedAdvice); let (commitment, hint) = PCS::commit(&poly, &self.preprocessing.generators); + // Presence frame (length-1) — pairs with the empty frame in the None branch above. self.transcript - .append_serializable(b"untrusted_advice", &commitment); + .write_commitments(std::slice::from_ref(&commitment)); self.advice.untrusted_advice_polynomial = Some(poly); self.advice.untrusted_advice_hint = Some(hint); - - Some(commitment) } fn generate_and_commit_trusted_advice(&mut self) { @@ -804,22 +799,14 @@ impl< let poly = MultilinearPolynomial::from(trusted_advice_vec); self.advice.trusted_advice_polynomial = Some(poly); - self.transcript.append_serializable( - b"trusted_advice", - self.advice.trusted_advice_commitment.as_ref().unwrap(), - ); + self.transcript + .absorb_commitment(self.advice.trusted_advice_commitment.as_ref().unwrap()); } /// Returns (uni_skip_proof, sumcheck_proof, challenges, initial_claim) #[allow(clippy::type_complexity)] #[tracing::instrument(skip_all)] - fn prove_stage1( - &mut self, - ) -> ( - UniSkipFirstRoundProofVariant, - SumcheckInstanceProof, - Vec, - ) { + fn prove_stage1(&mut self) -> Vec { #[cfg(not(target_arch = "wasm32"))] print_current_memory_usage("Stage 1 baseline"); @@ -830,7 +817,7 @@ impl< &self.trace, &self.preprocessing.materialized_program().bytecode, ); - let first_round_proof = self.prove_uniskip(&mut uni_skip); + self.prove_uniskip(&mut uni_skip); let schedule = LinearOnlySchedule::new(uni_skip_params.tau.len() - 1); let shared = OuterSharedState::new( @@ -842,23 +829,15 @@ impl< let mut spartan_outer_remaining: OuterRemainingStreamingSumcheck<_, _> = OuterRemainingStreamingSumcheck::new(shared, schedule); - let (sumcheck_proof, r_stage1, _initial_claim) = self.prove_batched_sumcheck(vec![ - &mut spartan_outer_remaining as &mut dyn SumcheckInstanceProver<_, _>, + let (r_stage1, _initial_claim) = self.prove_batched_sumcheck(vec![ + &mut spartan_outer_remaining as &mut dyn SumcheckInstanceProver<_>, ]); - (first_round_proof, sumcheck_proof, r_stage1) + r_stage1 } - /// Returns (uni_skip_proof, sumcheck_proof, challenges) - #[allow(clippy::type_complexity)] #[tracing::instrument(skip_all)] - fn prove_stage2( - &mut self, - ) -> ( - UniSkipFirstRoundProofVariant, - SumcheckInstanceProof, - Vec, - ) { + fn prove_stage2(&mut self) -> Vec { #[cfg(not(target_arch = "wasm32"))] print_current_memory_usage("Stage 2 baseline"); @@ -866,7 +845,7 @@ impl< ProductVirtualUniSkipParams::new(&self.opening_accumulator, &mut self.transcript); let mut uni_skip = ProductVirtualUniSkipProver::initialize(uni_skip_params.clone(), &self.trace); - let first_round_proof = self.prove_uniskip(&mut uni_skip); + self.prove_uniskip(&mut uni_skip); let ram_read_write_checking_params = RamReadWriteCheckingParams::new( &self.opening_accumulator, @@ -943,7 +922,7 @@ impl< print_data_structure_heap_usage("OutputSumcheckProver", &ram_output_check); } - let mut instances: Vec>> = vec![ + let mut instances: Vec>> = vec![ Box::new(ram_read_write_checking), Box::new(spartan_product_virtual_remainder), Box::new(instruction_claim_reduction), @@ -955,23 +934,18 @@ impl< write_boxed_instance_flamegraph_svg(&instances, "stage2_start_flamechart.svg"); tracing::info!("Stage 2 proving"); - let (sumcheck_proof, r_stage2, _initial_claim) = + let (r_stage2, _initial_claim) = self.prove_batched_sumcheck(instances.iter_mut().map(|v| &mut **v as _).collect()); #[cfg(feature = "allocative")] write_boxed_instance_flamegraph_svg(&instances, "stage2_end_flamechart.svg"); drop_in_background_thread(instances); - (first_round_proof, sumcheck_proof, r_stage2) + r_stage2 } #[tracing::instrument(skip_all)] - fn prove_stage3( - &mut self, - ) -> ( - SumcheckInstanceProof, - Vec, - ) { + fn prove_stage3(&mut self) -> Vec { #[cfg(not(target_arch = "wasm32"))] print_current_memory_usage("Stage 3 baseline"); @@ -1017,7 +991,7 @@ impl< ); } - let mut instances: Vec>> = vec![ + let mut instances: Vec>> = vec![ Box::new(spartan_shift), Box::new(spartan_instruction_input), Box::new(spartan_registers_claim_reduction), @@ -1027,21 +1001,16 @@ impl< write_boxed_instance_flamegraph_svg(&instances, "stage3_start_flamechart.svg"); tracing::info!("Stage 3 proving"); - let (sumcheck_proof, r_stage3, _initial_claim) = + let (r_stage3, _initial_claim) = self.prove_batched_sumcheck(instances.iter_mut().map(|v| &mut **v as _).collect()); #[cfg(feature = "allocative")] write_boxed_instance_flamegraph_svg(&instances, "stage3_end_flamechart.svg"); drop_in_background_thread(instances); - (sumcheck_proof, r_stage3) + r_stage3 } #[tracing::instrument(skip_all)] - fn prove_stage4( - &mut self, - ) -> ( - SumcheckInstanceProof, - Vec, - ) { + fn prove_stage4(&mut self) -> Vec { #[cfg(not(target_arch = "wasm32"))] print_current_memory_usage("Stage 4 baseline"); @@ -1067,8 +1036,7 @@ impl< ); } // Domain-separate the batching challenge. - self.transcript.append_bytes(b"ram_val_check_gamma", &[]); - let ram_val_check_gamma: F = self.transcript.challenge_scalar::(); + let ram_val_check_gamma: F = self.transcript.challenge_field(); let ram_val_check_params = RamValCheckSumcheckParams::new_from_prover( &self.one_hot_params, &self.opening_accumulator, @@ -1103,7 +1071,7 @@ impl< print_data_structure_heap_usage("RamValCheckSumcheckProver", &ram_val_check); } - let mut instances: Vec>> = vec![ + let mut instances: Vec>> = vec![ Box::new(registers_read_write_checking), Box::new(ram_val_check), ]; @@ -1112,22 +1080,17 @@ impl< write_boxed_instance_flamegraph_svg(&instances, "stage4_start_flamechart.svg"); tracing::info!("Stage 4 proving"); - let (sumcheck_proof, r_stage4, _initial_claim) = + let (r_stage4, _initial_claim) = self.prove_batched_sumcheck(instances.iter_mut().map(|v| &mut **v as _).collect()); #[cfg(feature = "allocative")] write_boxed_instance_flamegraph_svg(&instances, "stage4_end_flamechart.svg"); drop_in_background_thread(instances); - (sumcheck_proof, r_stage4) + r_stage4 } #[tracing::instrument(skip_all)] - fn prove_stage5( - &mut self, - ) -> ( - SumcheckInstanceProof, - Vec, - ) { + fn prove_stage5(&mut self) -> Vec { #[cfg(not(target_arch = "wasm32"))] print_current_memory_usage("Stage 5 baseline"); // Initialization params (same order as batch) @@ -1173,7 +1136,7 @@ impl< ); } - let mut instances: Vec>> = vec![ + let mut instances: Vec>> = vec![ Box::new(lookups_read_raf), Box::new(ram_ra_reduction), Box::new(registers_val_evaluation), @@ -1183,23 +1146,17 @@ impl< write_boxed_instance_flamegraph_svg(&instances, "stage5_start_flamechart.svg"); tracing::info!("Stage 5 proving"); - let (sumcheck_proof, r_stage5, _initial_claim) = + let (r_stage5, _initial_claim) = self.prove_batched_sumcheck(instances.iter_mut().map(|v| &mut **v as _).collect()); #[cfg(feature = "allocative")] write_boxed_instance_flamegraph_svg(&instances, "stage5_end_flamechart.svg"); drop_in_background_thread(instances); - (sumcheck_proof, r_stage5) + r_stage5 } #[tracing::instrument(skip_all)] - fn prove_stage6a( - &mut self, - ) -> ( - SumcheckInstanceProof, - BytecodeReadRafSumcheckParams, - BooleanityCycleInput, - ) { + fn prove_stage6a(&mut self) -> (BytecodeReadRafSumcheckParams, BooleanityCycleInput) { #[cfg(not(target_arch = "wasm32"))] print_current_memory_usage("Stage 6a baseline"); @@ -1239,14 +1196,14 @@ impl< print_data_structure_heap_usage("BooleanityAddressSumcheckProver", &booleanity); } - let mut instances: Vec<&mut dyn SumcheckInstanceProver<_, _>> = + let mut instances: Vec<&mut dyn SumcheckInstanceProver<_>> = vec![&mut bytecode_read_raf, &mut booleanity]; #[cfg(feature = "allocative")] write_instance_flamegraph_svg(&instances, "stage6a_start_flamechart.svg"); tracing::info!("Stage 6a proving"); - let (sumcheck_proof, _r_stage6a, _initial_claim) = + let (_r_stage6a, _initial_claim) = self.prove_batched_sumcheck(instances.iter_mut().map(|v| &mut **v as _).collect()); #[cfg(feature = "allocative")] @@ -1255,11 +1212,7 @@ impl< let booleanity_cycle_input = booleanity.into_cycle_input(); - ( - sumcheck_proof, - bytecode_read_raf.into_params(), - booleanity_cycle_input, - ) + (bytecode_read_raf.into_params(), booleanity_cycle_input) } #[tracing::instrument(skip_all)] @@ -1267,10 +1220,7 @@ impl< &mut self, bytecode_read_raf_params: BytecodeReadRafSumcheckParams, booleanity_cycle_input: BooleanityCycleInput, - ) -> ( - SumcheckInstanceProof, - Vec, - ) { + ) -> Vec { #[cfg(not(target_arch = "wasm32"))] print_current_memory_usage("Stage 6b baseline"); @@ -1444,7 +1394,7 @@ impl< let mut bytecode_reduction = self.bytecode_reduction_prover.take(); let mut program_image_reduction = self.program_image_reduction_prover.take(); - let mut instances: Vec<&mut dyn SumcheckInstanceProver<_, _>> = vec![ + let mut instances: Vec<&mut dyn SumcheckInstanceProver<_>> = vec![ &mut bytecode_read_raf, &mut booleanity, &mut ram_hamming_booleanity, @@ -1469,7 +1419,7 @@ impl< write_instance_flamegraph_svg(&instances, "stage6b_start_flamechart.svg"); tracing::info!("Stage 6b proving"); - let (sumcheck_proof, r_stage6b, _initial_claim) = + let (r_stage6b, _initial_claim) = self.prove_batched_sumcheck(instances.iter_mut().map(|v| &mut **v as _).collect()); #[cfg(feature = "allocative")] write_instance_flamegraph_svg(&instances, "stage6b_end_flamechart.svg"); @@ -1485,12 +1435,33 @@ impl< self.bytecode_reduction_prover = bytecode_reduction; self.program_image_reduction_prover = program_image_reduction; - (sumcheck_proof, r_stage6b) + r_stage6b + } + + /// Stash each ZK stage's per-round sumcheck polynomial degrees for the + /// `blindfold_r1cs_satisfaction` test, surfaced via `ProverDebugInfo`. Called at the only + /// point `ZkStageData` is available — just before BlindFold proving consumes it. The round + /// polynomials themselves live in the NARG (Chunk D/E), not the proof struct. + #[cfg(all(test, feature = "zk"))] + fn capture_zk_round_degrees( + &mut self, + zk_stages: &[crate::subprotocols::blindfold::ZkStageData], + ) { + self.zk_round_degrees = zk_stages + .iter() + .map(|stage| { + stage + .poly_coeffs + .iter() + .map(|c| c.len().saturating_sub(1)) + .collect() + }) + .collect(); } #[tracing::instrument(skip_all)] #[cfg(feature = "zk")] - fn prove_blindfold(&mut self, joint_opening_proof: &PCS::Proof) -> BlindFoldProof { + fn prove_blindfold(&mut self, joint_opening_proof: &PCS::Proof) { use crate::curve::JoltGroupElement; use rayon::prelude::*; @@ -1515,6 +1486,11 @@ impl< zk_stages.len() ); + // Test-only hook: stash per-stage round degrees for `blindfold_r1cs_satisfaction` + // (the round polys live in the NARG, not the proof; see `capture_zk_round_degrees`). + #[cfg(test)] + self.capture_zk_round_degrees(&zk_stages); + // Precompute power sums for uni-skip domains let outer_power_sums = LagrangeHelper::power_sums::< OUTER_UNIVARIATE_SKIP_DOMAIN_SIZE, @@ -1926,19 +1902,14 @@ impl< let eval_commitment_gens = PCS::eval_commitment_gens(&self.preprocessing.generators); let prover = BlindFoldProver::<_, _>::new(&pedersen_generators, &r1cs, eval_commitment_gens); - self.transcript.append_label(b"BlindFold"); - prover.prove(&real_instance, &real_witness, &z, &mut self.transcript) + // All BlindFold values are written into the NARG; `prove` returns no proof object. + prover.prove(&real_instance, &real_witness, &z, &mut self.transcript); } /// Stage 7: HammingWeight + ClaimReduction sumcheck (only log_k_chunk rounds). #[tracing::instrument(skip_all)] - fn prove_stage7( - &mut self, - ) -> ( - SumcheckInstanceProof, - Vec, - ) { + fn prove_stage7(&mut self) -> Vec { // Create params and prover for HammingWeightClaimReduction // (r_cycle and r_addr_bool are extracted from Booleanity opening internally) let hw_params = HammingWeightClaimReductionParams::new( @@ -1958,8 +1929,7 @@ impl< // Run Stage 7 batched sumcheck (address rounds only). // Includes HammingWeightClaimReduction plus address phase of advice reduction instances (if needed). - let mut instances: Vec>> = - vec![Box::new(hw_prover)]; + let mut instances: Vec>> = vec![Box::new(hw_prover)]; if let Some(mut advice_reduction_prover_trusted) = self.advice_reduction_prover_trusted.take() @@ -2017,13 +1987,13 @@ impl< write_boxed_instance_flamegraph_svg(&instances, "stage7_start_flamechart.svg"); tracing::info!("Stage 7 proving"); - let (sumcheck_proof, r_stage7, _initial_claim) = + let (r_stage7, _initial_claim) = self.prove_batched_sumcheck(instances.iter_mut().map(|v| &mut **v as _).collect()); #[cfg(feature = "allocative")] write_boxed_instance_flamegraph_svg(&instances, "stage7_end_flamechart.svg"); drop_in_background_thread(instances); - (sumcheck_proof, r_stage7) + r_stage7 } /// Stage 8: Dory batch opening proof. @@ -2216,8 +2186,8 @@ impl< // In non-ZK mode, absorb claims before sampling gamma for Fiat-Shamir binding. // In ZK mode, claims are secret; binding comes from BlindFold constraints instead. #[cfg(not(feature = "zk"))] - self.transcript.append_scalars(b"rlc_claims", &claims); - let gamma_powers: Vec = self.transcript.challenge_scalar_powers(claims.len()); + self.transcript.absorb_scalars(&claims); + let gamma_powers: Vec = self.transcript.challenge_powers(claims.len()); #[cfg(feature = "zk")] let constraint_coeffs: Vec = gamma_powers .iter() @@ -2320,7 +2290,7 @@ pub struct JoltAdvice> { #[cfg(feature = "allocative")] fn write_boxed_instance_flamegraph_svg( - instances: &[Box>], + instances: &[Box>], path: impl AsRef, ) { let mut flamegraph = FlameGraphBuilder::default(); @@ -2332,7 +2302,7 @@ fn write_boxed_instance_flamegraph_svg( #[cfg(feature = "allocative")] fn write_instance_flamegraph_svg( - instances: &[&mut dyn SumcheckInstanceProver], + instances: &[&mut dyn SumcheckInstanceProver], path: impl AsRef, ) { let mut flamegraph = FlameGraphBuilder::default(); @@ -2589,6 +2559,7 @@ mod tests { multilinear_polynomial::MultilinearPolynomial, opening_proof::{OpeningAccumulator, SumcheckId}, }; + use crate::utils::errors::ProofVerifyError; use crate::zkvm::bytecode::PreprocessingError; use crate::zkvm::claim_reductions::AdviceKind; use crate::zkvm::program::{CommittedProgramProverData, ProgramPreprocessing}; @@ -3329,6 +3300,152 @@ mod tests { verifier.verify().expect("Failed to verify proof"); } + /// T4 parity test (specs/on-chain-solidity-verifier-plan.md §15): the generic + /// `TranspilableVerifier` instantiated with the REAL accumulator and a REAL + /// spongefish transcript must accept a real proof, replaying the same NARG as + /// `JoltVerifier` stages 1–7. In non-ZK mode every NARG frame is consumed by the + /// end of stage 7 (stage 8 only absorbs/squeezes), so `check_eof` must pass. + #[cfg(all(feature = "transpiler", not(feature = "zk")))] + #[test] + #[serial] + fn muldiv_transpilable_verifier_parity() { + use crate::poly::commitment::dory::DoryCommitmentScheme; + use crate::poly::opening_proof::VerifierOpeningAccumulator; + use crate::utils::math::Math; + use crate::zkvm::transpilable_verifier::{TranspilableProofData, TranspilableVerifier}; + use crate::zkvm::{fiat_shamir_instance, RV64IMACSponge}; + use jolt_transcript::verifier_transcript; + + DoryGlobals::reset(); + let mut program = host::Program::new("muldiv-guest"); + let (bytecode, init_memory_state, _, e_entry) = program.decode(); + let inputs = postcard::to_stdvec(&[9u32, 5u32, 3u32]).unwrap(); + let (_, _, _, io_device) = program.trace(&inputs, &[], &[]); + + let (shared_preprocessing, _program_data) = test_shared_preprocessing( + bytecode, + init_memory_state, + e_entry, + io_device.memory_layout.clone(), + 1 << 16, + ) + .unwrap(); + let prover_preprocessing = JoltProverPreprocessing::new(shared_preprocessing.clone()); + let elf_contents_opt = program.get_elf_contents(); + let elf_contents = elf_contents_opt.as_deref().expect("elf contents is None"); + let prover = RV64IMACProver::gen_from_elf( + &prover_preprocessing, + elf_contents, + &inputs, + &[], + &[], + None, + None, + None, + ); + let io_device = prover.program_io.clone(); + let (jolt_proof, _debug_info) = prover.prove(); + assert!(!jolt_proof.zk_mode); + + let verifier_preprocessing = JoltVerifierPreprocessing::from(&prover_preprocessing); + + // Pre-seed the accumulator with the structural opening claims, exactly as + // `JoltVerifier::new` does. + let mut accumulator = + VerifierOpeningAccumulator::::new(jolt_proof.trace_length.log_2(), false); + accumulator.preseed_structural_claims(&jolt_proof.opening_claims.0); + + let proof_data = TranspilableProofData::from_proof(&jolt_proof); + let mut transpilable: TranspilableVerifier< + '_, + Fr, + Bn254Curve, + DoryCommitmentScheme, + VerifierOpeningAccumulator, + > = TranspilableVerifier::new( + &verifier_preprocessing, + proof_data, + io_device, + None, + accumulator, + ) + .expect("Failed to create transpilable verifier"); + + // Build the real transcript over the proof's NARG, mirroring `verify_inner` + // (instance computed over the TRUNCATED program_io held by the verifier). + let preprocessing_digest = verifier_preprocessing.shared.digest(); + let instance = fiat_shamir_instance( + &transpilable.program_io, + jolt_proof.ram_K, + jolt_proof.trace_length, + verifier_preprocessing.shared.program_meta.entry_address, + &jolt_proof.rw_config, + &jolt_proof.one_hot_config, + jolt_proof.dory_layout, + &preprocessing_digest, + ); + let mut ts = verifier_transcript( + b"Jolt", + instance, + RV64IMACSponge::default(), + &jolt_proof.narg, + ); + + transpilable + .verify(&mut ts) + .expect("TranspilableVerifier failed on a real proof"); + // Stage-7 completeness: stage 8 reads no NARG frames in non-ZK mode. + ts.check_eof() + .expect("NARG not fully consumed after stage 7"); + } + + /// `zk_mode` is an attacker-controlled proof field, but the build's `zk` feature fixes + /// which mode this verifier can actually check (the proof struct/BlindFold code differ + /// per mode at compile time). A flipped flag must be rejected up front by + /// `JoltVerifier::new` with `ZkModeMismatch`, not fail somewhere downstream. + #[test] + #[serial] + fn flipped_zk_mode_flag_is_rejected() { + DoryGlobals::reset(); + let mut program = host::Program::new("muldiv-guest"); + let (bytecode, init_memory_state, _, e_entry) = program.decode(); + let inputs = postcard::to_stdvec(&[9u32, 5u32, 3u32]).unwrap(); + let (_, _, _, io_device) = program.trace(&inputs, &[], &[]); + + let (shared_preprocessing, _program_data) = test_shared_preprocessing( + bytecode, + init_memory_state, + e_entry, + io_device.memory_layout.clone(), + 1 << 16, + ) + .unwrap(); + let prover_preprocessing = JoltProverPreprocessing::new(shared_preprocessing.clone()); + let elf_contents_opt = program.get_elf_contents(); + let elf_contents = elf_contents_opt.as_deref().expect("elf contents is None"); + let prover = RV64IMACProver::gen_from_elf( + &prover_preprocessing, + elf_contents, + &inputs, + &[], + &[], + None, + None, + None, + ); + let io_device = prover.program_io.clone(); + let (mut jolt_proof, _debug_info) = prover.prove(); + jolt_proof.zk_mode = !jolt_proof.zk_mode; + + let verifier_preprocessing = JoltVerifierPreprocessing::from(&prover_preprocessing); + let result = + RV64IMACVerifier::new(&verifier_preprocessing, jolt_proof, io_device, None, None); + assert!( + matches!(result, Err(ProofVerifyError::ZkModeMismatch)), + "flipped zk_mode flag must be rejected at verifier construction", + ); + } + #[test] #[serial] fn muldiv_e2e_dory_committed_program_commitments() { @@ -3500,102 +3617,52 @@ mod tests { fn blindfold_r1cs_satisfaction() { DoryGlobals::reset(); - use crate::curve::Bn254Curve; use crate::subprotocols::blindfold::{ BakedPublicInputs, BlindFoldWitness, RoundWitness, StageConfig, StageWitness, VerifierR1CSBuilder, }; - use crate::subprotocols::sumcheck::SumcheckInstanceProof; - use crate::transcripts::{KeccakTranscript, Transcript}; - /// Helper to process a single stage's sumcheck proof. - /// Returns a list of (RoundWitness, degree) for each round. - /// For ZK proofs, creates synthetic witnesses with correct degrees to test R1CS structure. - fn process_stage( - _stage_name: &str, - proof: &SumcheckInstanceProof, - transcript: &mut KeccakTranscript, + use crate::transcript_msgs::VerifierFs; + use jolt_transcript::{verifier_transcript, Blake2b512}; + /// Build synthetic per-round witnesses for one stage from its captured per-round + /// polynomial degrees. The round polynomials themselves now live in the NARG (Chunk D/E), + /// not the proof struct, so the test sources the degrees from the prover-side capture + /// (`ProverDebugInfo::zk_round_degrees`) and synthesizes coefficients that satisfy the + /// sumcheck relation to exercise the R1CS structure. + /// + /// `transcript` is a fresh, independent challenge generator (not the proof's transcript): + /// it is bound on `VerifierFs`, which pins `Fr`, so the `challenge_optimized` calls + /// below need no fully-qualified turbofish. + fn process_stage( + round_degrees: &[usize], + transcript: &mut impl VerifierFs, ) -> Vec<(RoundWitness, usize)> { - match proof { - SumcheckInstanceProof::Clear(std_proof) => { - // For Standard proofs, use actual polynomial coefficients - let compressed_polys = &std_proof.compressed_polys; - let num_rounds = compressed_polys.len(); - - if num_rounds == 0 { - return vec![]; - } - - let mut rounds = Vec::with_capacity(num_rounds); - - for compressed_poly in compressed_polys.iter() { - transcript.append_scalars( - b"sumcheck_poly", - &compressed_poly.coeffs_except_linear_term, - ); - let challenge: Fr = transcript.challenge_scalar_optimized::().into(); - - let compressed = &compressed_poly.coeffs_except_linear_term; - let degree = compressed.len(); + let mut rounds = Vec::with_capacity(round_degrees.len()); - let c0 = compressed[0]; - let sum_higher_coeffs: Fr = compressed[1..].iter().copied().sum(); + for °ree in round_degrees { + let challenge: Fr = transcript.challenge_optimized().into(); - let claimed_sum = Fr::from(12345u64); - let c1 = claimed_sum - c0 - c0 - sum_higher_coeffs; + // Create synthetic coefficients that satisfy sumcheck relation + // g(x) = c0 + c1*x + c2*x^2 + ... has degree+1 coefficients + // claimed_sum = 2*c0 + c1 + c2 + ... + let claimed_sum = Fr::from(12345u64); - let mut coeffs = vec![c0, c1]; - coeffs.extend_from_slice(&compressed[1..]); + // Use simple synthetic values: c0 = 1, c2..cd = 1, compute c1 + let c0 = Fr::from(1u64); + let num_higher_coeffs = degree.saturating_sub(1); + let sum_higher_coeffs = Fr::from(num_higher_coeffs as u64); + let c1 = claimed_sum - c0 - c0 - sum_higher_coeffs; - let round_witness = - RoundWitness::with_claimed_sum(coeffs, challenge, claimed_sum); - - rounds.push((round_witness, degree)); - } - - rounds + let mut coeffs = vec![c0, c1]; + for _ in 0..num_higher_coeffs { + coeffs.push(Fr::from(1u64)); } - SumcheckInstanceProof::Zk(zk_proof) => { - // For ZK proofs, create synthetic witnesses with correct degrees. - // This tests the R1CS structure without needing actual coefficients. - let num_rounds = zk_proof.round_commitments.len(); - - if num_rounds == 0 { - return vec![]; - } - - let mut rounds = Vec::with_capacity(num_rounds); - - for (round_idx, commitment) in zk_proof.round_commitments.iter().enumerate() { - transcript.append_commitment(b"sumcheck_commitment", commitment); - let challenge: Fr = transcript.challenge_scalar_optimized::().into(); - - let degree = zk_proof.poly_degrees[round_idx]; - - // Create synthetic coefficients that satisfy sumcheck relation - // g(x) = c0 + c1*x + c2*x^2 + ... has degree+1 coefficients - // claimed_sum = 2*c0 + c1 + c2 + ... - let claimed_sum = Fr::from(12345u64); - - // Use simple synthetic values: c0 = 1, c2..cd = 1, compute c1 - let c0 = Fr::from(1u64); - let num_higher_coeffs = degree.saturating_sub(1); - let sum_higher_coeffs = Fr::from(num_higher_coeffs as u64); - let c1 = claimed_sum - c0 - c0 - sum_higher_coeffs; - let mut coeffs = vec![c0, c1]; - for _ in 0..num_higher_coeffs { - coeffs.push(Fr::from(1u64)); - } - - let round_witness = - RoundWitness::with_claimed_sum(coeffs, challenge, claimed_sum); + let round_witness = RoundWitness::with_claimed_sum(coeffs, challenge, claimed_sum); - rounds.push((round_witness, degree)); - } - - rounds - } + rounds.push((round_witness, degree)); } + + rounds } // Run muldiv prover to get a real proof @@ -3624,38 +3691,39 @@ mod tests { None, None, ); - let (jolt_proof, _) = prover.prove(); + let (_jolt_proof, debug_info) = prover.prove(); + // The per-stage regular-round degrees are captured by the prover (the round polynomials + // now live in the NARG, not the proof struct). `zk_round_degrees` is indexed by the 8 ZK + // stages [stage1, stage2, stage3, stage4, stage5, stage6a, stage6b, stage7]; this test + // mirrors the original 7-stage set, which uses stage6b (index 6) and skips stage6a. + let zk_round_degrees = debug_info + .expect("debug info present in test builds") + .zk_round_degrees; println!("\n=== BlindFold R1CS Satisfaction Test (All 7 Stages) ===\n"); // Process all 7 stages and verify each one - let stage_proofs: Vec<(&str, &SumcheckInstanceProof)> = vec![ - ("Stage 1 (Spartan Outer)", &jolt_proof.stage1_sumcheck_proof), - ( - "Stage 2 (Product Virtual)", - &jolt_proof.stage2_sumcheck_proof, - ), - ("Stage 3 (Instruction)", &jolt_proof.stage3_sumcheck_proof), - ("Stage 4 (Registers+RAM)", &jolt_proof.stage4_sumcheck_proof), - ("Stage 5 (Value+Lookup)", &jolt_proof.stage5_sumcheck_proof), - ( - "Stage 6 (OneHot+Hamming)", - &jolt_proof.stage6b_sumcheck_proof, - ), - ( - "Stage 7 (HammingWeight+ClaimReduction)", - &jolt_proof.stage7_sumcheck_proof, - ), + let stage_proofs: Vec<(&str, usize)> = vec![ + ("Stage 1 (Spartan Outer)", 0), + ("Stage 2 (Product Virtual)", 1), + ("Stage 3 (Instruction)", 2), + ("Stage 4 (Registers+RAM)", 3), + ("Stage 5 (Value+Lookup)", 4), + ("Stage 6 (OneHot+Hamming)", 6), + ("Stage 7 (HammingWeight+ClaimReduction)", 7), ]; let mut total_rounds = 0; let mut total_constraints = 0; - for (stage_name, proof) in &stage_proofs { - // Create a fresh transcript for each stage (independent verification) - let mut stage_transcript = KeccakTranscript::new(b"BlindFoldStageTest"); + for (stage_name, stage_idx) in &stage_proofs { + // Create a fresh transcript for each stage (independent verification). BlindFold's + // R1CS-structure check only needs deterministic per-round challenges, so an empty-NARG + // verifier transcript (challenge only, no reads) suffices. + let mut stage_transcript = + verifier_transcript(b"BlindFoldStageTest", [0u8; 32], Blake2b512::default(), &[]); - let rounds = process_stage(stage_name, proof, &mut stage_transcript); + let rounds = process_stage(&zk_round_degrees[*stage_idx], &mut stage_transcript); if rounds.is_empty() { println!(" {stage_name} - 0 rounds, skipping"); @@ -3873,7 +3941,7 @@ mod tests { BlindFoldWitness, RelaxedR1CSInstance, RoundWitness, StageConfig, StageWitness, VerifierR1CSBuilder, }; - use crate::transcripts::{KeccakTranscript, Transcript}; + use jolt_transcript::Blake2b512; use rand::thread_rng; let mut rng = thread_rng(); @@ -3955,8 +4023,12 @@ mod tests { let prover = BlindFoldProver::new(&gens, &r1cs, None); let verifier = BlindFoldVerifier::new(&gens, &r1cs, None); - let mut prover_transcript = KeccakTranscript::new(b"BlindFold_E2E"); - let proof = prover.prove(&real_instance, &real_witness, &z, &mut prover_transcript); + // BlindFold now writes all prover-only values into the NARG (Option B); the verifier + // replays over the prover's NARG. + let mut prover_transcript = + jolt_transcript::prover_transcript(b"BlindFold_E2E", [0u8; 32], Blake2b512::default()); + prover.prove(&real_instance, &real_witness, &z, &mut prover_transcript); + let narg = prover_transcript.narg_string().to_vec(); let verifier_input = BlindFoldVerifierInput { round_commitments: real_instance.round_commitments.clone(), @@ -3964,8 +4036,13 @@ mod tests { eval_commitments: real_instance.eval_commitments.clone(), }; - let mut verifier_transcript = KeccakTranscript::new(b"BlindFold_E2E"); - let result = verifier.verify(&proof, &verifier_input, &mut verifier_transcript); + let mut verifier_transcript = jolt_transcript::verifier_transcript( + b"BlindFold_E2E", + [0u8; 32], + Blake2b512::default(), + &narg, + ); + let result = verifier.verify(verifier_input, &mut verifier_transcript); assert!( result.is_ok(), @@ -3978,7 +4055,6 @@ mod tests { r1cs.num_constraints, r1cs.num_vars ); println!("Witness size: {} field elements", witness.len()); - println!("Spartan sumcheck rounds: {}", proof.spartan_proof.len()); println!("Protocol verification: SUCCESS"); } diff --git a/jolt-core/src/zkvm/ram/hamming_booleanity.rs b/jolt-core/src/zkvm/ram/hamming_booleanity.rs index 4f2e4476b3..6cb0408f8d 100644 --- a/jolt-core/src/zkvm/ram/hamming_booleanity.rs +++ b/jolt-core/src/zkvm/ram/hamming_booleanity.rs @@ -19,7 +19,6 @@ use crate::subprotocols::sumcheck_claim::{ }; use crate::subprotocols::sumcheck_prover::SumcheckInstanceProver; use crate::subprotocols::sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}; -use crate::transcripts::Transcript; use crate::zkvm::witness::VirtualPolynomial; use allocative::Allocative; #[cfg(feature = "allocative")] @@ -153,9 +152,7 @@ impl HammingBooleanitySumcheckProver { } } -impl SumcheckInstanceProver - for HammingBooleanitySumcheckProver -{ +impl SumcheckInstanceProver for HammingBooleanitySumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -215,8 +212,8 @@ impl HammingBooleanitySumcheckVerifier { } } -impl> - SumcheckInstanceVerifier for HammingBooleanitySumcheckVerifier +impl> SumcheckInstanceVerifier + for HammingBooleanitySumcheckVerifier { fn input_claim(&self, accumulator: &A) -> F { let result = self.params.input_claim(accumulator); diff --git a/jolt-core/src/zkvm/ram/mod.rs b/jolt-core/src/zkvm/ram/mod.rs index 80374b8b25..231144a5bf 100644 --- a/jolt-core/src/zkvm/ram/mod.rs +++ b/jolt-core/src/zkvm/ram/mod.rs @@ -68,16 +68,26 @@ use std::vec; use common::{constants::RAM_START_ADDRESS, jolt_device::MemoryLayout}; pub use jolt_program::preprocess::RAMPreprocessing; use rayon::prelude::*; +#[cfg(feature = "transpiler")] use std::any::Any; +#[cfg(feature = "transpiler")] use std::cell::RefCell; use tracer::emulator::memory::Memory; use tracer::JoltDevice; // --------------------------------------------------------------------------- -// Symbolic IO overrides (used by transpiler, no-op in native prover/verifier) +// Symbolic IO overrides — transpiler ONLY. +// +// These thread-locals let the transpiler swap the concrete IO / initial-RAM MLE +// evaluation for caller-supplied symbolic values before running `verify()`. They are +// a verifier-path backdoor (`eval_io_mle` / `eval_initial_ram_mle` sit on the RAM +// output-check), so they are gated behind `#[cfg(feature = "transpiler")]`: a +// production (non-transpiler) build compiles ONLY the concrete path, and the public +// setters do not exist, so nothing can install an attacker-chosen IO evaluation. // --------------------------------------------------------------------------- /// Override values for `eval_io_mle` during symbolic execution. +#[cfg(feature = "transpiler")] pub struct PendingIoMleValues { pub input_words: Vec, pub output_words: Vec, @@ -85,20 +95,24 @@ pub struct PendingIoMleValues { } /// Override values for `eval_initial_ram_mle` during symbolic execution. +#[cfg(feature = "transpiler")] pub struct PendingInitialRamValues { pub bytecode_words: Vec, pub input_words: Vec, } +#[cfg(feature = "transpiler")] thread_local! { static PENDING_IO_MLE: RefCell>> = RefCell::new(None); static PENDING_INITIAL_RAM: RefCell>> = RefCell::new(None); } +#[cfg(feature = "transpiler")] pub fn set_pending_io_mle(v: PendingIoMleValues) { PENDING_IO_MLE.with(|cell| *cell.borrow_mut() = Some(Box::new(v))); } +#[cfg(feature = "transpiler")] pub fn take_pending_io_mle() -> Option> { PENDING_IO_MLE.with(|cell| { cell.borrow_mut() @@ -107,10 +121,12 @@ pub fn take_pending_io_mle() -> Option(v: PendingInitialRamValues) { PENDING_INITIAL_RAM.with(|cell| *cell.borrow_mut() = Some(Box::new(v))); } +#[cfg(feature = "transpiler")] pub fn take_pending_initial_ram() -> Option> { PENDING_INITIAL_RAM.with(|cell| { cell.borrow_mut() @@ -622,7 +638,8 @@ pub fn eval_initial_ram_mle( program_io: &JoltDevice, r_address: &[F::Challenge], ) -> F { - // Symbolic override path (transpiler sets this before verify()) + // Symbolic override path (transpiler-only; absent in production builds). + #[cfg(feature = "transpiler")] if let Some(pending) = take_pending_initial_ram::() { let bytecode_start = remap_address( ram_preprocessing.min_bytecode_address, @@ -690,7 +707,8 @@ pub fn eval_io_mle( hi_scale *= F::one() - *r_i; } - // Symbolic override path (transpiler sets this before verify()) + // Symbolic override path (transpiler-only; absent in production builds). + #[cfg(feature = "transpiler")] if let Some(pending) = take_pending_io_mle::() { let mut acc = F::zero(); diff --git a/jolt-core/src/zkvm/ram/output_check.rs b/jolt-core/src/zkvm/ram/output_check.rs index 4b62d16205..2824db7718 100644 --- a/jolt-core/src/zkvm/ram/output_check.rs +++ b/jolt-core/src/zkvm/ram/output_check.rs @@ -21,7 +21,7 @@ use crate::{ sumcheck_prover::SumcheckInstanceProver, sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}, }, - transcripts::Transcript, + transcript_msgs::FsChallenge, utils::math::Math, zkvm::{config::ReadWriteConfig, ram::remap_address, witness::VirtualPolynomial}, }; @@ -60,11 +60,11 @@ impl OutputSumcheckParams { pub fn new( ram_K: usize, program_io: &JoltDevice, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, trace_len: usize, rw_config: &ReadWriteConfig, ) -> Self { - let r_address = transcript.challenge_vector_optimized::(ram_K.log_2()); + let r_address = transcript.challenge_optimized_vec(ram_K.log_2()); Self { K: ram_K, log_T: trace_len.log_2(), @@ -264,7 +264,7 @@ impl OutputSumcheckProver { } } -impl SumcheckInstanceProver for OutputSumcheckProver { +impl SumcheckInstanceProver for OutputSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -379,7 +379,7 @@ impl OutputSumcheckVerifier { pub fn new( ram_K: usize, program_io: &JoltDevice, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, trace_len: usize, rw_config: &ReadWriteConfig, ) -> Self { @@ -388,8 +388,8 @@ impl OutputSumcheckVerifier { } } -impl> - SumcheckInstanceVerifier for OutputSumcheckVerifier +impl> SumcheckInstanceVerifier + for OutputSumcheckVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params diff --git a/jolt-core/src/zkvm/ram/ra_virtual.rs b/jolt-core/src/zkvm/ram/ra_virtual.rs index 6b7a996bed..589ae9a486 100644 --- a/jolt-core/src/zkvm/ram/ra_virtual.rs +++ b/jolt-core/src/zkvm/ram/ra_virtual.rs @@ -74,7 +74,7 @@ use crate::{ eq_poly::EqPolynomial, multilinear_polynomial::{BindingOrder, PolynomialBinding}, }, - transcripts::Transcript, + transcript_msgs::FsChallenge, utils::math::Math, }; use allocative::Allocative; @@ -240,7 +240,7 @@ impl RamRaVirtualSumcheckProver { } } -impl SumcheckInstanceProver for RamRaVirtualSumcheckProver { +impl SumcheckInstanceProver for RamRaVirtualSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -295,15 +295,15 @@ impl RamRaVirtualSumcheckVerifier { trace_len: usize, one_hot_params: &OneHotParams, opening_accumulator: &A, - _transcript: &mut impl Transcript, + _transcript: &mut impl FsChallenge, ) -> Self { let params = RamRaVirtualParams::new(trace_len, one_hot_params, opening_accumulator); Self { params } } } -impl> - SumcheckInstanceVerifier for RamRaVirtualSumcheckVerifier +impl> SumcheckInstanceVerifier + for RamRaVirtualSumcheckVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params diff --git a/jolt-core/src/zkvm/ram/raf_evaluation.rs b/jolt-core/src/zkvm/ram/raf_evaluation.rs index 5b80f95ab6..1741d83de2 100644 --- a/jolt-core/src/zkvm/ram/raf_evaluation.rs +++ b/jolt-core/src/zkvm/ram/raf_evaluation.rs @@ -32,7 +32,6 @@ use crate::{ sumcheck_prover::SumcheckInstanceProver, sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}, }, - transcripts::Transcript, utils::{math::Math, thread::unsafe_allocate_zero_vec}, zkvm::{ config::{OneHotParams, ReadWriteConfig}, @@ -272,7 +271,7 @@ impl RafEvaluationSumcheckProver { } } -impl SumcheckInstanceProver for RafEvaluationSumcheckProver { +impl SumcheckInstanceProver for RafEvaluationSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -381,8 +380,8 @@ impl RafEvaluationSumcheckVerifier { } } -impl> - SumcheckInstanceVerifier for RafEvaluationSumcheckVerifier +impl> SumcheckInstanceVerifier + for RafEvaluationSumcheckVerifier { fn input_claim(&self, accumulator: &A) -> F { let result = self.params.input_claim(accumulator); diff --git a/jolt-core/src/zkvm/ram/read_write_checking.rs b/jolt-core/src/zkvm/ram/read_write_checking.rs index beddc29f58..644e5d7a0c 100644 --- a/jolt-core/src/zkvm/ram/read_write_checking.rs +++ b/jolt-core/src/zkvm/ram/read_write_checking.rs @@ -33,7 +33,7 @@ use crate::{ multilinear_polynomial::{BindingOrder, MultilinearPolynomial, PolynomialBinding}, opening_proof::{OpeningPoint, ProverOpeningAccumulator, SumcheckId, BIG_ENDIAN}, }, - transcripts::Transcript, + transcript_msgs::FsChallenge, utils::math::Math, zkvm::witness::{CommittedPolynomial, VirtualPolynomial}, }; @@ -76,12 +76,12 @@ pub struct RamReadWriteCheckingParams { impl RamReadWriteCheckingParams { pub fn new( opening_accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, one_hot_params: &OneHotParams, trace_length: usize, config: &ReadWriteConfig, ) -> Self { - let gamma = transcript.challenge_scalar(); + let gamma = transcript.challenge_field(); let (r_cycle, _) = opening_accumulator.get_virtual_polynomial_opening( VirtualPolynomial::RamReadValue, SumcheckId::SpartanOuter, @@ -638,7 +638,7 @@ impl RamReadWriteCheckingProver { } } -impl SumcheckInstanceProver for RamReadWriteCheckingProver { +impl SumcheckInstanceProver for RamReadWriteCheckingProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -705,7 +705,7 @@ pub struct RamReadWriteCheckingVerifier { impl RamReadWriteCheckingVerifier { pub fn new( opening_accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, one_hot_params: &OneHotParams, trace_length: usize, config: &ReadWriteConfig, @@ -721,8 +721,8 @@ impl RamReadWriteCheckingVerifier { } } -impl> - SumcheckInstanceVerifier for RamReadWriteCheckingVerifier +impl> SumcheckInstanceVerifier + for RamReadWriteCheckingVerifier { fn input_claim(&self, accumulator: &A) -> F { let result = self.params.input_claim(accumulator); diff --git a/jolt-core/src/zkvm/ram/val_check.rs b/jolt-core/src/zkvm/ram/val_check.rs index 6e326cde87..6346746d56 100644 --- a/jolt-core/src/zkvm/ram/val_check.rs +++ b/jolt-core/src/zkvm/ram/val_check.rs @@ -28,7 +28,6 @@ use crate::{ sumcheck_prover::SumcheckInstanceProver, sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}, }, - transcripts::Transcript, utils::math::Math, zkvm::{ bytecode::BytecodePreprocessing, @@ -411,7 +410,7 @@ impl RamValCheckSumcheckProver { } } -impl SumcheckInstanceProver for RamValCheckSumcheckProver { +impl SumcheckInstanceProver for RamValCheckSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -537,8 +536,8 @@ impl RamValCheckSumcheckVerifier { } } -impl> - SumcheckInstanceVerifier for RamValCheckSumcheckVerifier +impl> SumcheckInstanceVerifier + for RamValCheckSumcheckVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params diff --git a/jolt-core/src/zkvm/registers/read_write_checking.rs b/jolt-core/src/zkvm/registers/read_write_checking.rs index fa5c26fa67..5554d27ca4 100644 --- a/jolt-core/src/zkvm/registers/read_write_checking.rs +++ b/jolt-core/src/zkvm/registers/read_write_checking.rs @@ -34,7 +34,7 @@ use crate::{ sumcheck_prover::SumcheckInstanceProver, sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}, }, - transcripts::Transcript, + transcript_msgs::FsChallenge, utils::math::Math, zkvm::witness::CommittedPolynomial, }; @@ -87,10 +87,10 @@ impl RegistersReadWriteCheckingParams { pub fn new( trace_length: usize, opening_accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, config: &ReadWriteConfig, ) -> Self { - let gamma = transcript.challenge_scalar::(); + let gamma = transcript.challenge_field(); let (r_cycle, _) = opening_accumulator.get_virtual_polynomial_opening( VirtualPolynomial::RdWriteValue, SumcheckId::RegistersClaimReduction, @@ -785,9 +785,7 @@ impl RegistersReadWriteCheckingProver { } } -impl SumcheckInstanceProver - for RegistersReadWriteCheckingProver -{ +impl SumcheckInstanceProver for RegistersReadWriteCheckingProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -898,7 +896,7 @@ impl RegistersReadWriteCheckingVerifier { pub fn new>( trace_len: usize, opening_accumulator: &A, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, config: &ReadWriteConfig, ) -> Self { let params = RegistersReadWriteCheckingParams::new( @@ -911,8 +909,8 @@ impl RegistersReadWriteCheckingVerifier { } } -impl> - SumcheckInstanceVerifier for RegistersReadWriteCheckingVerifier +impl> SumcheckInstanceVerifier + for RegistersReadWriteCheckingVerifier { fn input_claim(&self, accumulator: &A) -> F { let result = self.params.input_claim(accumulator); diff --git a/jolt-core/src/zkvm/registers/val_evaluation.rs b/jolt-core/src/zkvm/registers/val_evaluation.rs index 8c334756c9..1ca6372f34 100644 --- a/jolt-core/src/zkvm/registers/val_evaluation.rs +++ b/jolt-core/src/zkvm/registers/val_evaluation.rs @@ -25,7 +25,6 @@ use crate::{ sumcheck_prover::SumcheckInstanceProver, sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}, }, - transcripts::Transcript, zkvm::{ bytecode::BytecodePreprocessing, witness::{CommittedPolynomial, VirtualPolynomial}, @@ -195,7 +194,7 @@ impl ValEvaluationSumcheckProver { } } -impl SumcheckInstanceProver for ValEvaluationSumcheckProver { +impl SumcheckInstanceProver for ValEvaluationSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -296,8 +295,8 @@ impl ValEvaluationSumcheckVerifier { } } -impl> - SumcheckInstanceVerifier for ValEvaluationSumcheckVerifier +impl> SumcheckInstanceVerifier + for ValEvaluationSumcheckVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params diff --git a/jolt-core/src/zkvm/spartan/instruction_input.rs b/jolt-core/src/zkvm/spartan/instruction_input.rs index ef4436b4f6..995c72b328 100644 --- a/jolt-core/src/zkvm/spartan/instruction_input.rs +++ b/jolt-core/src/zkvm/spartan/instruction_input.rs @@ -30,7 +30,7 @@ use crate::{ sumcheck_prover::SumcheckInstanceProver, sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}, }, - transcripts::Transcript, + transcript_msgs::FsChallenge, zkvm::{ instruction::{Flags, InstructionFlags, JoltTraceCycle}, witness::VirtualPolynomial, @@ -49,13 +49,13 @@ pub struct InstructionInputParams { impl InstructionInputParams { pub fn new( opening_accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { let (r_cycle_stage_2, _) = opening_accumulator.get_virtual_polynomial_opening( VirtualPolynomial::LeftInstructionInput, SumcheckId::SpartanProductVirtualization, ); - let gamma = transcript.challenge_scalar(); + let gamma = transcript.challenge_field(); Self { r_cycle_stage_2, gamma, @@ -303,9 +303,7 @@ impl InstructionInputSumcheckProver { } } -impl SumcheckInstanceProver - for InstructionInputSumcheckProver -{ +impl SumcheckInstanceProver for InstructionInputSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -498,15 +496,15 @@ pub struct InstructionInputSumcheckVerifier { impl InstructionInputSumcheckVerifier { pub fn new>( opening_accumulator: &A, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { let params = InstructionInputParams::new(opening_accumulator, transcript); Self { params } } } -impl> - SumcheckInstanceVerifier for InstructionInputSumcheckVerifier +impl> SumcheckInstanceVerifier + for InstructionInputSumcheckVerifier { fn input_claim(&self, accumulator: &A) -> F { let result = self.params.input_claim(accumulator); diff --git a/jolt-core/src/zkvm/spartan/mod.rs b/jolt-core/src/zkvm/spartan/mod.rs index d963e3a642..0d0eb52e0d 100644 --- a/jolt-core/src/zkvm/spartan/mod.rs +++ b/jolt-core/src/zkvm/spartan/mod.rs @@ -1,8 +1,11 @@ use crate::curve::JoltCurve; use crate::field::JoltField; use crate::poly::opening_proof::AbstractVerifierOpeningAccumulator; -use crate::subprotocols::univariate_skip::{UniSkipFirstRoundProof, UniSkipFirstRoundProofVariant}; -use crate::transcripts::Transcript; +use crate::subprotocols::sumcheck_verifier::SumcheckInstanceVerifier; +#[cfg(feature = "zk")] +use crate::subprotocols::univariate_skip::zk_uni_skip_first_round; +use crate::subprotocols::univariate_skip::{uni_skip_first_round, ZkUniSkipReadback}; +use crate::transcript_msgs::VerifierFs; use crate::utils::errors::ProofVerifyError; use crate::zkvm::r1cs::constraints::{ OUTER_FIRST_ROUND_POLY_NUM_COEFFS, OUTER_UNIVARIATE_SKIP_DOMAIN_SIZE, @@ -17,59 +20,95 @@ pub mod outer; pub mod product; pub mod shift; +/// `(uni-skip params, first-round challenge, NARG ZK read-back)`. The read-back is `Some` +/// only in ZK mode (the first-round commitment + degree + output-claim commitments threaded +/// to stage 8 / BlindFold); `None` otherwise. +type UniSkipVerifyResult = (P, ::Challenge, Option>); + +/// Shared zk-vs-clear selector for a uni-skip first round. `zk_mode` (from +/// `JoltProof::zk_mode`) picks the read path; the per-path NARG read order is identical +/// either way. The ZK read-back (commitment + degree + output-claim commitments, threaded +/// to stage 8 / BlindFold) is `Some` only in ZK mode. +fn verify_uni_skip_first_round< + F: JoltField, + C: JoltCurve, + const N: usize, + const FIRST_ROUND_POLY_NUM_COEFFS: usize, + A: AbstractVerifierOpeningAccumulator, + V: SumcheckInstanceVerifier, +>( + zk_mode: bool, + verifier: &V, + opening_accumulator: &mut A, + transcript: &mut impl VerifierFs, +) -> Result<(F::Challenge, Option>), ProofVerifyError> { + if !zk_mode { + let challenge = uni_skip_first_round::verify::( + verifier, + opening_accumulator, + transcript, + )?; + Ok((challenge, None)) + } else { + #[cfg(feature = "zk")] + { + let (challenge, readback) = zk_uni_skip_first_round::verify_transcript::( + verifier, + opening_accumulator, + transcript, + )?; + Ok((challenge, Some(readback))) + } + #[cfg(not(feature = "zk"))] + { + Err(ProofVerifyError::ZkFeatureRequired) + } + } +} + pub fn verify_stage1_uni_skip< F: JoltField, C: JoltCurve, - T: Transcript, + T: VerifierFs, A: AbstractVerifierOpeningAccumulator, >( - proof: &UniSkipFirstRoundProofVariant, + zk_mode: bool, key: &UniformSpartanKey, opening_accumulator: &mut A, transcript: &mut T, -) -> Result<(OuterUniSkipParams, F::Challenge), ProofVerifyError> { +) -> Result>, ProofVerifyError> { let verifier = OuterUniSkipVerifier::new(key, transcript); + let (challenge, zk_readback) = verify_uni_skip_first_round::< + F, + C, + OUTER_UNIVARIATE_SKIP_DOMAIN_SIZE, + OUTER_FIRST_ROUND_POLY_NUM_COEFFS, + A, + _, + >(zk_mode, &verifier, opening_accumulator, transcript)?; - let challenge = match proof { - UniSkipFirstRoundProofVariant::Standard(std_proof) => { - UniSkipFirstRoundProof::verify::< - OUTER_UNIVARIATE_SKIP_DOMAIN_SIZE, - OUTER_FIRST_ROUND_POLY_NUM_COEFFS, - A, - >(std_proof, &verifier, opening_accumulator, transcript)? - } - UniSkipFirstRoundProofVariant::Zk(zk_proof) => { - zk_proof.verify_transcript(&verifier, opening_accumulator, transcript)? - } - }; - - Ok((verifier.params, challenge)) + Ok((verifier.params, challenge, zk_readback)) } pub fn verify_stage2_uni_skip< F: JoltField, C: JoltCurve, - T: Transcript, + T: VerifierFs, A: AbstractVerifierOpeningAccumulator, >( - proof: &UniSkipFirstRoundProofVariant, + zk_mode: bool, opening_accumulator: &mut A, transcript: &mut T, -) -> Result<(ProductVirtualUniSkipParams, F::Challenge), ProofVerifyError> { +) -> Result>, ProofVerifyError> { let verifier = ProductVirtualUniSkipVerifier::new(opening_accumulator, transcript); + let (challenge, zk_readback) = verify_uni_skip_first_round::< + F, + C, + PRODUCT_VIRTUAL_UNIVARIATE_SKIP_DOMAIN_SIZE, + PRODUCT_VIRTUAL_FIRST_ROUND_POLY_NUM_COEFFS, + A, + _, + >(zk_mode, &verifier, opening_accumulator, transcript)?; - let challenge = match proof { - UniSkipFirstRoundProofVariant::Standard(std_proof) => { - UniSkipFirstRoundProof::verify::< - PRODUCT_VIRTUAL_UNIVARIATE_SKIP_DOMAIN_SIZE, - PRODUCT_VIRTUAL_FIRST_ROUND_POLY_NUM_COEFFS, - A, - >(std_proof, &verifier, opening_accumulator, transcript)? - } - UniSkipFirstRoundProofVariant::Zk(zk_proof) => { - zk_proof.verify_transcript(&verifier, opening_accumulator, transcript)? - } - }; - - Ok((verifier.params, challenge)) + Ok((verifier.params, challenge, zk_readback)) } diff --git a/jolt-core/src/zkvm/spartan/outer.rs b/jolt-core/src/zkvm/spartan/outer.rs index 91b46762d7..8f9f89841e 100644 --- a/jolt-core/src/zkvm/spartan/outer.rs +++ b/jolt-core/src/zkvm/spartan/outer.rs @@ -31,7 +31,7 @@ use crate::subprotocols::streaming_sumcheck::{ use crate::subprotocols::sumcheck_prover::SumcheckInstanceProver; use crate::subprotocols::sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}; use crate::subprotocols::univariate_skip::build_uniskip_first_round_poly; -use crate::transcripts::Transcript; +use crate::transcript_msgs::FsChallenge; use crate::utils::accumulation::{FullAccumS, MedAccumS, SmallAccumU, WideAccumS}; use crate::utils::expanding_table::ExpandingTable; use crate::utils::math::Math; @@ -96,9 +96,9 @@ pub struct OuterUniSkipParams { } impl OuterUniSkipParams { - pub fn new(key: &UniformSpartanKey, transcript: &mut T) -> Self { + pub fn new>(key: &UniformSpartanKey, transcript: &mut T) -> Self { let num_rounds_x: usize = key.num_rows_bits(); - let tau = transcript.challenge_vector_optimized::(num_rounds_x); + let tau = transcript.challenge_optimized_vec(num_rounds_x); Self { tau } } } @@ -259,7 +259,7 @@ impl OuterUniSkipProver { } } -impl SumcheckInstanceProver for OuterUniSkipProver { +impl SumcheckInstanceProver for OuterUniSkipProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -316,14 +316,14 @@ pub struct OuterUniSkipVerifier { } impl OuterUniSkipVerifier { - pub fn new(key: &UniformSpartanKey, transcript: &mut T) -> Self { + pub fn new>(key: &UniformSpartanKey, transcript: &mut T) -> Self { let params = OuterUniSkipParams::new(key, transcript); Self { params } } } -impl> - SumcheckInstanceVerifier for OuterUniSkipVerifier +impl> SumcheckInstanceVerifier + for OuterUniSkipVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params @@ -693,8 +693,8 @@ impl OuterRemainingSumcheckVerifier { } } -impl> - SumcheckInstanceVerifier for OuterRemainingSumcheckVerifier +impl> SumcheckInstanceVerifier + for OuterRemainingSumcheckVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params diff --git a/jolt-core/src/zkvm/spartan/product.rs b/jolt-core/src/zkvm/spartan/product.rs index d38582eae0..4f23532737 100644 --- a/jolt-core/src/zkvm/spartan/product.rs +++ b/jolt-core/src/zkvm/spartan/product.rs @@ -26,7 +26,7 @@ use crate::subprotocols::blindfold::{ use crate::subprotocols::sumcheck_prover::SumcheckInstanceProver; use crate::subprotocols::sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}; use crate::subprotocols::univariate_skip::build_uniskip_first_round_poly; -use crate::transcripts::Transcript; +use crate::transcript_msgs::FsChallenge; use crate::utils::accumulation::FullAccumS; use crate::utils::math::Math; #[cfg(feature = "allocative")] @@ -88,7 +88,7 @@ pub struct ProductVirtualUniSkipParams { } impl ProductVirtualUniSkipParams { - pub fn new( + pub fn new>( opening_accumulator: &dyn OpeningAccumulator, transcript: &mut T, ) -> Self { @@ -97,7 +97,7 @@ impl ProductVirtualUniSkipParams { .get_virtual_polynomial_opening(VirtualPolynomial::Product, SumcheckId::SpartanOuter) .0 .r; - let tau_high = transcript.challenge_scalar_optimized::(); + let tau_high = transcript.challenge_optimized(); let mut tau = r_cycle; tau.push(tau_high); @@ -275,7 +275,7 @@ impl ProductVirtualUniSkipProver { } } -impl SumcheckInstanceProver for ProductVirtualUniSkipProver { +impl SumcheckInstanceProver for ProductVirtualUniSkipProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -334,7 +334,7 @@ pub struct ProductVirtualUniSkipVerifier { } impl ProductVirtualUniSkipVerifier { - pub fn new( + pub fn new>( opening_accumulator: &dyn OpeningAccumulator, transcript: &mut T, ) -> Self { @@ -343,8 +343,8 @@ impl ProductVirtualUniSkipVerifier { } } -impl> - SumcheckInstanceVerifier for ProductVirtualUniSkipVerifier +impl> SumcheckInstanceVerifier + for ProductVirtualUniSkipVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params @@ -757,9 +757,7 @@ impl ProductVirtualRemainderProver { } } -impl SumcheckInstanceProver - for ProductVirtualRemainderProver -{ +impl SumcheckInstanceProver for ProductVirtualRemainderProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -825,8 +823,8 @@ impl ProductVirtualRemainderVerifier { } } -impl> - SumcheckInstanceVerifier for ProductVirtualRemainderVerifier +impl> SumcheckInstanceVerifier + for ProductVirtualRemainderVerifier { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params diff --git a/jolt-core/src/zkvm/spartan/shift.rs b/jolt-core/src/zkvm/spartan/shift.rs index f7a203aeec..84b45afbc4 100644 --- a/jolt-core/src/zkvm/spartan/shift.rs +++ b/jolt-core/src/zkvm/spartan/shift.rs @@ -29,7 +29,7 @@ use crate::subprotocols::sumcheck_claim::{ }; use crate::subprotocols::sumcheck_prover::SumcheckInstanceProver; use crate::subprotocols::sumcheck_verifier::{SumcheckInstanceParams, SumcheckInstanceVerifier}; -use crate::transcripts::Transcript; +use crate::transcript_msgs::FsChallenge; use crate::zkvm::bytecode::BytecodePreprocessing; use crate::zkvm::instruction::{CircuitFlags, InstructionFlags}; use crate::zkvm::r1cs::inputs::ShiftSumcheckCycleState; @@ -66,9 +66,9 @@ impl ShiftSumcheckParams { pub fn new( n_cycle_vars: usize, opening_accumulator: &dyn OpeningAccumulator, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { - let gamma_powers = transcript.challenge_scalar_powers(5).try_into().unwrap(); + let gamma_powers = transcript.challenge_powers(5).try_into().unwrap(); let (outer_sumcheck_r, _) = opening_accumulator .get_virtual_polynomial_opening(VirtualPolynomial::NextPC, SumcheckId::SpartanOuter); let (r_outer, _rx_var) = outer_sumcheck_r.split_at(n_cycle_vars); @@ -278,7 +278,7 @@ impl ShiftSumcheckProver { } } -impl SumcheckInstanceProver for ShiftSumcheckProver { +impl SumcheckInstanceProver for ShiftSumcheckProver { fn get_params(&self) -> &dyn SumcheckInstanceParams { &self.params } @@ -379,15 +379,15 @@ impl ShiftSumcheckVerifier { pub fn new>( n_cycle_vars: usize, opening_accumulator: &A, - transcript: &mut impl Transcript, + transcript: &mut impl FsChallenge, ) -> Self { let params = ShiftSumcheckParams::new(n_cycle_vars, opening_accumulator, transcript); Self { params } } } -impl> - SumcheckInstanceVerifier for ShiftSumcheckVerifier +impl> SumcheckInstanceVerifier + for ShiftSumcheckVerifier { fn input_claim(&self, accumulator: &A) -> F { let result = self.params.input_claim(accumulator); diff --git a/jolt-core/src/zkvm/transpilable_verifier.rs b/jolt-core/src/zkvm/transpilable_verifier.rs index abf57dad76..84c053d1fd 100644 --- a/jolt-core/src/zkvm/transpilable_verifier.rs +++ b/jolt-core/src/zkvm/transpilable_verifier.rs @@ -1,4 +1,4 @@ -//! Transpilable Verifier: Generic version of JoltVerifier for symbolic execution. +//! Transpilable Verifier: generic mirror of `JoltVerifier` for symbolic execution. //! //! # Temporary Module //! @@ -7,68 +7,63 @@ //! for this separate file. This would ensure transpilation automatically stays //! in sync with verifier changes. //! -//! The current separation exists to: -//! 1. Minimize changes to core Jolt code -//! 2. Allow rapid iteration on transpilation without coordination overhead -//! -//! //! # Overview //! -//! This module provides a verifier that is generic over the OpeningAccumulator, -//! allowing it to be used with both: -//! - `VerifierOpeningAccumulator` for real verification -//! - `AstOpeningAccumulator` for symbolic transpilation to circuit code -//! -//! The verification logic is identical to `verifier.rs`, ensuring that the -//! transpiled circuit matches the real verifier exactly. +//! A non-ZK verifier for stages 1–7, generic over two seams the transpiler swaps: +//! - `A: AbstractVerifierOpeningAccumulator` — `VerifierOpeningAccumulator` for +//! real verification, `AstOpeningAccumulator` for symbolic transpilation. +//! - the transcript, taken as `&mut impl VerifierFs` in [`Self::verify`] — a real +//! spongefish `VerifierState` over the proof's NARG, or a symbolic `VerifierFs` +//! implementation replaying pre-parsed NARG frames as AST variables. //! -//! ## Stages Implemented +//! Unlike `JoltVerifier`, this struct holds the proof's *structural* fields directly +//! (no `JoltProof`, hence no sponge type parameter): the symbolic path +//! has no real sponge, and the verification logic only needs `trace_length`, `ram_K`, +//! the configs, and the NARG (owned by the caller-built transcript). The non-ZK +//! `opening_claims` are pre-seeded into `A` by the caller, exactly as +//! `JoltVerifier::new` seeds its accumulator. //! -//! This verifier implements stages 1-7 (all sumcheck stages): -//! - Stages 1-5: Standard sumcheck verifications -//! - Stage 6a: Address-phase bytecode read-RAF and booleanity sumchecks -//! - Stage 6b: Cycle-phase sumchecks and precommitted claim reductions -//! - Stage 7: HammingWeight claim reduction and address-phase advice reductions +//! ## Scope //! -//! Stage 8 (PCS verification) is NOT transpiled by this module. It requires -//! native elliptic curve operations that are handled separately by the target -//! proving system (e.g., native Gnark Hyrax for BN254/Grumpkin). +//! Stages 1–7 only (all sumcheck stages), **non-ZK proofs only**. Stage 8 (Dory PCS +//! verification) is NOT transpiled: it requires native elliptic-curve operations that +//! the target proving system performs natively. In non-ZK mode every NARG frame is +//! consumed by the end of stage 7 (stage 8 only absorbs/squeezes), so callers holding +//! a concrete `VerifierState` can — and the parity test does — `check_eof` afterwards. //! -//! ## Advice Verifiers -//! -//! `AdviceClaimReduction` verifiers are included when advice commitments are present. -//! They span stages 6b and 7 with a phase transition between them: -//! - Stage 6b: CycleVariables phase (bind cycle-derived coordinates) -//! - Stage 7: AddressVariables phase (bind address-derived coordinates) +//! The per-stage methods are `pub` (unlike `JoltVerifier`) so the transpiler driver +//! can set witness-naming context between stages; `verify()` runs them all in order. use crate::curve::JoltCurve; +use crate::field::JoltField; use crate::poly::commitment::commitment_scheme::CommitmentScheme; -use crate::poly::commitment::dory::DoryGlobals; -#[cfg(not(feature = "zk"))] -use crate::poly::opening_proof::{OpeningPoint, BIG_ENDIAN}; -use crate::subprotocols::sumcheck::{BatchedSumcheck, ClearSumcheckProof, SumcheckInstanceProof}; -use crate::zkvm::claim_reductions::{ - AdviceClaimReductionVerifier, AdviceKind, BytecodeClaimReductionParams, - BytecodeClaimReductionVerifier, HammingWeightClaimReductionVerifier, PrecommittedParams, - ProgramImageClaimReductionParams, ProgramImageClaimReductionVerifier, - RegistersClaimReductionSumcheckVerifier, +use crate::poly::commitment::dory::{DoryContext, DoryGlobals, DoryLayout}; +use crate::poly::opening_proof::AbstractVerifierOpeningAccumulator; +use crate::subprotocols::booleanity::{ + BooleanityAddressSumcheckVerifier, BooleanityCycleSumcheckVerifier, BooleanitySumcheckParams, }; -use crate::zkvm::config::OneHotParams; +use crate::subprotocols::sumcheck::BatchedSumcheck; +use crate::subprotocols::sumcheck_verifier::SumcheckInstanceVerifier; +use crate::transcript_msgs::VerifierFs; +use crate::utils::{errors::ProofVerifyError, math::Math}; +use crate::zkvm::config::{OneHotConfig, OneHotParams, ReadWriteConfig}; use crate::zkvm::{ bytecode::read_raf_checking::{ BytecodeReadRafAddressSumcheckVerifier, BytecodeReadRafCycleSumcheckVerifier, BytecodeReadRafSumcheckParams, }, claim_reductions::{ + AdviceClaimReductionVerifier, AdviceKind, BytecodeClaimReductionParams, + BytecodeClaimReductionVerifier, HammingWeightClaimReductionVerifier, IncClaimReductionSumcheckVerifier, InstructionLookupsClaimReductionSumcheckVerifier, - RamRaClaimReductionSumcheckVerifier, + PrecommittedClaimReduction, PrecommittedParams, ProgramImageClaimReductionParams, + ProgramImageClaimReductionVerifier, RamRaClaimReductionSumcheckVerifier, + RegistersClaimReductionSumcheckVerifier, }, - fiat_shamir_preamble, instruction_lookups::{ ra_virtual::RaSumcheckVerifier as LookupsRaSumcheckVerifier, read_raf_checking::InstructionReadRafSumcheckVerifier, }, - proof_serialization::JoltProof, r1cs::key::UniformSpartanKey, ram::{ compute_max_ram_K, compute_min_ram_K, @@ -88,62 +83,70 @@ use crate::zkvm::{ verify_stage1_uni_skip, verify_stage2_uni_skip, }, verifier::{JoltSharedPreprocessing, JoltVerifierPreprocessing}, - ProverDebugInfo, -}; -use crate::{ - field::JoltField, - poly::opening_proof::{AbstractVerifierOpeningAccumulator, VerifierOpeningAccumulator}, - pprof_scope, - subprotocols::{ - booleanity::{ - BooleanityAddressSumcheckVerifier, BooleanityCycleSumcheckVerifier, - BooleanitySumcheckParams, - }, - sumcheck_verifier::SumcheckInstanceVerifier, - }, - transcripts::Transcript, - utils::{errors::ProofVerifyError, math::Math}, }; +use std::marker::PhantomData; use tracer::JoltDevice; -/// Extract the Clear (non-ZK) proof from a SumcheckInstanceProof enum. -/// TranspilableVerifier only handles non-ZK proofs; ZK mode uses the main verifier. -fn extract_clear_proof, T: Transcript>( - proof: &SumcheckInstanceProof, -) -> &ClearSumcheckProof { - match proof { - SumcheckInstanceProof::Clear(p) => p, - SumcheckInstanceProof::Zk(_) => { - panic!("TranspilableVerifier only supports non-ZK (Clear) proofs") +/// The proof's structural (non-NARG) fields needed by stages 1–7. Mirror of the +/// corresponding `JoltProof` fields; the NARG itself lives in the caller-built +/// transcript, and the non-ZK `opening_claims` are pre-seeded into the accumulator. +#[derive(Clone, Debug)] +pub struct TranspilableProofData { + pub trace_length: usize, + pub ram_K: usize, + pub rw_config: ReadWriteConfig, + pub one_hot_config: OneHotConfig, + pub dory_layout: DoryLayout, +} + +impl TranspilableProofData { + /// Structural fields extracted from a proof — single source for the replay + /// entry points. + pub fn from_proof< + F: JoltField, + C: JoltCurve, + PCS: CommitmentScheme, + H: jolt_transcript::DuplexSpongeInterface, + >( + proof: &crate::zkvm::proof_serialization::JoltProof, + ) -> Self { + Self { + trace_length: proof.trace_length, + ram_K: proof.ram_K, + rw_config: proof.rw_config.clone(), + one_hot_config: proof.one_hot_config.clone(), + dory_layout: proof.dory_layout, } } } -/// Generic verifier that can be used for both real verification and symbolic transpilation. -/// -/// The type parameter `A` is the OpeningAccumulator: -/// - For real verification: `A = VerifierOpeningAccumulator` -/// - For transpilation: `A = AstOpeningAccumulator` (symbolic accumulator) pub struct TranspilableVerifier< 'a, F: JoltField, C: JoltCurve, PCS: CommitmentScheme, - ProofTranscript: Transcript, - A: AbstractVerifierOpeningAccumulator = VerifierOpeningAccumulator, + A: AbstractVerifierOpeningAccumulator, > { pub trusted_advice_commitment: Option, pub program_io: JoltDevice, - pub proof: JoltProof, + pub proof_data: TranspilableProofData, + /// Witness-polynomial commitments, decoded from the NARG (one `read_slice` frame) + /// at the start of `verify` — mirrors `JoltVerifier::commitments`. + pub commitments: Vec, + /// Untrusted-advice commitment, decoded from the NARG presence frame (length 0/1). + pub untrusted_advice_commitment: Option, pub preprocessing: &'a JoltVerifierPreprocessing, - pub transcript: ProofTranscript, pub opening_accumulator: A, - pub spartan_key: UniformSpartanKey, - pub one_hot_params: OneHotParams, + /// Advice claim reduction spans stages 6b and 7; verifier state cached between them. advice_reduction_verifier_trusted: Option>, advice_reduction_verifier_untrusted: Option>, + /// Bytecode claim reduction spans stages 6b and 7 in committed mode. bytecode_reduction_verifier: Option>, + /// Program-image claim reduction spans stages 6b and 7 in committed mode. program_image_reduction_verifier: Option>, + pub spartan_key: UniformSpartanKey, + pub one_hot_params: OneHotParams, + _curve: PhantomData C>, } impl< @@ -151,25 +154,22 @@ impl< F: JoltField, C: JoltCurve, PCS: CommitmentScheme, - ProofTranscript: Transcript, A: AbstractVerifierOpeningAccumulator, - > TranspilableVerifier<'a, F, C, PCS, ProofTranscript, A> + > TranspilableVerifier<'a, F, C, PCS, A> { - /// Create a TranspilableVerifier for real verification. + /// Validates the structural proof data and constructs the verifier — the same + /// checks as `JoltVerifier::new`. The caller supplies the accumulator pre-seeded + /// with the proof's `opening_claims` (real path) or symbolic claims (transpiler). /// - /// This constructor creates a new `VerifierOpeningAccumulator` and populates - /// it with claims from the proof. Only available when `A = VerifierOpeningAccumulator`. + /// `untrusted_advice_commitment` starts `None`; it is decoded from the NARG + /// presence frame during `verify`, mirroring `JoltVerifier`. pub fn new( preprocessing: &'a JoltVerifierPreprocessing, - proof: JoltProof, + proof_data: TranspilableProofData, mut program_io: JoltDevice, trusted_advice_commitment: Option, - _debug_info: Option>, - ) -> Result< - TranspilableVerifier<'a, F, C, PCS, ProofTranscript, VerifierOpeningAccumulator>, - ProofVerifyError, - > { - // Memory layout checks + opening_accumulator: A, + ) -> Result { if program_io.memory_layout != preprocessing.shared.memory_layout { return Err(ProofVerifyError::MemoryLayoutMismatch); } @@ -180,17 +180,17 @@ impl< return Err(ProofVerifyError::OutputTooLarge); } - // Validate trace_length: must be a power of 2 and within the preprocessed bound - if !proof.trace_length.is_power_of_two() - || proof.trace_length > preprocessing.shared.max_padded_trace_length + if !proof_data.trace_length.is_power_of_two() + || proof_data.trace_length > preprocessing.shared.max_padded_trace_length { return Err(ProofVerifyError::InvalidTraceLength( - proof.trace_length, + proof_data.trace_length, preprocessing.shared.max_padded_trace_length, )); } - // truncate trailing zeros on device outputs + // Truncate trailing zero bytes from outputs, matching `JoltVerifier::new` — + // the Fiat-Shamir instance must be computed over the truncated outputs. program_io.outputs.truncate( program_io .outputs @@ -199,39 +199,9 @@ impl< .map_or(0, |pos| pos + 1), ); - let zk_mode = proof.verify_zk_consistency()?; - #[allow(unused_mut)] - let mut opening_accumulator = - VerifierOpeningAccumulator::new(proof.trace_length.log_2(), zk_mode); + let spartan_key = UniformSpartanKey::new(proof_data.trace_length.next_power_of_two()); - // Populate claims in the verifier accumulator - #[cfg(not(feature = "zk"))] - { - for (key, (_, claim)) in &proof.opening_claims.0 { - let dummy_point = OpeningPoint::::new(vec![]); - opening_accumulator - .openings - .insert(*key, (dummy_point, *claim)); - } - } - - #[cfg(test)] - let mut transcript = ProofTranscript::new(b"Jolt"); - #[cfg(not(test))] - let transcript = ProofTranscript::new(b"Jolt"); - - #[cfg(test)] - { - if let Some(debug_info) = _debug_info { - transcript.compare_to(debug_info.transcript); - opening_accumulator.compare_to(debug_info.opening_accumulator); - } - } - - let spartan_key = UniformSpartanKey::new(proof.trace_length.next_power_of_two()); - - // Validate configs from the proof - proof + proof_data .one_hot_config .validate() .map_err(ProofVerifyError::InvalidOneHotConfig)?; @@ -242,231 +212,204 @@ impl< }; let min_ram_K = compute_min_ram_K(&ram_preprocessing, &preprocessing.shared.memory_layout); let max_ram_K = compute_max_ram_K(&preprocessing.shared.memory_layout); - if !proof.ram_K.is_power_of_two() || proof.ram_K < min_ram_K || proof.ram_K > max_ram_K { + if !proof_data.ram_K.is_power_of_two() + || proof_data.ram_K < min_ram_K + || proof_data.ram_K > max_ram_K + { return Err(ProofVerifyError::InvalidRamK { - got: proof.ram_K, + got: proof_data.ram_K, min: min_ram_K, max: max_ram_K, }); } - proof + proof_data .rw_config - .validate(proof.trace_length.log_2(), proof.ram_K.log_2()) + .validate(proof_data.trace_length.log_2(), proof_data.ram_K.log_2()) .map_err(ProofVerifyError::InvalidReadWriteConfig)?; - // Construct full params from the validated config let bytecode_K = preprocessing.shared.bytecode_size(); let one_hot_params = - OneHotParams::from_config(&proof.one_hot_config, bytecode_K, proof.ram_K); + OneHotParams::from_config(&proof_data.one_hot_config, bytecode_K, proof_data.ram_K); - Ok(TranspilableVerifier { + Ok(Self { trusted_advice_commitment, program_io, - proof, + proof_data, + commitments: Vec::new(), + untrusted_advice_commitment: None, preprocessing, - transcript, opening_accumulator, - spartan_key, - one_hot_params, advice_reduction_verifier_trusted: None, advice_reduction_verifier_untrusted: None, bytecode_reduction_verifier: None, program_image_reduction_verifier: None, - }) - } - - /// Create a TranspilableVerifier with a pre-configured opening accumulator. - /// - /// This constructor is used for symbolic transpilation where the accumulator - /// is already populated with MleAst claims (or similar symbolic values). - pub fn new_with_accumulator( - preprocessing: &'a JoltVerifierPreprocessing, - proof: JoltProof, - program_io: JoltDevice, - trusted_advice_commitment: Option, - transcript: ProofTranscript, - opening_accumulator: A, - ) -> Self { - let spartan_key = UniformSpartanKey::new(proof.trace_length.next_power_of_two()); - let bytecode_K = preprocessing.shared.bytecode_size(); - let one_hot_params = - OneHotParams::from_config(&proof.one_hot_config, bytecode_K, proof.ram_K); - - Self { - trusted_advice_commitment, - program_io, - proof, - preprocessing, - transcript, - opening_accumulator, spartan_key, one_hot_params, - advice_reduction_verifier_trusted: None, - advice_reduction_verifier_untrusted: None, - bytecode_reduction_verifier: None, - program_image_reduction_verifier: None, - } + _curve: PhantomData, + }) } #[inline] fn main_total_vars(&self) -> usize { - let trace_log_t = self.proof.trace_length.log_2(); + let trace_log_t = self.proof_data.trace_length.log_2(); let log_k_chunk = self.one_hot_params.log_k_chunk; JoltSharedPreprocessing::::max_total_vars_from_candidates( trace_log_t + log_k_chunk, self.preprocessing.shared.precommitted_candidate_total_vars( self.preprocessing.shared.program.is_committed(), self.trusted_advice_commitment.is_some(), - self.proof.untrusted_advice_commitment.is_some(), + self.untrusted_advice_commitment.is_some(), ), ) } - /// Verify the Jolt proof (stages 1-7). + /// Verify stages 1–7 against `transcript`, which the caller has already seeded with + /// the Fiat-Shamir instance (for the real path: `verifier_transcript(b"Jolt", + /// fiat_shamir_instance(..), H::default(), &proof.narg)` computed over the TRUNCATED + /// `program_io` — use `self.program_io` after construction). /// - /// Note: Stage 8 (PCS verification) is not included because it uses - /// VerifierOpeningAccumulator-specific methods. For Gnark transpilation, - /// this is replaced by native Gnark pairing checks. - #[tracing::instrument(skip_all)] - pub fn verify(mut self) -> Result<(), ProofVerifyError> { - let _pprof_verify = pprof_scope!("verify"); - - let preprocessing_digest = self.preprocessing.shared.digest(); - fiat_shamir_preamble( - &self.program_io, - self.proof.ram_K, - self.proof.trace_length, - self.preprocessing.shared.program_meta.entry_address, - &self.proof.rw_config, - &self.proof.one_hot_config, - self.proof.dory_layout, - &preprocessing_digest, - &mut self.transcript, + /// Mirrors `JoltVerifier::verify_inner` from the Dory-context guard through stage 7; + /// stage 8 and `check_eof` are the caller's concern (see module docs). + pub fn verify(&mut self, transcript: &mut impl VerifierFs) -> Result<(), ProofVerifyError> { + let _guard = DoryGlobals::initialize_context( + 1 << self.one_hot_params.log_k_chunk, + self.proof_data.trace_length.next_power_of_two(), + DoryContext::Main, + Some(self.proof_data.dory_layout), ); - // Append commitments to transcript - for commitment in &self.proof.commitments { - self.transcript - .append_serializable(b"commitment", commitment); - } - // Append untrusted advice commitment to transcript - if let Some(ref untrusted_advice_commitment) = self.proof.untrusted_advice_commitment { - self.transcript - .append_serializable(b"untrusted_advice", untrusted_advice_commitment); + self.read_commitment_frames(transcript)?; + + self.verify_stage1(transcript) + .inspect_err(|e| tracing::error!("Stage 1: {e}"))?; + self.verify_stage2(transcript) + .inspect_err(|e| tracing::error!("Stage 2: {e}"))?; + self.verify_stage3(transcript) + .inspect_err(|e| tracing::error!("Stage 3: {e}"))?; + self.verify_stage4(transcript) + .inspect_err(|e| tracing::error!("Stage 4: {e}"))?; + self.verify_stage5(transcript) + .inspect_err(|e| tracing::error!("Stage 5: {e}"))?; + self.verify_stage6(transcript) + .inspect_err(|e| tracing::error!("Stage 6: {e}"))?; + self.verify_stage7(transcript) + .inspect_err(|e| tracing::error!("Stage 7: {e}"))?; + // Stage 8 (PCS) is not transpiled — see module docs. + + Ok(()) + } + + /// Pre-stage NARG frames + shared-commitment absorbs, mirroring + /// `JoltVerifier::verify_inner` between the Dory guard and stage 1. + pub fn read_commitment_frames( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result<(), ProofVerifyError> { + // Witness-polynomial commitments: ONE frame (matching the prover's single + // `write_commitments`), absorbed in the process. + self.commitments = transcript + .read_commitments() + .map_err(|_| ProofVerifyError::SumcheckVerificationError)?; + // Untrusted-advice presence frame (length-0/1 vec) → Option. The prover ALWAYS + // writes this frame, so the read position is the same in both cases. + let untrusted_advice: Vec = transcript + .read_commitments() + .map_err(|_| ProofVerifyError::SumcheckVerificationError)?; + if untrusted_advice.len() > 1 { + return Err(ProofVerifyError::SumcheckVerificationError); } - // Append trusted advice commitment to transcript + self.untrusted_advice_commitment = untrusted_advice.into_iter().next(); + if let Some(ref trusted_advice_commitment) = self.trusted_advice_commitment { - self.transcript - .append_serializable(b"trusted_advice", trusted_advice_commitment); + transcript.absorb_commitment(trusted_advice_commitment); } if let Some(trusted_bytecode) = self.preprocessing.shared.program.bytecode_commitments() { for commitment in &trusted_bytecode.commitments { - self.transcript - .append_serializable(b"bytecode_chunk_commit", commitment); + transcript.absorb_commitment(commitment); } } if self.preprocessing.shared.program.is_committed() { let trusted = self.preprocessing.shared.program.as_committed()?; - self.transcript.append_serializable( - b"program_image_commitment", - &trusted.program_image_commitment, - ); + transcript.absorb_commitment(&trusted.program_image_commitment); } - - self.verify_stage1() - .inspect_err(|e| tracing::error!("Stage 1: {e}"))?; - self.verify_stage2() - .inspect_err(|e| tracing::error!("Stage 2: {e}"))?; - self.verify_stage3() - .inspect_err(|e| tracing::error!("Stage 3: {e}"))?; - self.verify_stage4() - .inspect_err(|e| tracing::error!("Stage 4: {e}"))?; - self.verify_stage5() - .inspect_err(|e| tracing::error!("Stage 5: {e}"))?; - self.verify_stage6() - .inspect_err(|e| tracing::error!("Stage 6: {e}"))?; - self.verify_stage7() - .inspect_err(|e| tracing::error!("Stage 7: {e}"))?; - // Stage 8 (PCS) is not being transpiled in this version. - Ok(()) } - fn verify_stage1(&mut self) -> Result<(), ProofVerifyError> { - let (uni_skip_params, _uni_skip_challenge) = - verify_stage1_uni_skip::( - &self.proof.stage1_uni_skip_first_round_proof, + pub fn verify_stage1( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result<(), ProofVerifyError> { + let (uni_skip_params, _uni_skip_challenge, _zk_readback) = + verify_stage1_uni_skip::( + false, &self.spartan_key, &mut self.opening_accumulator, - &mut self.transcript, + transcript, )?; let spartan_outer_remaining = OuterRemainingSumcheckVerifier::new( self.spartan_key, - self.proof.trace_length, + self.proof_data.trace_length, &uni_skip_params, &self.opening_accumulator, ); - let instances: Vec<&dyn SumcheckInstanceVerifier> = - vec![&spartan_outer_remaining]; + let instances: Vec<&dyn SumcheckInstanceVerifier> = vec![&spartan_outer_remaining]; - let _r_stage1 = BatchedSumcheck::verify_standard::( - extract_clear_proof(&self.proof.stage1_sumcheck_proof), + let _r_stage1 = BatchedSumcheck::verify_standard::( instances, &mut self.opening_accumulator, - &mut self.transcript, + transcript, )?; Ok(()) } - fn verify_stage2(&mut self) -> Result<(), ProofVerifyError> { - let (uni_skip_params, _uni_skip_challenge) = - verify_stage2_uni_skip::( - &self.proof.stage2_uni_skip_first_round_proof, - &mut self.opening_accumulator, - &mut self.transcript, - )?; + pub fn verify_stage2( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result<(), ProofVerifyError> { + let (uni_skip_params, _uni_skip_challenge, _zk_readback) = + verify_stage2_uni_skip::(false, &mut self.opening_accumulator, transcript)?; let ram_read_write_checking = RamReadWriteCheckingVerifier::new( &self.opening_accumulator, - &mut self.transcript, + transcript, &self.one_hot_params, - self.proof.trace_length, - &self.proof.rw_config, + self.proof_data.trace_length, + &self.proof_data.rw_config, ); let spartan_product_virtual_remainder = ProductVirtualRemainderVerifier::new( - self.proof.trace_length, - uni_skip_params, + self.proof_data.trace_length, + uni_skip_params.clone(), &self.opening_accumulator, ); let instruction_claim_reduction = InstructionLookupsClaimReductionSumcheckVerifier::new( - self.proof.trace_length, + self.proof_data.trace_length, &self.opening_accumulator, - &mut self.transcript, + transcript, ); let ram_raf_evaluation = RamRafEvaluationSumcheckVerifier::new( &self.program_io.memory_layout, &self.one_hot_params, - self.proof.trace_length, - &self.proof.rw_config, + self.proof_data.trace_length, + &self.proof_data.rw_config, &self.opening_accumulator, ); let ram_output_check = OutputSumcheckVerifier::new( - self.proof.ram_K, + self.proof_data.ram_K, &self.program_io, - &mut self.transcript, - self.proof.trace_length, - &self.proof.rw_config, + transcript, + self.proof_data.trace_length, + &self.proof_data.rw_config, ); - let instances: Vec<&dyn SumcheckInstanceVerifier> = vec![ + let instances: Vec<&dyn SumcheckInstanceVerifier> = vec![ &ram_read_write_checking, &spartan_product_virtual_remainder, &instruction_claim_reduction, @@ -474,83 +417,83 @@ impl< &ram_output_check, ]; - let _r_stage2 = BatchedSumcheck::verify_standard::( - extract_clear_proof(&self.proof.stage2_sumcheck_proof), + let _r_stage2 = BatchedSumcheck::verify_standard::( instances, &mut self.opening_accumulator, - &mut self.transcript, + transcript, )?; Ok(()) } - fn verify_stage3(&mut self) -> Result<(), ProofVerifyError> { + pub fn verify_stage3( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result<(), ProofVerifyError> { let spartan_shift = ShiftSumcheckVerifier::new( - self.proof.trace_length.log_2(), + self.proof_data.trace_length.log_2(), &self.opening_accumulator, - &mut self.transcript, + transcript, ); let spartan_instruction_input = - InstructionInputSumcheckVerifier::new(&self.opening_accumulator, &mut self.transcript); + InstructionInputSumcheckVerifier::new(&self.opening_accumulator, transcript); let spartan_registers_claim_reduction = RegistersClaimReductionSumcheckVerifier::new( - self.proof.trace_length, + self.proof_data.trace_length, &self.opening_accumulator, - &mut self.transcript, + transcript, ); - let instances: Vec<&dyn SumcheckInstanceVerifier> = vec![ + let instances: Vec<&dyn SumcheckInstanceVerifier> = vec![ &spartan_shift, &spartan_instruction_input, &spartan_registers_claim_reduction, ]; - let _r_stage3 = BatchedSumcheck::verify_standard::( - extract_clear_proof(&self.proof.stage3_sumcheck_proof), + let _r_stage3 = BatchedSumcheck::verify_standard::( instances, &mut self.opening_accumulator, - &mut self.transcript, + transcript, )?; Ok(()) } - fn verify_stage4(&mut self) -> Result<(), ProofVerifyError> { + pub fn verify_stage4( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result<(), ProofVerifyError> { let registers_read_write_checking = RegistersReadWriteCheckingVerifier::new( - self.proof.trace_length, + self.proof_data.trace_length, &self.opening_accumulator, - &mut self.transcript, - &self.proof.rw_config, + transcript, + &self.proof_data.rw_config, ); verifier_accumulate_advice::( - self.proof.ram_K, + self.proof_data.ram_K, &self.program_io, - self.proof.untrusted_advice_commitment.is_some(), + self.untrusted_advice_commitment.is_some(), self.trusted_advice_commitment.is_some(), &mut self.opening_accumulator, ); if self.preprocessing.shared.program.is_committed() { - verifier_accumulate_program_image::(self.proof.ram_K, &mut self.opening_accumulator); + verifier_accumulate_program_image::( + self.proof_data.ram_K, + &mut self.opening_accumulator, + ); } // Domain-separate the batching challenge. - self.transcript.append_bytes(b"ram_val_check_gamma", &[]); - let ram_val_check_gamma: F = self.transcript.challenge_scalar::(); + let ram_val_check_gamma: F = transcript.challenge_field(); let initial_ram_state = if self.preprocessing.shared.program.is_full() { crate::zkvm::ram::gen_ram_initial_memory_state::( - self.proof.ram_K, - &self.preprocessing.shared.program.as_full().unwrap().ram, + self.proof_data.ram_K, + &self.preprocessing.shared.program.as_full()?.ram, &self.program_io, ) } else { - vec![0u64; self.proof.ram_K] + vec![0u64; self.proof_data.ram_K] }; let ram_preprocessing = if self.preprocessing.shared.program.is_full() { - self.preprocessing - .shared - .program - .as_full() - .unwrap() - .ram - .clone() + self.preprocessing.shared.program.as_full()?.ram.clone() } else { crate::zkvm::ram::RAMPreprocessing { min_bytecode_address: self.preprocessing.shared.program_meta.min_bytecode_address, @@ -567,75 +510,77 @@ impl< &initial_ram_state, &self.program_io, &ram_preprocessing, - self.proof.trace_length, - self.proof.ram_K, - &self.proof.rw_config, + self.proof_data.trace_length, + self.proof_data.ram_K, + &self.proof_data.rw_config, ram_val_check_gamma, &self.opening_accumulator, self.preprocessing.shared.program.is_committed(), ); - let instances: Vec<&dyn SumcheckInstanceVerifier> = + let instances: Vec<&dyn SumcheckInstanceVerifier> = vec![®isters_read_write_checking, &ram_val_check]; - let _r_stage4 = BatchedSumcheck::verify_standard::( - extract_clear_proof(&self.proof.stage4_sumcheck_proof), + let _r_stage4 = BatchedSumcheck::verify_standard::( instances, &mut self.opening_accumulator, - &mut self.transcript, + transcript, )?; Ok(()) } - fn verify_stage5(&mut self) -> Result<(), ProofVerifyError> { - let n_cycle_vars = self.proof.trace_length.log_2(); + pub fn verify_stage5( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result<(), ProofVerifyError> { + let n_cycle_vars = self.proof_data.trace_length.log_2(); let lookups_read_raf = InstructionReadRafSumcheckVerifier::new( n_cycle_vars, &self.one_hot_params, &self.opening_accumulator, - &mut self.transcript, + transcript, ); let ram_ra_reduction = RamRaClaimReductionSumcheckVerifier::new( - self.proof.trace_length, + self.proof_data.trace_length, &self.one_hot_params, &self.opening_accumulator, - &mut self.transcript, + transcript, ); let registers_val_evaluation = RegistersValEvaluationSumcheckVerifier::new(&self.opening_accumulator); - let instances: Vec<&dyn SumcheckInstanceVerifier> = vec![ + let instances: Vec<&dyn SumcheckInstanceVerifier> = vec![ &lookups_read_raf, &ram_ra_reduction, ®isters_val_evaluation, ]; - let _r_stage5 = BatchedSumcheck::verify_standard::( - extract_clear_proof(&self.proof.stage5_sumcheck_proof), + let _r_stage5 = BatchedSumcheck::verify_standard::( instances, &mut self.opening_accumulator, - &mut self.transcript, + transcript, )?; Ok(()) } - fn verify_stage6(&mut self) -> Result<(), ProofVerifyError> { - let _ = DoryGlobals::initialize_main_with_log_embedding( - self.one_hot_params.k_chunk, - self.proof.trace_length, - self.main_total_vars(), - Some(self.proof.dory_layout), - ); - let (bytecode_read_raf_params, booleanity_params) = self.verify_stage6a()?; - self.verify_stage6b(bytecode_read_raf_params, booleanity_params)?; + pub fn verify_stage6( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result<(), ProofVerifyError> { + let (bytecode_read_raf_params, booleanity_params) = self.verify_stage6a(transcript)?; + self.verify_stage6b(transcript, bytecode_read_raf_params, booleanity_params)?; Ok(()) } - fn verify_stage6a( + /// NOTE: the Dory main-embedding initialization lives here (not in + /// `verify_stage6`) so drivers that run 6a/6b individually (the transpiler, for + /// witness-naming context) get identical behavior to `verify_stage6`. + pub fn verify_stage6a( &mut self, + transcript: &mut impl VerifierFs, ) -> Result< ( BytecodeReadRafSumcheckParams, @@ -643,37 +588,42 @@ impl< ), ProofVerifyError, > { - let n_cycle_vars = self.proof.trace_length.log_2(); - let bytecode_read_raf = BytecodeReadRafAddressSumcheckVerifier::new::( + let _ = DoryGlobals::initialize_main_with_log_embedding( + self.one_hot_params.k_chunk, + self.proof_data.trace_length, + self.main_total_vars(), + Some(self.proof_data.dory_layout), + ); + let n_cycle_vars = self.proof_data.trace_length.log_2(); + let bytecode_read_raf = BytecodeReadRafAddressSumcheckVerifier::new( &self.preprocessing.shared.program, n_cycle_vars, &self.one_hot_params, &self.opening_accumulator, - &mut self.transcript, + transcript, ); - let booleanity_params = BooleanitySumcheckParams::new( + let booleanity = BooleanityAddressSumcheckVerifier::new(BooleanitySumcheckParams::new( n_cycle_vars, &self.one_hot_params, &self.opening_accumulator, - &mut self.transcript, - ); - let booleanity = BooleanityAddressSumcheckVerifier::new(booleanity_params.clone()); - - let instances: Vec<&dyn SumcheckInstanceVerifier> = + transcript, + )); + let instances: Vec<&dyn SumcheckInstanceVerifier> = vec![&bytecode_read_raf, &booleanity]; - let _r_stage6a = BatchedSumcheck::verify_standard::( - extract_clear_proof(&self.proof.stage6a_sumcheck_proof), + let _r_stage6a = BatchedSumcheck::verify_standard::( instances, &mut self.opening_accumulator, - &mut self.transcript, - )?; + transcript, + ) + .inspect_err(|err| tracing::error!("Stage 6a: {err}"))?; - Ok((bytecode_read_raf.into_params(), booleanity_params)) + Ok((bytecode_read_raf.into_params(), booleanity.into_params())) } - fn verify_stage6b( + pub fn verify_stage6b( &mut self, + transcript: &mut impl VerifierFs, bytecode_read_raf_params: BytecodeReadRafSumcheckParams, booleanity_params: BooleanitySumcheckParams, ) -> Result<(), ProofVerifyError> { @@ -682,30 +632,31 @@ impl< let booleanity = BooleanityCycleSumcheckVerifier::new(booleanity_params, &self.opening_accumulator); let ram_ra_virtual = RamRaVirtualSumcheckVerifier::new( - self.proof.trace_length, + self.proof_data.trace_length, &self.one_hot_params, &self.opening_accumulator, - &mut self.transcript, + transcript, ); let lookups_ra_virtual = LookupsRaSumcheckVerifier::new( &self.one_hot_params, &self.opening_accumulator, - &mut self.transcript, + transcript, ); let inc_reduction = IncClaimReductionSumcheckVerifier::new( - self.proof.trace_length, + self.proof_data.trace_length, &self.opening_accumulator, - &mut self.transcript, + transcript, ); - let main_total_vars = self.proof.trace_length.log_2() + self.one_hot_params.log_k_chunk; + let main_total_vars = + self.proof_data.trace_length.log_2() + self.one_hot_params.log_k_chunk; let precommitted_candidates = self.preprocessing.shared.precommitted_candidate_total_vars( self.preprocessing.shared.program.is_committed(), self.trusted_advice_commitment.is_some(), - self.proof.untrusted_advice_commitment.is_some(), + self.untrusted_advice_commitment.is_some(), ); let precommitted_scheduling_reference = - crate::zkvm::claim_reductions::PrecommittedClaimReduction::::scheduling_reference( + PrecommittedClaimReduction::::scheduling_reference( main_total_vars, &precommitted_candidates, ); @@ -718,7 +669,7 @@ impl< &self.opening_accumulator, )); } - if self.proof.untrusted_advice_commitment.is_some() { + if self.untrusted_advice_commitment.is_some() { self.advice_reduction_verifier_untrusted = Some(AdviceClaimReductionVerifier::new( AdviceKind::Untrusted, self.program_io.memory_layout.max_untrusted_advice_size as usize, @@ -726,7 +677,6 @@ impl< &self.opening_accumulator, )); } - if self.preprocessing.shared.program.is_committed() { let bytecode_chunk_count = self.preprocessing.shared.bytecode_chunk_count; let bytecode_reduction_params = BytecodeClaimReductionParams::new( @@ -735,7 +685,7 @@ impl< bytecode_chunk_count, precommitted_scheduling_reference, &self.opening_accumulator, - &mut self.transcript, + transcript, ); self.bytecode_reduction_verifier = Some(BytecodeClaimReductionVerifier::new( bytecode_reduction_params, @@ -750,7 +700,7 @@ impl< &self.program_io, self.preprocessing.shared.program_meta.min_bytecode_address, padded_len_words, - self.proof.ram_K, + self.proof_data.ram_K, precommitted_scheduling_reference, &self.opening_accumulator, ); @@ -764,7 +714,7 @@ impl< &self.opening_accumulator, ); - let mut instances: Vec<&dyn SumcheckInstanceVerifier> = vec![ + let mut instances: Vec<&dyn SumcheckInstanceVerifier> = vec![ &bytecode_read_raf, &booleanity, &ram_hamming_booleanity, @@ -785,32 +735,27 @@ impl< instances.push(reduction); } - let _r_stage6b = BatchedSumcheck::verify_standard::( - extract_clear_proof(&self.proof.stage6b_sumcheck_proof), + let _r_stage6b = BatchedSumcheck::verify_standard::( instances, &mut self.opening_accumulator, - &mut self.transcript, - )?; + transcript, + ) + .inspect_err(|err| tracing::error!("Stage 6b: {err}"))?; Ok(()) } - /// Stage 7: HammingWeight claim reduction verification. - fn verify_stage7(&mut self) -> Result<(), ProofVerifyError> { - // Create verifier for HammingWeight claim reduction. - // This sumcheck fuses HammingWeight + Address Reduction into a single degree-2 sumcheck. + pub fn verify_stage7( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result<(), ProofVerifyError> { let hw_verifier = HammingWeightClaimReductionVerifier::new( &self.one_hot_params, &self.opening_accumulator, - &mut self.transcript, + transcript, ); - let mut instances: Vec<&dyn SumcheckInstanceVerifier> = - vec![&hw_verifier]; - - // Phase transition: CycleVariables -> AddressVariables for advice verifiers. - // The advice verifiers were created in stage 6 with phase = CycleVariables. - // Now transition to AddressVariables phase for the address-binding rounds. + let mut instances: Vec<&dyn SumcheckInstanceVerifier> = vec![&hw_verifier]; if let Some(advice_reduction_verifier_trusted) = self.advice_reduction_verifier_trusted.as_mut() { @@ -841,12 +786,39 @@ impl< instances.push(advice_reduction_verifier_untrusted); } } + if let Some(bytecode_reduction_verifier) = self.bytecode_reduction_verifier.as_mut() { + if bytecode_reduction_verifier + .params + .precommitted + .num_address_phase_rounds() + > 0 + { + bytecode_reduction_verifier + .params + .transition_to_address_phase(&self.opening_accumulator); + instances.push(bytecode_reduction_verifier); + } + } + if let Some(program_image_reduction_verifier) = + self.program_image_reduction_verifier.as_mut() + { + if program_image_reduction_verifier + .params + .precommitted + .num_address_phase_rounds() + > 0 + { + program_image_reduction_verifier + .params + .transition_to_address_phase(&self.opening_accumulator); + instances.push(program_image_reduction_verifier); + } + } - let _r_stage7 = BatchedSumcheck::verify_standard::( - extract_clear_proof(&self.proof.stage7_sumcheck_proof), + let _r_stage7 = BatchedSumcheck::verify_standard::( instances, &mut self.opening_accumulator, - &mut self.transcript, + transcript, )?; Ok(()) diff --git a/jolt-core/src/zkvm/verifier.rs b/jolt-core/src/zkvm/verifier.rs index 5690c44da5..13252a2a0b 100644 --- a/jolt-core/src/zkvm/verifier.rs +++ b/jolt-core/src/zkvm/verifier.rs @@ -16,11 +16,11 @@ use crate::subprotocols::blindfold::{ }; use crate::subprotocols::sumcheck::BatchedSumcheck; #[cfg(feature = "zk")] -use crate::subprotocols::sumcheck::SumcheckInstanceProof; +use crate::subprotocols::sumcheck::ZkSumcheckReadback; #[cfg(feature = "zk")] use crate::subprotocols::sumcheck_verifier::SumcheckInstanceParams; #[cfg(feature = "zk")] -use crate::subprotocols::univariate_skip::UniSkipFirstRoundProofVariant; +use crate::subprotocols::univariate_skip::ZkUniSkipReadback; use crate::zkvm::bytecode::chunks::DEFAULT_COMMITTED_BYTECODE_CHUNK_COUNT; use crate::zkvm::bytecode::chunks::{ committed_lanes, is_valid_committed_bytecode_chunking_for_len, @@ -49,7 +49,7 @@ use crate::zkvm::{ PrecommittedClaimReduction, PrecommittedParams, ProgramImageClaimReductionParams, ProgramImageClaimReductionVerifier, RamRaClaimReductionSumcheckVerifier, }, - compute_final_opening_point, fiat_shamir_preamble, + compute_final_opening_point, fiat_shamir_instance, instruction_lookups::{ ra_virtual::RaSumcheckVerifier as LookupsRaSumcheckVerifier, read_raf_checking::InstructionReadRafSumcheckVerifier, @@ -89,7 +89,7 @@ use crate::{ }, sumcheck_verifier::SumcheckInstanceVerifier, }, - transcripts::Transcript, + transcript_msgs::{FsAbsorb, VerifierFs}, utils::{errors::ProofVerifyError, math::Math}, zkvm::witness::CommittedPolynomial, }; @@ -177,12 +177,8 @@ impl StageVerifyResult { } #[cfg(feature = "zk")] -fn batch_output_constraints< - F: JoltField, - T: Transcript, - A: AbstractVerifierOpeningAccumulator, ->( - instances: &[&dyn SumcheckInstanceVerifier], +fn batch_output_constraints>( + instances: &[&dyn SumcheckInstanceVerifier], ) -> Option { let constraints: Vec> = instances .iter() @@ -192,12 +188,8 @@ fn batch_output_constraints< } #[cfg(feature = "zk")] -fn batch_input_constraints< - F: JoltField, - T: Transcript, - A: AbstractVerifierOpeningAccumulator, ->( - instances: &[&dyn SumcheckInstanceVerifier], +fn batch_input_constraints>( + instances: &[&dyn SumcheckInstanceVerifier], ) -> InputClaimConstraint { let constraints: Vec = instances .iter() @@ -207,13 +199,9 @@ fn batch_input_constraints< } #[cfg(feature = "zk")] -fn scale_batching_coefficients< - F: JoltField, - T: Transcript, - A: AbstractVerifierOpeningAccumulator, ->( +fn scale_batching_coefficients>( batching_coefficients: &[F], - instances: &[&dyn SumcheckInstanceVerifier], + instances: &[&dyn SumcheckInstanceVerifier], ) -> Vec { let max_num_rounds = instances.iter().map(|i| i.num_rounds()).max().unwrap_or(0); batching_coefficients @@ -225,8 +213,60 @@ fn scale_batching_coefficients< }) .collect() } + +/// Shared ZK "finish" tail for a regular (non-uni-skip) stage: derives the batched +/// input/output BlindFold constraints and their challenge values from the stage's +/// `instances`, writes the stage's NARG read-back into `readback_slot`, and returns the +/// `StageVerifyResult`. Stages 3–7 (incl. 6a) share this verbatim — only the instance set, +/// challenges, and read-back slot differ; consolidating it stops the claim/constraint sync +/// (see CLAUDE.md) from drifting across copies. The two touched fields are passed as +/// separate `&mut`s (not `&mut self`) so the call coexists with the `instances` borrow, +/// which holds shared borrows of other `self` fields. +#[cfg(feature = "zk")] +fn finish_regular_zk_stage>( + opening_accumulator: &mut VerifierOpeningAccumulator, + instances: &[&dyn SumcheckInstanceVerifier>], + batching_coefficients: &[F], + challenges: Vec, + zk_sumcheck_readback: Option>, + readback_slot: &mut Option>, +) -> StageVerifyResult { + let regular_oc_ids = opening_accumulator.take_pending_claim_ids(); + let batched_output_constraint = batch_output_constraints(instances); + let batched_input_constraint = batch_input_constraints(instances); + let max_num_rounds = instances.iter().map(|i| i.num_rounds()).max().unwrap(); + let mut output_constraint_challenge_values: Vec = batching_coefficients.to_vec(); + let mut input_constraint_challenge_values: Vec = + scale_batching_coefficients(batching_coefficients, instances); + for instance in instances { + let num_rounds = instance.num_rounds(); + let offset = instance.round_offset(max_num_rounds); + let r_slice = &challenges[offset..offset + num_rounds]; + output_constraint_challenge_values.extend( + instance + .get_params() + .output_constraint_challenge_values(r_slice), + ); + input_constraint_challenge_values.extend( + instance + .get_params() + .input_constraint_challenge_values(opening_accumulator), + ); + } + let stage_result = StageVerifyResult::new( + challenges, + batched_output_constraint, + output_constraint_challenge_values, + batched_input_constraint, + input_constraint_challenge_values, + vec![regular_oc_ids], + ); + *readback_slot = zk_sumcheck_readback; + stage_result +} use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; use common::jolt_device::MemoryLayout; +use jolt_transcript::{verifier_transcript, DuplexSpongeInterface, TranscriptInit, VerifierState}; use tracer::JoltDevice; pub struct JoltVerifier< @@ -234,13 +274,19 @@ pub struct JoltVerifier< F: JoltField, C: JoltCurve, PCS: CommitmentScheme, - ProofTranscript: Transcript, + H: DuplexSpongeInterface, > { pub trusted_advice_commitment: Option, pub program_io: JoltDevice, - pub proof: JoltProof, + pub proof: JoltProof, + /// Witness-polynomial commitments, decoded from the NARG in `verify_inner` + /// (one `read_slice` frame) before stage 1. Populated at FS time, not at + /// construction (the NARG isn't read until `verify_inner`). + commitments: Vec, + /// Untrusted-advice commitment, decoded from the NARG presence frame + /// (length-0/1) in `verify_inner`. `None` if absent. + untrusted_advice_commitment: Option, pub preprocessing: &'a JoltVerifierPreprocessing, - pub transcript: ProofTranscript, pub opening_accumulator: VerifierOpeningAccumulator, /// The advice claim reduction sumcheck effectively spans two stages (6 and 7). /// Cache the verifier state here between stages. @@ -254,6 +300,18 @@ pub struct JoltVerifier< program_image_reduction_verifier: Option>, pub spartan_key: UniformSpartanKey, pub one_hot_params: OneHotParams, + /// ZK sumcheck values (round commitments, per-round degrees, output-claim commitments) + /// read back from the NARG during each stage's `BatchedSumcheck::verify`, indexed by + /// stage (0 = stage1 … 7 = stage7, where 5 = stage6a and 6 = stage6b). Since the proof + /// structs are now data-free, stage 8 (BlindFold) is fed from here instead. Populated + /// only in ZK mode. + #[cfg(feature = "zk")] + zk_sumcheck_readback: Vec>>, + /// ZK uni-skip values (commitment, degree, output-claim commitments) read back from the + /// NARG during stages 1–2, indexed by uni-skip stage (0 = stage1, 1 = stage2). Populated + /// only in ZK mode. + #[cfg(feature = "zk")] + zk_uniskip_readback: Vec>>, } #[derive(Clone, Debug)] @@ -268,8 +326,10 @@ impl< F: JoltField, C: JoltCurve, PCS: CommitmentScheme + ZkEvalCommitment, - ProofTranscript: Transcript, - > JoltVerifier<'a, F, C, PCS, ProofTranscript> + H: TranscriptInit + Default, + > JoltVerifier<'a, F, C, PCS, H> +where + for<'b> VerifierState<'b, H>: VerifierFs, { #[inline] fn main_total_vars(&self) -> usize { @@ -280,17 +340,17 @@ impl< self.preprocessing.shared.precommitted_candidate_total_vars( self.preprocessing.shared.program.is_committed(), self.trusted_advice_commitment.is_some(), - self.proof.untrusted_advice_commitment.is_some(), + self.untrusted_advice_commitment.is_some(), ), ) } pub fn new( preprocessing: &'a JoltVerifierPreprocessing, - proof: JoltProof, + proof: JoltProof, mut program_io: JoltDevice, trusted_advice_commitment: Option, - _debug_info: Option>, + _debug_info: Option>, ) -> Result { // Memory layout checks if program_io.memory_layout != preprocessing.shared.memory_layout { @@ -326,36 +386,29 @@ impl< .map_or(0, |pos| pos + 1), ); - let zk_mode = proof.verify_zk_consistency()?; - #[cfg(test)] - #[allow(unused_mut)] - let mut opening_accumulator = - VerifierOpeningAccumulator::new(proof.trace_length.log_2(), zk_mode); - #[cfg(not(test))] + let zk_mode = proof.zk_mode; + // `zk_mode` is an attacker-controlled proof field, but the verifier's ability to + // check a given mode is fixed at compile time: a `zk` build's `JoltProof` has no + // `opening_claims` field (so non-ZK proofs cannot be verified), and a non-`zk` + // build has no BlindFold code (so ZK proofs cannot be verified). Reject the + // mismatch here, explicitly, instead of failing somewhere downstream with an + // empty opening accumulator or a missing-stage error. + if zk_mode != cfg!(feature = "zk") { + return Err(ProofVerifyError::ZkModeMismatch); + } #[allow(unused_mut)] let mut opening_accumulator = VerifierOpeningAccumulator::new(proof.trace_length.log_2(), zk_mode); #[cfg(not(feature = "zk"))] - { - use crate::poly::opening_proof::{OpeningPoint, BIG_ENDIAN}; - for (id, (_, claim)) in &proof.opening_claims.0 { - let dummy_point = OpeningPoint::::new(vec![]); - opening_accumulator - .openings - .insert(*id, (dummy_point, *claim)); - } - } - - #[cfg(test)] - let mut transcript = ProofTranscript::new(b"Jolt"); - #[cfg(not(test))] - let transcript = ProofTranscript::new(b"Jolt"); + opening_accumulator.preseed_structural_claims(&proof.opening_claims.0); + // The verifier transcript (VerifierState) borrows the proof's NARG, so it is + // built locally in `verify_inner`, not stored as a field (D3). NARG consistency + // is inherent (the verifier replays the prover's NARG in lock-step). #[cfg(test)] { if let Some(debug_info) = _debug_info { - transcript.compare_to(debug_info.transcript); opening_accumulator.compare_to(debug_info.opening_accumulator); } } @@ -396,8 +449,9 @@ impl< trusted_advice_commitment, program_io, proof, + commitments: Vec::new(), + untrusted_advice_commitment: None, preprocessing, - transcript, opening_accumulator, advice_reduction_verifier_trusted: None, advice_reduction_verifier_untrusted: None, @@ -405,6 +459,10 @@ impl< program_image_reduction_verifier: None, spartan_key, one_hot_params, + #[cfg(feature = "zk")] + zk_sumcheck_readback: (0..8).map(|_| None).collect(), + #[cfg(feature = "zk")] + zk_uniskip_readback: (0..2).map(|_| None).collect(), }) } @@ -437,8 +495,11 @@ impl< let _pprof_verify = pprof_scope!("verify"); let zk_mode = self.opening_accumulator.zk_mode; + // Build the verifier transcript locally over the proof's NARG (D3). A.1: the + // public statement is bound into the `instance` digest (`Blake2b(statement)`), + // recomputed from the proof's public tail — byte-identical to the prover (O1). let preprocessing_digest = self.preprocessing.shared.digest(); - fiat_shamir_preamble( + let instance = fiat_shamir_instance( &self.program_io, self.proof.ram_K, self.proof.trace_length, @@ -447,8 +508,10 @@ impl< &self.proof.one_hot_config, self.proof.dory_layout, &preprocessing_digest, - &mut self.transcript, ); + let narg = std::mem::take(&mut self.proof.narg); + let mut ts = verifier_transcript(b"Jolt", instance, H::default(), &narg); + let transcript = &mut ts; // Initialize DoryGlobals with the layout from the proof // This ensures the verifier uses the same layout as the prover @@ -459,58 +522,60 @@ impl< Some(self.proof.dory_layout), ); - // Append commitments to transcript - for commitment in &self.proof.commitments { - self.transcript - .append_serializable(b"commitment", commitment); - } - // Append untrusted advice commitment to transcript - if let Some(ref untrusted_advice_commitment) = self.proof.untrusted_advice_commitment { - self.transcript - .append_serializable(b"untrusted_advice", untrusted_advice_commitment); - } + // Read the witness-polynomial commitments back from the NARG as ONE frame + // (matching the prover's single `write_commitments`), absorbing them in the process. + self.commitments = transcript + .read_commitments() + .map_err(|_| ProofVerifyError::SumcheckVerificationError)?; + // Read the untrusted-advice presence frame (length-0/1 vec) and reconstruct + // the Option. The prover ALWAYS writes this frame, so the read position is the + // same in the Some and None cases (count-led on the Poseidon path). + let untrusted_advice: Vec = transcript + .read_commitments() + .map_err(|_| ProofVerifyError::SumcheckVerificationError)?; + // The presence frame is length 0 (None) or 1 (Some); reject any over-long frame + // rather than silently dropping extra entries via `.next()`. + if untrusted_advice.len() > 1 { + return Err(ProofVerifyError::SumcheckVerificationError); + } + self.untrusted_advice_commitment = untrusted_advice.into_iter().next(); // Append trusted advice commitment to transcript if let Some(ref trusted_advice_commitment) = self.trusted_advice_commitment { - self.transcript - .append_serializable(b"trusted_advice", trusted_advice_commitment); + transcript.absorb_commitment(trusted_advice_commitment); } if let Some(trusted_bytecode) = self.preprocessing.shared.program.bytecode_commitments() { for commitment in &trusted_bytecode.commitments { - self.transcript - .append_serializable(b"bytecode_chunk_commit", commitment); + transcript.absorb_commitment(commitment); } } if self.preprocessing.shared.program.is_committed() { let trusted = self.preprocessing.shared.program.as_committed()?; - self.transcript.append_serializable( - b"program_image_commitment", - &trusted.program_image_commitment, - ); + transcript.absorb_commitment(&trusted.program_image_commitment); } let (stage1_result, uniskip_challenge1) = self - .verify_stage1() + .verify_stage1(transcript) .inspect_err(|e| tracing::error!("Stage 1: {e}"))?; let (stage2_result, uniskip_challenge2) = self - .verify_stage2() + .verify_stage2(transcript) .inspect_err(|e| tracing::error!("Stage 2: {e}"))?; let stage3_result = self - .verify_stage3() + .verify_stage3(transcript) .inspect_err(|e| tracing::error!("Stage 3: {e}"))?; let stage4_result = self - .verify_stage4() + .verify_stage4(transcript) .inspect_err(|e| tracing::error!("Stage 4: {e}"))?; let stage5_result = self - .verify_stage5() + .verify_stage5(transcript) .inspect_err(|e| tracing::error!("Stage 5: {e}"))?; let (stage6a_result, stage6b_result) = self - .verify_stage6() + .verify_stage6(transcript) .inspect_err(|e| tracing::error!("Stage 6: {e}"))?; let stage7_result = self - .verify_stage7() + .verify_stage7(transcript) .inspect_err(|e| tracing::error!("Stage 7: {e}"))?; let stage8_data = self - .verify_stage8() + .verify_stage8(transcript) .inspect_err(|e| tracing::error!("Stage 8: {e}"))?; if zk_mode { @@ -600,6 +665,7 @@ impl< ]; self.verify_blindfold( + transcript, &sumcheck_challenges, uniskip_challenges, &stage_output_constraints, @@ -620,17 +686,25 @@ impl< return Err(ProofVerifyError::ZkFeatureRequired); } + // Soundness (malleability guard): the NARG must be fully consumed — reject any + // trailing/garbage bytes. Error paths above already returned before reaching here. + ts.check_eof() + .map_err(|_| ProofVerifyError::SumcheckVerificationError)?; Ok(()) } #[cfg_attr(not(feature = "zk"), allow(unused_variables))] - fn verify_stage1(&mut self) -> Result<(StageVerifyResult, F::Challenge), ProofVerifyError> { - let (uni_skip_params, uni_skip_challenge) = verify_stage1_uni_skip( - &self.proof.stage1_uni_skip_first_round_proof, - &self.spartan_key, - &mut self.opening_accumulator, - &mut self.transcript, - )?; + fn verify_stage1( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result<(StageVerifyResult, F::Challenge), ProofVerifyError> { + let (uni_skip_params, uni_skip_challenge, zk_uniskip_readback) = + verify_stage1_uni_skip::( + self.proof.zk_mode, + &self.spartan_key, + &mut self.opening_accumulator, + transcript, + )?; // Drain uniskip OC block IDs (pending_claims were drained inside verify_transcript) #[cfg(feature = "zk")] @@ -643,16 +717,16 @@ impl< &self.opening_accumulator, ); - let instances: Vec< - &dyn SumcheckInstanceVerifier>, - > = vec![&spartan_outer_remaining]; + let instances: Vec<&dyn SumcheckInstanceVerifier>> = + vec![&spartan_outer_remaining]; - let (batching_coefficients, r_stage1) = BatchedSumcheck::verify( - &self.proof.stage1_sumcheck_proof, - instances.clone(), - &mut self.opening_accumulator, - &mut self.transcript, - )?; + let (batching_coefficients, r_stage1, zk_sumcheck_readback) = + BatchedSumcheck::verify::( + self.proof.zk_mode, + instances.clone(), + &mut self.opening_accumulator, + transcript, + )?; #[cfg(feature = "zk")] { @@ -710,6 +784,9 @@ impl< vec![uniskip_oc_ids, regular_oc_ids], ); + self.zk_uniskip_readback[0] = zk_uniskip_readback; + self.zk_sumcheck_readback[0] = zk_sumcheck_readback; + Ok((stage_result, uni_skip_challenge)) } #[cfg(not(feature = "zk"))] @@ -722,19 +799,23 @@ impl< } #[cfg_attr(not(feature = "zk"), allow(unused_variables))] - fn verify_stage2(&mut self) -> Result<(StageVerifyResult, F::Challenge), ProofVerifyError> { - let (uni_skip_params, uni_skip_challenge) = verify_stage2_uni_skip( - &self.proof.stage2_uni_skip_first_round_proof, - &mut self.opening_accumulator, - &mut self.transcript, - )?; + fn verify_stage2( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result<(StageVerifyResult, F::Challenge), ProofVerifyError> { + let (uni_skip_params, uni_skip_challenge, zk_uniskip_readback) = + verify_stage2_uni_skip::( + self.proof.zk_mode, + &mut self.opening_accumulator, + transcript, + )?; #[cfg(feature = "zk")] let uniskip_oc_ids = self.opening_accumulator.take_pending_claim_ids(); let ram_read_write_checking = RamReadWriteCheckingVerifier::new( &self.opening_accumulator, - &mut self.transcript, + transcript, &self.one_hot_params, self.proof.trace_length, &self.proof.rw_config, @@ -749,7 +830,7 @@ impl< let instruction_claim_reduction = InstructionLookupsClaimReductionSumcheckVerifier::new( self.proof.trace_length, &self.opening_accumulator, - &mut self.transcript, + transcript, ); let ram_raf_evaluation = RamRafEvaluationSumcheckVerifier::new( @@ -763,14 +844,12 @@ impl< let ram_output_check = OutputSumcheckVerifier::new( self.proof.ram_K, &self.program_io, - &mut self.transcript, + transcript, self.proof.trace_length, &self.proof.rw_config, ); - let instances: Vec< - &dyn SumcheckInstanceVerifier>, - > = vec![ + let instances: Vec<&dyn SumcheckInstanceVerifier>> = vec![ &ram_read_write_checking, &spartan_product_virtual_remainder, &instruction_claim_reduction, @@ -778,12 +857,13 @@ impl< &ram_output_check, ]; - let (batching_coefficients, r_stage2) = BatchedSumcheck::verify( - &self.proof.stage2_sumcheck_proof, - instances.clone(), - &mut self.opening_accumulator, - &mut self.transcript, - )?; + let (batching_coefficients, r_stage2, zk_sumcheck_readback) = + BatchedSumcheck::verify::( + self.proof.zk_mode, + instances.clone(), + &mut self.opening_accumulator, + transcript, + )?; #[cfg(feature = "zk")] { @@ -832,6 +912,9 @@ impl< vec![uniskip_oc_ids, regular_oc_ids], ); + self.zk_uniskip_readback[1] = zk_uniskip_readback; + self.zk_sumcheck_readback[1] = zk_sumcheck_readback; + Ok((stage_result, uni_skip_challenge)) } #[cfg(not(feature = "zk"))] @@ -844,66 +927,46 @@ impl< } #[cfg_attr(not(feature = "zk"), allow(unused_variables))] - fn verify_stage3(&mut self) -> Result, ProofVerifyError> { + fn verify_stage3( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result, ProofVerifyError> { let spartan_shift = ShiftSumcheckVerifier::new( self.proof.trace_length.log_2(), &self.opening_accumulator, - &mut self.transcript, + transcript, ); let spartan_instruction_input = - InstructionInputSumcheckVerifier::new(&self.opening_accumulator, &mut self.transcript); + InstructionInputSumcheckVerifier::new(&self.opening_accumulator, transcript); let spartan_registers_claim_reduction = RegistersClaimReductionSumcheckVerifier::new( self.proof.trace_length, &self.opening_accumulator, - &mut self.transcript, + transcript, ); - let instances: Vec< - &dyn SumcheckInstanceVerifier>, - > = vec![ + let instances: Vec<&dyn SumcheckInstanceVerifier>> = vec![ &spartan_shift, &spartan_instruction_input, &spartan_registers_claim_reduction, ]; - let (batching_coefficients, r_stage3) = BatchedSumcheck::verify( - &self.proof.stage3_sumcheck_proof, - instances.clone(), - &mut self.opening_accumulator, - &mut self.transcript, - )?; + let (batching_coefficients, r_stage3, zk_sumcheck_readback) = + BatchedSumcheck::verify::( + self.proof.zk_mode, + instances.clone(), + &mut self.opening_accumulator, + transcript, + )?; #[cfg(feature = "zk")] { - let regular_oc_ids = self.opening_accumulator.take_pending_claim_ids(); - let batched_output_constraint = batch_output_constraints(&instances); - let batched_input_constraint = batch_input_constraints(&instances); - let max_num_rounds = instances.iter().map(|i| i.num_rounds()).max().unwrap(); - let mut output_constraint_challenge_values: Vec = batching_coefficients.clone(); - let mut input_constraint_challenge_values: Vec = - scale_batching_coefficients(&batching_coefficients, &instances); - for instance in &instances { - let num_rounds = instance.num_rounds(); - let offset = instance.round_offset(max_num_rounds); - let r_slice = &r_stage3[offset..offset + num_rounds]; - output_constraint_challenge_values.extend( - instance - .get_params() - .output_constraint_challenge_values(r_slice), - ); - input_constraint_challenge_values.extend( - instance - .get_params() - .input_constraint_challenge_values(&self.opening_accumulator), - ); - } - Ok(StageVerifyResult::new( + Ok(finish_regular_zk_stage( + &mut self.opening_accumulator, + &instances, + &batching_coefficients, r_stage3, - batched_output_constraint, - output_constraint_challenge_values, - batched_input_constraint, - input_constraint_challenge_values, - vec![regular_oc_ids], + zk_sumcheck_readback, + &mut self.zk_sumcheck_readback[2], )) } #[cfg(not(feature = "zk"))] @@ -913,17 +976,20 @@ impl< } #[cfg_attr(not(feature = "zk"), allow(unused_variables))] - fn verify_stage4(&mut self) -> Result, ProofVerifyError> { + fn verify_stage4( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result, ProofVerifyError> { let registers_read_write_checking = RegistersReadWriteCheckingVerifier::new( self.proof.trace_length, &self.opening_accumulator, - &mut self.transcript, + transcript, &self.proof.rw_config, ); verifier_accumulate_advice::>( self.proof.ram_K, &self.program_io, - self.proof.untrusted_advice_commitment.is_some(), + self.untrusted_advice_commitment.is_some(), self.trusted_advice_commitment.is_some(), &mut self.opening_accumulator, ); @@ -931,8 +997,7 @@ impl< verifier_accumulate_program_image::(self.proof.ram_K, &mut self.opening_accumulator); } // Domain-separate the batching challenge. - self.transcript.append_bytes(b"ram_val_check_gamma", &[]); - let ram_val_check_gamma: F = self.transcript.challenge_scalar::(); + let ram_val_check_gamma: F = transcript.challenge_field(); let initial_ram_state = if self.preprocessing.shared.program.is_full() { crate::zkvm::ram::gen_ram_initial_memory_state::( self.proof.ram_K, @@ -968,48 +1033,26 @@ impl< self.preprocessing.shared.program.is_committed(), ); - let instances: Vec< - &dyn SumcheckInstanceVerifier>, - > = vec![®isters_read_write_checking, &ram_val_check]; + let instances: Vec<&dyn SumcheckInstanceVerifier>> = + vec![®isters_read_write_checking, &ram_val_check]; - let (batching_coefficients, r_stage4) = BatchedSumcheck::verify( - &self.proof.stage4_sumcheck_proof, - instances.clone(), - &mut self.opening_accumulator, - &mut self.transcript, - )?; + let (batching_coefficients, r_stage4, zk_sumcheck_readback) = + BatchedSumcheck::verify::( + self.proof.zk_mode, + instances.clone(), + &mut self.opening_accumulator, + transcript, + )?; #[cfg(feature = "zk")] { - let regular_oc_ids = self.opening_accumulator.take_pending_claim_ids(); - let batched_output_constraint = batch_output_constraints(&instances); - let batched_input_constraint = batch_input_constraints(&instances); - let max_num_rounds = instances.iter().map(|i| i.num_rounds()).max().unwrap(); - let mut output_constraint_challenge_values: Vec = batching_coefficients.clone(); - let mut input_constraint_challenge_values: Vec = - scale_batching_coefficients(&batching_coefficients, &instances); - for instance in &instances { - let num_rounds = instance.num_rounds(); - let offset = instance.round_offset(max_num_rounds); - let r_slice = &r_stage4[offset..offset + num_rounds]; - output_constraint_challenge_values.extend( - instance - .get_params() - .output_constraint_challenge_values(r_slice), - ); - input_constraint_challenge_values.extend( - instance - .get_params() - .input_constraint_challenge_values(&self.opening_accumulator), - ); - } - Ok(StageVerifyResult::new( + Ok(finish_regular_zk_stage( + &mut self.opening_accumulator, + &instances, + &batching_coefficients, r_stage4, - batched_output_constraint, - output_constraint_challenge_values, - batched_input_constraint, - input_constraint_challenge_values, - vec![regular_oc_ids], + zk_sumcheck_readback, + &mut self.zk_sumcheck_readback[3], )) } #[cfg(not(feature = "zk"))] @@ -1019,70 +1062,50 @@ impl< } #[cfg_attr(not(feature = "zk"), allow(unused_variables))] - fn verify_stage5(&mut self) -> Result, ProofVerifyError> { + fn verify_stage5( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result, ProofVerifyError> { let n_cycle_vars = self.proof.trace_length.log_2(); let lookups_read_raf = InstructionReadRafSumcheckVerifier::new( n_cycle_vars, &self.one_hot_params, &self.opening_accumulator, - &mut self.transcript, + transcript, ); let ram_ra_reduction = RamRaClaimReductionSumcheckVerifier::new( self.proof.trace_length, &self.one_hot_params, &self.opening_accumulator, - &mut self.transcript, + transcript, ); let registers_val_evaluation = RegistersValEvaluationSumcheckVerifier::new(&self.opening_accumulator); - let instances: Vec< - &dyn SumcheckInstanceVerifier>, - > = vec![ + let instances: Vec<&dyn SumcheckInstanceVerifier>> = vec![ &lookups_read_raf, &ram_ra_reduction, ®isters_val_evaluation, ]; - let (batching_coefficients, r_stage5) = BatchedSumcheck::verify( - &self.proof.stage5_sumcheck_proof, - instances.clone(), - &mut self.opening_accumulator, - &mut self.transcript, - )?; + let (batching_coefficients, r_stage5, zk_sumcheck_readback) = + BatchedSumcheck::verify::( + self.proof.zk_mode, + instances.clone(), + &mut self.opening_accumulator, + transcript, + )?; #[cfg(feature = "zk")] { - let regular_oc_ids = self.opening_accumulator.take_pending_claim_ids(); - let batched_output_constraint = batch_output_constraints(&instances); - let batched_input_constraint = batch_input_constraints(&instances); - let max_num_rounds = instances.iter().map(|i| i.num_rounds()).max().unwrap(); - let mut output_constraint_challenge_values: Vec = batching_coefficients.clone(); - let mut input_constraint_challenge_values: Vec = - scale_batching_coefficients(&batching_coefficients, &instances); - for instance in &instances { - let num_rounds = instance.num_rounds(); - let offset = instance.round_offset(max_num_rounds); - let r_slice = &r_stage5[offset..offset + num_rounds]; - output_constraint_challenge_values.extend( - instance - .get_params() - .output_constraint_challenge_values(r_slice), - ); - input_constraint_challenge_values.extend( - instance - .get_params() - .input_constraint_challenge_values(&self.opening_accumulator), - ); - } - Ok(StageVerifyResult::new( + Ok(finish_regular_zk_stage( + &mut self.opening_accumulator, + &instances, + &batching_coefficients, r_stage5, - batched_output_constraint, - output_constraint_challenge_values, - batched_input_constraint, - input_constraint_challenge_values, - vec![regular_oc_ids], + zk_sumcheck_readback, + &mut self.zk_sumcheck_readback[4], )) } #[cfg(not(feature = "zk"))] @@ -1094,6 +1117,7 @@ impl< #[cfg_attr(not(feature = "zk"), allow(unused_variables))] fn verify_stage6( &mut self, + transcript: &mut impl VerifierFs, ) -> Result<(StageVerifyResult, StageVerifyResult), ProofVerifyError> { let _ = DoryGlobals::initialize_main_with_log_embedding( self.one_hot_params.k_chunk, @@ -1102,67 +1126,50 @@ impl< Some(self.proof.dory_layout), ); let (bytecode_read_raf_params, booleanity_params, stage6a_result) = - self.verify_stage6a()?; - let stage6b_result = self.verify_stage6b(bytecode_read_raf_params, booleanity_params)?; + self.verify_stage6a(transcript)?; + let stage6b_result = + self.verify_stage6b(transcript, bytecode_read_raf_params, booleanity_params)?; Ok((stage6a_result, stage6b_result)) } - fn verify_stage6a(&mut self) -> Result, ProofVerifyError> { + #[cfg_attr(not(feature = "zk"), allow(unused_variables))] + fn verify_stage6a( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result, ProofVerifyError> { let n_cycle_vars = self.proof.trace_length.log_2(); let bytecode_read_raf = BytecodeReadRafAddressSumcheckVerifier::new( &self.preprocessing.shared.program, n_cycle_vars, &self.one_hot_params, &self.opening_accumulator, - &mut self.transcript, + transcript, ); let booleanity = BooleanityAddressSumcheckVerifier::new(BooleanitySumcheckParams::new( n_cycle_vars, &self.one_hot_params, &self.opening_accumulator, - &mut self.transcript, + transcript, )); - let instances: Vec< - &dyn SumcheckInstanceVerifier>, - > = vec![&bytecode_read_raf, &booleanity]; - let (_batching_coefficients, r_stage6a) = BatchedSumcheck::verify( - &self.proof.stage6a_sumcheck_proof, - instances.clone(), - &mut self.opening_accumulator, - &mut self.transcript, - ) - .inspect_err(|err| tracing::error!("Stage 6a: {err}"))?; + let instances: Vec<&dyn SumcheckInstanceVerifier>> = + vec![&bytecode_read_raf, &booleanity]; + let (_batching_coefficients, r_stage6a, zk_sumcheck_readback) = + BatchedSumcheck::verify::( + self.proof.zk_mode, + instances.clone(), + &mut self.opening_accumulator, + transcript, + ) + .inspect_err(|err| tracing::error!("Stage 6a: {err}"))?; #[cfg(feature = "zk")] { - let regular_oc_ids = self.opening_accumulator.take_pending_claim_ids(); - let batched_output_constraint = batch_output_constraints(&instances); - let batched_input_constraint = batch_input_constraints(&instances); - let max_num_rounds = instances.iter().map(|i| i.num_rounds()).max().unwrap(); - let mut output_constraint_challenge_values: Vec = _batching_coefficients.clone(); - let mut input_constraint_challenge_values: Vec = - scale_batching_coefficients(&_batching_coefficients, &instances); - for instance in &instances { - let num_rounds = instance.num_rounds(); - let offset = instance.round_offset(max_num_rounds); - let r_slice = &r_stage6a[offset..offset + num_rounds]; - output_constraint_challenge_values.extend( - instance - .get_params() - .output_constraint_challenge_values(r_slice), - ); - input_constraint_challenge_values.extend( - instance - .get_params() - .input_constraint_challenge_values(&self.opening_accumulator), - ); - } - let stage_result = StageVerifyResult::new( + let stage_result = finish_regular_zk_stage( + &mut self.opening_accumulator, + &instances, + &_batching_coefficients, r_stage6a, - batched_output_constraint, - output_constraint_challenge_values, - batched_input_constraint, - input_constraint_challenge_values, - vec![regular_oc_ids], + zk_sumcheck_readback, + &mut self.zk_sumcheck_readback[5], ); Ok(( bytecode_read_raf.into_params(), @@ -1183,6 +1190,7 @@ impl< #[cfg_attr(not(feature = "zk"), allow(unused_variables))] fn verify_stage6b( &mut self, + transcript: &mut impl VerifierFs, bytecode_read_raf_params: BytecodeReadRafSumcheckParams, booleanity_params: BooleanitySumcheckParams, ) -> Result, ProofVerifyError> { @@ -1194,24 +1202,24 @@ impl< self.proof.trace_length, &self.one_hot_params, &self.opening_accumulator, - &mut self.transcript, + transcript, ); let lookups_ra_virtual = LookupsRaSumcheckVerifier::new( &self.one_hot_params, &self.opening_accumulator, - &mut self.transcript, + transcript, ); let inc_reduction = IncClaimReductionSumcheckVerifier::new( self.proof.trace_length, &self.opening_accumulator, - &mut self.transcript, + transcript, ); let main_total_vars = self.proof.trace_length.log_2() + self.one_hot_params.log_k_chunk; let precommitted_candidates = self.preprocessing.shared.precommitted_candidate_total_vars( self.preprocessing.shared.program.is_committed(), self.trusted_advice_commitment.is_some(), - self.proof.untrusted_advice_commitment.is_some(), + self.untrusted_advice_commitment.is_some(), ); let precommitted_scheduling_reference = PrecommittedClaimReduction::::scheduling_reference( @@ -1228,7 +1236,7 @@ impl< &self.opening_accumulator, )); } - if self.proof.untrusted_advice_commitment.is_some() { + if self.untrusted_advice_commitment.is_some() { self.advice_reduction_verifier_untrusted = Some(AdviceClaimReductionVerifier::new( AdviceKind::Untrusted, self.program_io.memory_layout.max_untrusted_advice_size as usize, @@ -1244,7 +1252,7 @@ impl< bytecode_chunk_count, precommitted_scheduling_reference, &self.opening_accumulator, - &mut self.transcript, + transcript, ); self.bytecode_reduction_verifier = Some(BytecodeClaimReductionVerifier::new( bytecode_reduction_params, @@ -1273,9 +1281,7 @@ impl< &self.opening_accumulator, ); - let mut instances: Vec< - &dyn SumcheckInstanceVerifier>, - > = vec![ + let mut instances: Vec<&dyn SumcheckInstanceVerifier>> = vec![ &bytecode_read_raf, &booleanity, &ram_hamming_booleanity, @@ -1296,45 +1302,24 @@ impl< instances.push(reduction); } - let (batching_coefficients, r_stage6b) = BatchedSumcheck::verify( - &self.proof.stage6b_sumcheck_proof, - instances.clone(), - &mut self.opening_accumulator, - &mut self.transcript, - ) - .inspect_err(|err| tracing::error!("Stage 6b: {err}"))?; + let (batching_coefficients, r_stage6b, zk_sumcheck_readback) = + BatchedSumcheck::verify::( + self.proof.zk_mode, + instances.clone(), + &mut self.opening_accumulator, + transcript, + ) + .inspect_err(|err| tracing::error!("Stage 6b: {err}"))?; #[cfg(feature = "zk")] { - let regular_oc_ids = self.opening_accumulator.take_pending_claim_ids(); - let batched_output_constraint = batch_output_constraints(&instances); - let batched_input_constraint = batch_input_constraints(&instances); - let max_num_rounds = instances.iter().map(|i| i.num_rounds()).max().unwrap(); - let mut output_constraint_challenge_values: Vec = batching_coefficients.clone(); - let mut input_constraint_challenge_values: Vec = - scale_batching_coefficients(&batching_coefficients, &instances); - for instance in &instances { - let num_rounds = instance.num_rounds(); - let offset = instance.round_offset(max_num_rounds); - let r_slice = &r_stage6b[offset..offset + num_rounds]; - output_constraint_challenge_values.extend( - instance - .get_params() - .output_constraint_challenge_values(r_slice), - ); - input_constraint_challenge_values.extend( - instance - .get_params() - .input_constraint_challenge_values(&self.opening_accumulator), - ); - } - Ok(StageVerifyResult::new( + Ok(finish_regular_zk_stage( + &mut self.opening_accumulator, + &instances, + &batching_coefficients, r_stage6b, - batched_output_constraint, - output_constraint_challenge_values, - batched_input_constraint, - input_constraint_challenge_values, - vec![regular_oc_ids], + zk_sumcheck_readback, + &mut self.zk_sumcheck_readback[6], )) } #[cfg(not(feature = "zk"))] @@ -1347,6 +1332,7 @@ impl< #[allow(clippy::too_many_arguments)] fn verify_blindfold( &mut self, + transcript: &mut impl VerifierFs, sumcheck_challenges: &[Vec; 8], uniskip_challenges: [F::Challenge; 2], stage_output_constraints: &[Option; 8], @@ -1365,16 +1351,23 @@ impl< ) -> Result<(), ProofVerifyError> { // Build stage configurations including uni-skip rounds. // Uni-skip rounds are the first round of stages 1 and 2 (indices 0 and 1). - let stage_proofs = [ - &self.proof.stage1_sumcheck_proof, - &self.proof.stage2_sumcheck_proof, - &self.proof.stage3_sumcheck_proof, - &self.proof.stage4_sumcheck_proof, - &self.proof.stage5_sumcheck_proof, - &self.proof.stage6a_sumcheck_proof, - &self.proof.stage6b_sumcheck_proof, - &self.proof.stage7_sumcheck_proof, - ]; + // + // The ZK commitments/degrees that used to live in the (now data-free) proof structs + // were read back from the NARG during each stage's verification; we feed BlindFold + // from those caches (`zk_sumcheck_readback` / `zk_uniskip_readback`) instead. + let zk_sumcheck_readback: Vec<&ZkSumcheckReadback> = self + .zk_sumcheck_readback + .iter() + .map(|r| { + r.as_ref() + .ok_or(ProofVerifyError::SumcheckVerificationError) + }) + .collect::>()?; + let zk_uniskip_readback: Vec<&ZkUniSkipReadback> = self + .zk_uniskip_readback + .iter() + .map(|r| r.as_ref().ok_or(ProofVerifyError::UniSkipVerificationError)) + .collect::>()?; // Precompute power sums for uni-skip domains let outer_power_sums = LagrangeHelper::power_sums::< @@ -1392,15 +1385,10 @@ impl< let mut regular_first_round_indices: Vec = Vec::new(); // 8 elements for all stages let mut last_round_indices: Vec = Vec::new(); - for (stage_idx, proof) in stage_proofs.iter().enumerate() { + for (stage_idx, sumcheck_readback) in zk_sumcheck_readback.iter().enumerate() { // For stages 0 and 1 (Jolt stages 1 and 2), add uni-skip config first if stage_idx < 2 { - let uniskip_proof = if stage_idx == 0 { - &self.proof.stage1_uni_skip_first_round_proof - } else { - &self.proof.stage2_uni_skip_first_round_proof - }; - let poly_degree = uniskip_proof.poly_degree(); + let poly_degree = zk_uniskip_readback[stage_idx].poly_degree; let power_sums: Vec = if stage_idx == 0 { outer_power_sums.to_vec() @@ -1422,18 +1410,9 @@ impl< // Record first regular round index for its input constraint regular_first_round_indices.push(stage_configs.len()); - let round_poly_degrees = (0..proof.num_rounds()) - .map(|round_idx| match proof { - crate::subprotocols::sumcheck::SumcheckInstanceProof::Clear(std_proof) => { - std_proof.compressed_polys[round_idx] - .coeffs_except_linear_term - .len() - } - crate::subprotocols::sumcheck::SumcheckInstanceProof::Zk(zk_proof) => { - zk_proof.poly_degrees[round_idx] - } - }) - .collect::>(); + // The per-round degrees come from the NARG read-back (one per round); the round + // count is derived from its length, not from any (now data-free) proof struct. + let round_poly_degrees = sumcheck_readback.poly_degrees.clone(); stage_configs.push(StageConfig::new_chain_with_round_degrees( round_poly_degrees, )); @@ -1553,24 +1532,18 @@ impl< extra_constraint_challenges: stage8_data.constraint_coeffs.clone(), }; + // Assemble the BlindFold input from the NARG read-back caches, in the same stage + // order as before (uni-skip first for stages 0–1, then the stage's round commitments). let mut round_commitments: Vec = Vec::new(); let mut oc_row_commitments: Vec = Vec::new(); - for (stage_idx, proof) in stage_proofs.iter().enumerate() { + for (stage_idx, sumcheck_readback) in zk_sumcheck_readback.iter().enumerate() { if stage_idx < 2 { - let uniskip_proof = if stage_idx == 0 { - &self.proof.stage1_uni_skip_first_round_proof - } else { - &self.proof.stage2_uni_skip_first_round_proof - }; - if let UniSkipFirstRoundProofVariant::Zk(zk_uniskip) = uniskip_proof { - round_commitments.push(zk_uniskip.commitment); - oc_row_commitments.extend_from_slice(&zk_uniskip.output_claims_commitments); - } - } - if let SumcheckInstanceProof::Zk(zk_proof) = proof { - round_commitments.extend(zk_proof.round_commitments.iter().cloned()); - oc_row_commitments.extend_from_slice(&zk_proof.output_claims_commitments); + let zk_uniskip = zk_uniskip_readback[stage_idx]; + round_commitments.push(zk_uniskip.commitment); + oc_row_commitments.extend_from_slice(&zk_uniskip.output_claims_commitments); } + round_commitments.extend(sumcheck_readback.round_commitments.iter().cloned()); + oc_row_commitments.extend_from_slice(&sumcheck_readback.output_claims_commitments); } let builder = VerifierR1CSBuilder::new_with_extra( @@ -1600,14 +1573,9 @@ impl< PCS::eval_commitment_gens_verifier(&self.preprocessing.generators); let verifier = BlindFoldVerifier::<_, _>::new(&pedersen_generators, &r1cs, eval_commitment_gens); - self.transcript.append_label(b"BlindFold"); verifier - .verify( - &self.proof.blindfold_proof, - &verifier_input, - &mut self.transcript, - ) + .verify(verifier_input, transcript) .map_err(|e| ProofVerifyError::BlindFoldError(format!("{e:?}")))?; tracing::debug!( @@ -1619,18 +1587,20 @@ impl< } #[cfg_attr(not(feature = "zk"), allow(unused_variables))] - fn verify_stage7(&mut self) -> Result, ProofVerifyError> { + fn verify_stage7( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result, ProofVerifyError> { // Create verifier for HammingWeightClaimReduction // (r_cycle and r_addr_bool are extracted from Booleanity opening internally) let hw_verifier = HammingWeightClaimReductionVerifier::new( &self.one_hot_params, &self.opening_accumulator, - &mut self.transcript, + transcript, ); - let mut instances: Vec< - &dyn SumcheckInstanceVerifier>, - > = vec![&hw_verifier]; + let mut instances: Vec<&dyn SumcheckInstanceVerifier>> = + vec![&hw_verifier]; if let Some(advice_reduction_verifier_trusted) = self.advice_reduction_verifier_trusted.as_mut() { @@ -1692,44 +1662,23 @@ impl< } } - let (batching_coefficients, r_stage7) = BatchedSumcheck::verify( - &self.proof.stage7_sumcheck_proof, - instances.clone(), - &mut self.opening_accumulator, - &mut self.transcript, - )?; + let (batching_coefficients, r_stage7, zk_sumcheck_readback) = + BatchedSumcheck::verify::( + self.proof.zk_mode, + instances.clone(), + &mut self.opening_accumulator, + transcript, + )?; #[cfg(feature = "zk")] { - let regular_oc_ids = self.opening_accumulator.take_pending_claim_ids(); - let batched_output_constraint = batch_output_constraints(&instances); - let batched_input_constraint = batch_input_constraints(&instances); - let max_num_rounds = instances.iter().map(|i| i.num_rounds()).max().unwrap(); - let mut output_constraint_challenge_values: Vec = batching_coefficients.clone(); - let mut input_constraint_challenge_values: Vec = - scale_batching_coefficients(&batching_coefficients, &instances); - for instance in &instances { - let num_rounds = instance.num_rounds(); - let offset = instance.round_offset(max_num_rounds); - let r_slice = &r_stage7[offset..offset + num_rounds]; - output_constraint_challenge_values.extend( - instance - .get_params() - .output_constraint_challenge_values(r_slice), - ); - input_constraint_challenge_values.extend( - instance - .get_params() - .input_constraint_challenge_values(&self.opening_accumulator), - ); - } - Ok(StageVerifyResult::new( + Ok(finish_regular_zk_stage( + &mut self.opening_accumulator, + &instances, + &batching_coefficients, r_stage7, - batched_output_constraint, - output_constraint_challenge_values, - batched_input_constraint, - input_constraint_challenge_values, - vec![regular_oc_ids], + zk_sumcheck_readback, + &mut self.zk_sumcheck_readback[7], )) } #[cfg(not(feature = "zk"))] @@ -1738,7 +1687,10 @@ impl< }) } - fn verify_stage8(&mut self) -> Result, ProofVerifyError> { + fn verify_stage8( + &mut self, + transcript: &mut impl VerifierFs, + ) -> Result, ProofVerifyError> { let native_main_vars = self.proof.trace_length.log_2() + self.one_hot_params.log_k_chunk; let opening_point = compute_final_opening_point( &self.opening_accumulator, @@ -1875,10 +1827,8 @@ impl< // In non-ZK mode, absorb claims before sampling gamma for Fiat-Shamir binding. // In ZK mode, claims are secret; binding comes from BlindFold constraints instead. #[cfg(not(feature = "zk"))] - self.transcript.append_scalars(b"rlc_claims", &claims); - let gamma_powers: Vec = self - .transcript - .challenge_scalar_powers(polynomial_claims.len()); + transcript.absorb_scalars(&claims); + let gamma_powers: Vec = transcript.challenge_powers(polynomial_claims.len()); let constraint_coeffs: Vec = gamma_powers .iter() .zip(&scaling_factors) @@ -1912,16 +1862,13 @@ impl< // Build commitments map let mut commitments_map = HashMap::new(); let expected_polynomials = all_committed_polynomials(&self.one_hot_params); - if expected_polynomials.len() != self.proof.commitments.len() { + if expected_polynomials.len() != self.commitments.len() { return Err(ProofVerifyError::InvalidInputLength( expected_polynomials.len(), - self.proof.commitments.len(), + self.commitments.len(), )); } - for (polynomial, commitment) in expected_polynomials - .into_iter() - .zip(&self.proof.commitments) - { + for (polynomial, commitment) in expected_polynomials.into_iter().zip(&self.commitments) { commitments_map.insert(polynomial, commitment.clone()); } @@ -1935,7 +1882,7 @@ impl< commitments_map.insert(CommittedPolynomial::TrustedAdvice, commitment.clone()); } } - if let Some(ref commitment) = self.proof.untrusted_advice_commitment { + if let Some(ref commitment) = self.untrusted_advice_commitment { if state .polynomial_claims .iter() @@ -1978,7 +1925,7 @@ impl< PCS::verify( &self.proof.joint_opening_proof, &self.preprocessing.generators, - &mut self.transcript, + transcript, &opening_point.r, &F::zero(), &joint_commitment, @@ -1988,7 +1935,7 @@ impl< { let y_com: C::G1 = PCS::eval_commitment(&self.proof.joint_opening_proof) .ok_or(ProofVerifyError::InvalidOpeningProof)?; - bind_opening_inputs_zk::(&mut self.transcript, &opening_point.r, &y_com); + bind_opening_inputs_zk::(transcript, &opening_point.r, &y_com); } #[cfg(not(feature = "zk"))] { @@ -1998,13 +1945,13 @@ impl< PCS::verify( &self.proof.joint_opening_proof, &self.preprocessing.generators, - &mut self.transcript, + transcript, &opening_point.r, &joint_claim, &joint_commitment, )?; - bind_opening_inputs::(&mut self.transcript, &opening_point.r, &joint_claim); + bind_opening_inputs::(transcript, &opening_point.r, &joint_claim); } Ok(Stage8VerifyData { diff --git a/jolt-eval/src/guests/mod.rs b/jolt-eval/src/guests/mod.rs index dbd178688e..17252728ea 100644 --- a/jolt-eval/src/guests/mod.rs +++ b/jolt-eval/src/guests/mod.rs @@ -5,7 +5,7 @@ pub mod sha2_chain; use ark_bn254::Fr; use jolt_core::curve::Bn254Curve; use jolt_core::poly::commitment::dory::DoryCommitmentScheme; -use jolt_core::transcripts::Blake2bTranscript; +use jolt_transcript::Blake2b512; use common::constants::{DEFAULT_MAX_TRUSTED_ADVICE_SIZE, DEFAULT_MAX_UNTRUSTED_ADVICE_SIZE}; use common::jolt_device::MemoryConfig; @@ -20,7 +20,7 @@ pub use tracer::JoltDevice; pub type F = Fr; pub type C = Bn254Curve; pub type PCS = DoryCommitmentScheme; -pub type FS = Blake2bTranscript; +pub type FS = Blake2b512; pub type Proof = jolt_core::zkvm::proof_serialization::JoltProof; pub type ProverPreprocessing = jolt_core::zkvm::prover::JoltProverPreprocessing; diff --git a/jolt-eval/src/invariant/jolt_core_transcript_consistency.rs b/jolt-eval/src/invariant/jolt_core_transcript_consistency.rs new file mode 100644 index 0000000000..afaeecab71 --- /dev/null +++ b/jolt-eval/src/invariant/jolt_core_transcript_consistency.rs @@ -0,0 +1,243 @@ +//! `jolt_core_transcript_consistency` — mechanizes Invariant #1 of the +//! jolt-core transcript→spongefish migration below the full `muldiv` e2e. +//! +//! For a representative jolt-core-shaped operation sequence (`public_message` +//! of shared values + `prover_message` of proof values + +//! `verifier_message`/`challenge_128`), the prover produces a NARG byte-string +//! and an **independently-built verifier replays that NARG** +//! (`prover_message::()` reads in order), deriving **identical challenges** +//! and passing **`check_eof`**. A NARG with **trailing garbage** must be +//! **rejected** by `check_eof` (the soundness-critical malleability guard). +//! Finally, the same ops under a **different instance digest** must derive +//! **different challenges** — confirming the instance is bound into the sponge +//! (a symmetric instance-drop the replay check alone cannot see). +//! +//! This catches role/order drift and the malleability hole with a fast +//! `jolt-eval` test rather than only the end-to-end prover. It uses the +//! Blake2b512 sponge — jolt-core's default. + +use ark_bn254::Fr as ArkFr; +use jolt_field::Fr as JFr; +use spongefish::instantiations::Blake2b512; + +use jolt_transcript::{prover_transcript, verifier_transcript, BytesMsg, OptimizedChallenge}; + +use crate::invariant::transcript_symmetry::{Input, Op}; +use crate::invariant::{CheckError, Invariant, InvariantViolation}; + +const SESSION: &[u8] = b"jolt-eval/jolt-core-transcript-consistency/v1"; + +/// Runs the prover side, returning the NARG byte-string and the challenges it +/// derived in order. +fn prove(ops: &[Op], instance: [u8; 32]) -> (Vec, Vec) { + let mut prover = prover_transcript(SESSION, instance, Blake2b512::default()); + let mut challenges = Vec::new(); + for op in ops { + match op { + Op::PublicBytes(b) => prover.public_message(&BytesMsg(b.clone())), + Op::PublicScalar(f) => prover.public_message(&ArkFr::from(*f)), + Op::ProverBytes(b) => prover.prover_message(&BytesMsg(b.clone())), + Op::ProverScalar(f) => prover.prover_message(&ArkFr::from(*f)), + Op::Challenge => { + let c: ArkFr = prover.verifier_message(); + challenges.push(JFr::from(c)); + } + Op::OptimizedChallenge => challenges.push(prover.challenge_128()), + } + } + (prover.narg_string().to_vec(), challenges) +} + +/// Replays `ops` against `narg`: reads back every prover message in order, +/// checks each challenge equals `expected`, and finally asserts `check_eof`. +/// Returns `Err(reason)` on any read failure, challenge divergence, or a +/// non-empty NARG tail. +fn replay(ops: &[Op], instance: [u8; 32], narg: &[u8], expected: &[JFr]) -> Result<(), String> { + let mut verifier = verifier_transcript(SESSION, instance, Blake2b512::default(), narg); + let mut idx = 0usize; + for (op_idx, op) in ops.iter().enumerate() { + match op { + Op::PublicBytes(b) => verifier.public_message(&BytesMsg(b.clone())), + Op::PublicScalar(f) => verifier.public_message(&ArkFr::from(*f)), + Op::ProverBytes(want) => { + let got: BytesMsg = verifier + .prover_message() + .map_err(|e| format!("op {op_idx}: read BytesMsg failed: {e:?}"))?; + if got.as_slice() != want.as_slice() { + return Err(format!("op {op_idx}: ProverBytes round-trip mismatch")); + } + } + Op::ProverScalar(want) => { + let got: ArkFr = verifier + .prover_message() + .map_err(|e| format!("op {op_idx}: read Fr failed: {e:?}"))?; + if JFr::from(got) != *want { + return Err(format!("op {op_idx}: ProverScalar round-trip mismatch")); + } + } + Op::Challenge => { + let c: ArkFr = verifier.verifier_message(); + if JFr::from(c) != expected[idx] { + return Err(format!("op {op_idx}: Challenge diverged")); + } + idx += 1; + } + Op::OptimizedChallenge => { + if verifier.challenge_128() != expected[idx] { + return Err(format!("op {op_idx}: OptimizedChallenge diverged")); + } + idx += 1; + } + } + } + verifier + .check_eof() + .map_err(|e| format!("check_eof failed: {e:?}")) +} + +fn seed_corpus() -> Vec { + let scalar = JFr::from_le_bytes_mod_order(&[0x3Cu8; 32]); + + // A jolt-core-shaped multi-stage sequence: per "stage", absorb shared + // claims, write a round-poly frame, squeeze a run of optimized challenges + // (the `challenge_optimized_vec` shape), then flush shared claims. Dense + // absorb/write/challenge interleaving is what catches order transpositions + // the short seeds miss. + let mut staged = vec![Op::PublicBytes(b"statement".to_vec())]; + for stage in 0u64..8 { + staged.push(Op::PublicScalar(JFr::from(stage + 1))); + staged.push(Op::ProverBytes(vec![stage as u8; (stage % 5 + 1) as usize])); + // challenge_vec / challenge_optimized_vec shape: consecutive squeezes + // with no absorb in between. + for _ in 0..(stage % 3 + 1) { + staged.push(Op::OptimizedChallenge); + } + staged.push(Op::ProverScalar(JFr::from(stage.wrapping_mul(0x9E37_79B9)))); + staged.push(Op::Challenge); + staged.push(Op::PublicScalar(JFr::from(stage ^ 0xA5))); + } + + let op_sequences: Vec> = vec![ + vec![], + vec![Op::ProverScalar(scalar), Op::Challenge], + // A jolt-core-shaped stage: absorb shared statement, write proof + // payload, squeeze optimized + full challenges. + vec![ + Op::PublicBytes(b"statement".to_vec()), + Op::PublicScalar(scalar), + Op::ProverBytes(vec![1, 2, 3, 4, 5, 6, 7, 8]), + Op::OptimizedChallenge, + Op::ProverScalar(JFr::from(7u64)), + Op::Challenge, + Op::ProverBytes(vec![]), + Op::OptimizedChallenge, + ], + // Challenge runs with no interleaved absorbs (challenge_vec shape), + // mixing the optimized and plain variants back-to-back. + vec![ + Op::PublicScalar(scalar), + Op::Challenge, + Op::Challenge, + Op::Challenge, + Op::OptimizedChallenge, + Op::OptimizedChallenge, + Op::ProverBytes(b"tail".to_vec()), + Op::Challenge, + ], + // Consecutive NARG frames with no challenge between them (the + // commitments + advice-presence-frame shape at the top of verify). + vec![ + Op::ProverBytes(vec![0xAA; 48]), + Op::ProverBytes(vec![]), + Op::ProverBytes(vec![0xBB; 3]), + Op::OptimizedChallenge, + ], + staged, + ]; + + // Index 0 keeps the degenerate all-zeros instance; the rest are non-zero so + // the corpus exercises instance binding, not just the zero fixture. + op_sequences + .into_iter() + .enumerate() + .map(|(i, ops)| Input { + instance: [i as u8; 32], + ops, + }) + .collect() +} + +/// NARG-replay + malleability-guard invariant for the jolt-core transcript flow. +#[jolt_eval_macros::invariant(Test, Fuzz)] +#[derive(Default)] +pub struct JoltCoreTranscriptConsistencyInvariant; + +impl Invariant for JoltCoreTranscriptConsistencyInvariant { + type Setup = (); + type Input = Input; + + fn name(&self) -> &str { + "jolt_core_transcript_consistency" + } + + fn description(&self) -> String { + "A jolt-core-shaped NARG (public_message + prover_message + \ + verifier_message/challenge_128) replayed by an independently-built \ + verifier derives identical challenges and passes check_eof; a NARG \ + with trailing garbage is rejected by check_eof; and the same ops under \ + a different instance digest derive different challenges (the instance \ + is bound into the sponge)." + .to_string() + } + + fn setup(&self) {} + + fn check(&self, _setup: &(), input: Input) -> Result<(), CheckError> { + let (narg, challenges) = prove(&input.ops, input.instance); + + // Honest replay: identical challenges + clean check_eof. + replay(&input.ops, input.instance, &narg, &challenges).map_err(|reason| { + CheckError::Violation(InvariantViolation::with_details( + "honest NARG replay diverged or failed check_eof".to_string(), + reason, + )) + })?; + + // Malleability guard: appending a trailing byte must be rejected + // (either a read fails or, more typically, check_eof sees the unread + // tail). If it verifies cleanly, the proof bytes are malleable. + let mut tampered = narg.clone(); + tampered.push(0xFF); + if replay(&input.ops, input.instance, &tampered, &challenges).is_ok() { + return Err(CheckError::Violation(InvariantViolation::with_details( + "trailing-garbage NARG accepted — proof bytes are malleable".to_string(), + "check_eof returned Ok on narg ‖ 0xFF".to_string(), + ))); + } + + // Domain separation: the same ops under a *different* instance digest + // must derive *different* challenges — otherwise the instance isn't + // bound into the sponge. The replay/symmetry checks above cannot catch + // a symmetric instance-drop: if both sides ignored the instance they + // would still agree. (Skipped when the ops squeeze no challenges.) + if !challenges.is_empty() { + let mut other_instance = input.instance; + other_instance[0] ^= 0xFF; + let (_, other_challenges) = prove(&input.ops, other_instance); + if other_challenges == challenges { + return Err(CheckError::Violation(InvariantViolation::with_details( + "instance digest not bound — distinct instances derive identical challenges" + .to_string(), + "domain separation violated: prove(instance) == prove(instance ^ 0xFF)" + .to_string(), + ))); + } + } + + Ok(()) + } + + fn seed_corpus(&self) -> Vec { + seed_corpus() + } +} diff --git a/jolt-eval/src/invariant/mod.rs b/jolt-eval/src/invariant/mod.rs index b8f9a71df1..8a5a675532 100644 --- a/jolt-eval/src/invariant/mod.rs +++ b/jolt-eval/src/invariant/mod.rs @@ -1,4 +1,5 @@ pub mod field_mul_scalar; +pub mod jolt_core_transcript_consistency; #[cfg(test)] mod macro_tests; pub mod soundness; @@ -141,6 +142,9 @@ pub enum JoltInvariants { TranscriptConsistencyBlake2b(transcript_symmetry::TranscriptConsistencyBlake2bInvariant), TranscriptConsistencyKeccak(transcript_symmetry::TranscriptConsistencyKeccakInvariant), TranscriptConsistencyPoseidon(transcript_symmetry::TranscriptConsistencyPoseidonInvariant), + JoltCoreTranscriptConsistency( + jolt_core_transcript_consistency::JoltCoreTranscriptConsistencyInvariant, + ), SourceToJoltExpansionEquivalence( source_to_jolt_expansion_equivalence::SourceToJoltExpansionEquivalenceInvariant, ), @@ -156,6 +160,7 @@ macro_rules! dispatch { JoltInvariants::TranscriptConsistencyBlake2b($inv) => $body, JoltInvariants::TranscriptConsistencyKeccak($inv) => $body, JoltInvariants::TranscriptConsistencyPoseidon($inv) => $body, + JoltInvariants::JoltCoreTranscriptConsistency($inv) => $body, JoltInvariants::SourceToJoltExpansionEquivalence($inv) => $body, } }; @@ -177,6 +182,9 @@ impl JoltInvariants { Self::TranscriptConsistencyPoseidon( transcript_symmetry::TranscriptConsistencyPoseidonInvariant, ), + Self::JoltCoreTranscriptConsistency( + jolt_core_transcript_consistency::JoltCoreTranscriptConsistencyInvariant, + ), Self::SourceToJoltExpansionEquivalence( source_to_jolt_expansion_equivalence::SourceToJoltExpansionEquivalenceInvariant, ), diff --git a/jolt-eval/src/invariant/transcript_symmetry.rs b/jolt-eval/src/invariant/transcript_symmetry.rs index 6e78faf699..09e45dfdc3 100644 --- a/jolt-eval/src/invariant/transcript_symmetry.rs +++ b/jolt-eval/src/invariant/transcript_symmetry.rs @@ -1,19 +1,24 @@ //! `transcript_prover_verifier_consistency` — for each spongefish sponge, //! a `ProverState` / `VerifierState` pair driven by the same operation //! sequence must round-trip every prover message and produce the same -//! verifier challenges. +//! verifier challenges. The same ops under a different instance digest must +//! also derive different challenges, confirming the instance is bound into +//! the sponge (a symmetric instance-drop the symmetry check alone can't see). use arbitrary::{Arbitrary, Unstructured}; use ark_bn254::Fr as ArkFr; use jolt_field::Fr as JFr; +use rand::rngs::StdRng; use spongefish::instantiations::{Blake2b512, Keccak}; -use jolt_transcript::{prover_transcript, verifier_transcript, BytesMsg, PoseidonSponge}; +use jolt_transcript::{ + prover_transcript, verifier_transcript, BytesMsg, FieldFrameMsg, NativeChallenge, + OptimizedChallenge, PoseidonSponge, ProverState, RawBytesMsg, VerifierState, +}; use crate::invariant::{CheckError, Invariant, InvariantViolation}; const SESSION: &[u8] = b"jolt-eval/transcript-symmetry/v1"; -const INSTANCE_DIGEST: [u8; 32] = [0u8; 32]; /// One operation in the prover/verifier sequence. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] @@ -28,30 +33,43 @@ pub enum Op { ProverScalar(#[schemars(with = "[u8; 32]")] JFr), /// Both sides squeeze a verifier challenge. Challenge, + /// Both sides squeeze a 128-bit optimized challenge (`challenge_128`). + /// + /// Only the byte sponges (Blake2b/Keccak) implement this; the Poseidon + /// invariant filters it out (Poseidon uses full-field `challenge-254-bit` + /// and leaves `challenge_128` `unimplemented!()` — #1586 reviewer). + OptimizedChallenge, } /// Sequence of operations replayed in lockstep by both sides. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] pub struct Input { + /// 32-byte instance digest — the per-statement `DomainSeparator` binding. + /// Varied (not fixed to zero) so the corpus/fuzzer exercises instance + /// binding: an all-zeros-only fixture would mask a one-sided instance drop, + /// since `prover(0)` and `verifier(0-or-ignored)` agree regardless. + pub instance: [u8; 32], /// Operations to apply in order. pub ops: Vec, } impl<'a> Arbitrary<'a> for Input { fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + let instance: [u8; 32] = u.arbitrary()?; let n = u.int_in_range(0u8..=20)? as usize; let mut ops = Vec::with_capacity(n); for _ in 0..n { - let tag = u.int_in_range(0u8..=4)?; + let tag = u.int_in_range(0u8..=5)?; ops.push(match tag { 0 => Op::PublicBytes(arb_bytes(u)?), 1 => Op::PublicScalar(arb_scalar(u)?), 2 => Op::ProverBytes(arb_bytes(u)?), 3 => Op::ProverScalar(arb_scalar(u)?), - _ => Op::Challenge, + 4 => Op::Challenge, + _ => Op::OptimizedChallenge, }); } - Ok(Self { ops }) + Ok(Self { instance, ops }) } } @@ -65,13 +83,19 @@ fn arb_scalar(u: &mut Unstructured<'_>) -> arbitrary::Result { Ok(JFr::from_le_bytes_mod_order(&bytes)) } -fn run_check(input: &Input, build_sponge: impl Fn() -> H) -> Result<(), CheckError> +/// Runs the prover side over `input.ops` under `instance`, returning the NARG +/// byte-string and the challenges squeezed in order. +fn prover_run( + input: &Input, + instance: [u8; 32], + build_sponge: &impl Fn() -> H, +) -> (Vec, Vec) where H: spongefish::DuplexSpongeInterface, + ProverState: OptimizedChallenge, { - let mut prover = prover_transcript(SESSION, INSTANCE_DIGEST, build_sponge()); - let mut prover_challenges: Vec = Vec::new(); - + let mut prover = prover_transcript(SESSION, instance, build_sponge()); + let mut challenges: Vec = Vec::new(); for op in &input.ops { match op { Op::PublicBytes(b) => prover.public_message(&BytesMsg(b.clone())), @@ -80,13 +104,24 @@ where Op::ProverScalar(f) => prover.prover_message(&ArkFr::from(*f)), Op::Challenge => { let c: ArkFr = prover.verifier_message(); - prover_challenges.push(JFr::from(c)); + challenges.push(JFr::from(c)); + } + Op::OptimizedChallenge => { + challenges.push(prover.challenge_128()); } } } + (prover.narg_string().to_vec(), challenges) +} - let narg: Vec = prover.narg_string().to_vec(); - let mut verifier = verifier_transcript(SESSION, INSTANCE_DIGEST, build_sponge(), &narg); +fn run_check(input: &Input, build_sponge: impl Fn() -> H) -> Result<(), CheckError> +where + H: spongefish::DuplexSpongeInterface, + ProverState: OptimizedChallenge, + for<'a> VerifierState<'a, H>: OptimizedChallenge, +{ + let (narg, prover_challenges) = prover_run(input, input.instance, &build_sponge); + let mut verifier = verifier_transcript(SESSION, input.instance, build_sponge(), &narg); let mut challenge_idx = 0usize; for (op_idx, op) in input.ops.iter().enumerate() { @@ -116,12 +151,36 @@ where } challenge_idx += 1; } + Op::OptimizedChallenge => { + if verifier.challenge_128() != prover_challenges[challenge_idx] { + return Err(mismatch("OptimizedChallenge", op_idx)); + } + challenge_idx += 1; + } } } verifier .check_eof() .map_err(|e| violation("check_eof", input.ops.len(), e))?; + + // Domain separation: the same ops under a *different* instance digest must + // derive *different* challenges — otherwise the instance isn't bound into + // the sponge. The symmetry check above can't see a *symmetric* instance + // drop: if both sides ignored the instance they would still agree. + // (Skipped when the ops squeeze no challenges — nothing to compare.) + if !prover_challenges.is_empty() { + let mut other_instance = input.instance; + other_instance[0] ^= 0xFF; + let (_, other_challenges) = prover_run(input, other_instance, &build_sponge); + if other_challenges == prover_challenges { + return Err(CheckError::Violation(InvariantViolation::with_details( + "instance digest not bound — distinct instances derive identical challenges" + .to_string(), + "domain separation violated: prove(instance) == prove(instance ^ 0xFF)".to_string(), + ))); + } + } Ok(()) } @@ -139,59 +198,144 @@ fn mismatch(what: &str, op_idx: usize) -> CheckError { )) } +/// Poseidon (`U = Fr`) sibling of [`prover_run`]: the field-aligned sponge +/// has no `[u8]`-domain codecs, so the same ops go through the typed `Fr` +/// message vocabulary (spec §4.2/§4.3) — bytes under [`RawBytesMsg`]'s byte +/// rule, scalars as count-led [`FieldFrameMsg`] frames, challenges as +/// one-unit [`NativeChallenge`] squeezes. `Op::OptimizedChallenge` never +/// reaches here (the invariant maps it to `Op::Challenge`: Poseidon's +/// optimized challenge IS the full-field one). +fn prover_run_poseidon(input: &Input, instance: [u8; 32]) -> (Vec, Vec) { + let mut prover = prover_transcript(SESSION, instance, PoseidonSponge::new()); + let mut challenges: Vec = Vec::new(); + for op in &input.ops { + match op { + Op::PublicBytes(b) => prover.public_message(&RawBytesMsg(b.clone())), + Op::PublicScalar(f) => prover.public_message(&FieldFrameMsg(vec![ArkFr::from(*f)])), + Op::ProverBytes(b) => prover.prover_message(&RawBytesMsg(b.clone())), + Op::ProverScalar(f) => prover.prover_message(&FieldFrameMsg(vec![ArkFr::from(*f)])), + Op::Challenge | Op::OptimizedChallenge => { + let c: NativeChallenge = prover.verifier_message(); + challenges.push(JFr::from(c.0)); + } + } + } + (prover.narg_string().to_vec(), challenges) +} + +/// Poseidon (`U = Fr`) sibling of [`run_check`], over the typed `Fr` codecs. +fn run_check_poseidon(input: &Input) -> Result<(), CheckError> { + let (narg, prover_challenges) = prover_run_poseidon(input, input.instance); + let mut verifier = verifier_transcript(SESSION, input.instance, PoseidonSponge::new(), &narg); + let mut challenge_idx = 0usize; + + for (op_idx, op) in input.ops.iter().enumerate() { + match op { + Op::PublicBytes(b) => verifier.public_message(&RawBytesMsg(b.clone())), + Op::PublicScalar(f) => verifier.public_message(&FieldFrameMsg(vec![ArkFr::from(*f)])), + Op::ProverBytes(expected) => { + let got: RawBytesMsg = verifier + .prover_message() + .map_err(|e| violation("prover_message", op_idx, e))?; + if got.0.as_slice() != expected.as_slice() { + return Err(mismatch("ProverBytes round-trip", op_idx)); + } + } + Op::ProverScalar(expected) => { + let got: FieldFrameMsg = verifier + .prover_message() + .map_err(|e| violation("prover_message", op_idx, e))?; + if got.0 != vec![ArkFr::from(*expected)] { + return Err(mismatch("ProverScalar round-trip", op_idx)); + } + } + Op::Challenge | Op::OptimizedChallenge => { + let verifier_c: NativeChallenge = verifier.verifier_message(); + if JFr::from(verifier_c.0) != prover_challenges[challenge_idx] { + return Err(mismatch("Challenge", op_idx)); + } + challenge_idx += 1; + } + } + } + + verifier + .check_eof() + .map_err(|e| violation("check_eof", input.ops.len(), e))?; + + // Domain separation — same rationale as `run_check`. + if !prover_challenges.is_empty() { + let mut other_instance = input.instance; + other_instance[0] ^= 0xFF; + let (_, other_challenges) = prover_run_poseidon(input, other_instance); + if other_challenges == prover_challenges { + return Err(CheckError::Violation(InvariantViolation::with_details( + "instance digest not bound — distinct instances derive identical challenges" + .to_string(), + "domain separation violated: prove(instance) == prove(instance ^ 0xFF)".to_string(), + ))); + } + } + Ok(()) +} + fn seed_corpus_shared() -> Vec { let scalar = JFr::from_le_bytes_mod_order(&[0xABu8; 32]); let mut mixed_1k = Vec::with_capacity(1000); for i in 0..1000u64 { - mixed_1k.push(match i % 5 { + mixed_1k.push(match i % 6 { 0 => Op::PublicBytes(vec![i as u8; (i % 13) as usize]), 1 => Op::PublicScalar(JFr::from(i)), 2 => Op::ProverBytes(vec![(i ^ 0x5A) as u8; (i % 11) as usize]), 3 => Op::ProverScalar(JFr::from(i.wrapping_mul(2_654_435_761))), - _ => Op::Challenge, + 4 => Op::Challenge, + _ => Op::OptimizedChallenge, }); } - vec![ - Input { ops: vec![] }, - Input { - ops: vec![Op::Challenge], - }, - Input { - ops: vec![Op::PublicBytes(b"hello".to_vec())], - }, - Input { - ops: vec![Op::PublicScalar(scalar)], - }, - Input { - ops: vec![Op::ProverBytes(b"prover-data".to_vec())], - }, - Input { - ops: vec![Op::ProverScalar(scalar)], - }, - Input { - ops: vec![ - Op::PublicBytes(b"setup".to_vec()), - Op::ProverScalar(scalar), - Op::Challenge, - Op::ProverBytes(vec![1, 2, 3, 4, 5]), - Op::Challenge, - Op::PublicScalar(scalar), - Op::Challenge, - Op::ProverScalar(JFr::from(42u64)), - Op::Challenge, - Op::PublicBytes(vec![]), - ], - }, - Input { ops: mixed_1k }, - ] + let op_sequences: Vec> = vec![ + vec![], + vec![Op::Challenge], + vec![Op::PublicBytes(b"hello".to_vec())], + vec![Op::PublicScalar(scalar)], + vec![Op::ProverBytes(b"prover-data".to_vec())], + vec![Op::ProverScalar(scalar)], + vec![Op::OptimizedChallenge], + vec![ + Op::PublicBytes(b"setup".to_vec()), + Op::ProverScalar(scalar), + Op::Challenge, + Op::ProverBytes(vec![1, 2, 3, 4, 5]), + Op::OptimizedChallenge, + Op::PublicScalar(scalar), + Op::Challenge, + Op::ProverScalar(JFr::from(42u64)), + Op::OptimizedChallenge, + Op::PublicBytes(vec![]), + ], + mixed_1k, + ]; + + // Pair each op-sequence with a distinct instance digest: index 0 keeps the + // degenerate all-zeros case; the rest are non-zero so the corpus exercises + // instance binding (a zero-only fixture would mask a one-sided instance drop). + op_sequences + .into_iter() + .enumerate() + .map(|(i, ops)| Input { + instance: [i as u8; 32], + ops, + }) + .collect() } fn description_for(label: &str) -> String { format!( "spongefish ProverState/VerifierState pair ({label} sponge) replaying \ the same operation sequence must round-trip every prover message \ - and agree on every challenge." + and agree on every challenge; and the same ops under a different \ + instance digest must derive different challenges (the instance is \ + bound into the sponge)." ) } @@ -271,7 +415,24 @@ impl Invariant for TranscriptConsistencyPoseidonInvariant { fn setup(&self) {} fn check(&self, _setup: &(), input: Input) -> Result<(), CheckError> { - run_check::(&input, PoseidonSponge::new) + // Poseidon has no 128-bit `challenge_128` (it's `unimplemented!()`); its optimized + // challenge IS the full-field one. Map `OptimizedChallenge -> Challenge` (don't drop + // it) so the op still squeezes — keeping the domain-separation check alive. + // The field-aligned sponge (`U = Fr`) takes the typed-codec sibling of + // `run_check` (no `[u8]`-domain codecs exist for it). + let ops = input + .ops + .into_iter() + .map(|op| match op { + Op::OptimizedChallenge => Op::Challenge, + other => other, + }) + .collect(); + let input = Input { + instance: input.instance, + ops, + }; + run_check_poseidon(&input) } fn seed_corpus(&self) -> Vec { diff --git a/specs/jolt-core-transcript-migration.md b/specs/jolt-core-transcript-migration.md new file mode 100644 index 0000000000..cbf7b28a31 --- /dev/null +++ b/specs/jolt-core-transcript-migration.md @@ -0,0 +1,174 @@ +# Spec: jolt-core Transcript Migration to Spongefish Split-Trait Surface + +| Field | Value | +|-------------|--------------------------------| +| Author(s) | @shreyas-londhe | +| Created | 2026-06-03 | +| Status | implemented | +| PR | 1586 | + +## Summary + +jolt-core carries its own hand-rolled Fiat-Shamir transcript stack (`jolt-core/src/transcripts/`, ~1,370 LOC across `transcript.rs`, `blake2b.rs`, `keccak.rs`, `poseidon.rs`) that duplicates the functionality now provided by the `crates/jolt-transcript` spongefish crate. The `jolt-transcript` PR (`specs/jolt-transcript-spongefish.md`) deliberately left jolt-core untouched and shipped a temporary **legacy facade** (`jolt-transcript/src/legacy.rs`) so that the modular consumers (`jolt-sumcheck`, `jolt-openings`, `jolt-crypto`) and the newer `jolt-verifier` / `jolt-dory` crates could keep compiling against a jolt-core-shaped API. This spec covers follow-up PR (b): migrate jolt-core — and **all** remaining facade consumers (`jolt-verifier`, `jolt-dory`, `jolt-sumcheck`, `jolt-openings`, `jolt-crypto`) — onto jolt-transcript's spongefish-native **split-trait surface** (`ProverTranscript` / `VerifierTranscript` / `OptimizedChallenge`), delete jolt-core's duplicate transcript stack, and delete the legacy facade so that nothing in the workspace routes through anything but the spongefish-native split traits. The problem being solved is duplication and divergence: two transcript implementations that must be kept byte-consistent by hand, plus a facade explicitly marked for retirement. + +## Intent + +### Goal + +Replace jolt-core's in-house `crate::transcripts::Transcript` and its `Blake2bTranscript` / `KeccakTranscript` / `PoseidonTranscript` implementations with jolt-transcript's spongefish-native split-trait surface across every transcript callsite, migrate **all** remaining facade consumers (`jolt-verifier`, `jolt-dory`, `jolt-sumcheck`, `jolt-openings`, `jolt-crypto`) off the legacy `jolt_transcript::Transcript` facade onto the same split-trait surface, implement `OptimizedChallenge` for `PoseidonSponge`, and delete both `jolt-core/src/transcripts/` (~1,370 LOC) and `jolt-transcript/src/legacy.rs` (~287 LOC) so that a single spongefish-backed transcript surface — the split traits in `jolt-transcript` — is the only Fiat-Shamir API anywhere in the workspace. + +Key abstractions and boundaries: +- **Adopted API:** `jolt_transcript::ProverTranscript` (`public_message` / `prover_message` / `verifier_message` / `narg_string`), `jolt_transcript::VerifierTranscript` (`public_message` / `prover_message -> VerificationResult` / `verifier_message` / `check_eof`), and `jolt_transcript::OptimizedChallenge` (`challenge_128 -> Fr`). These are positional (no per-call label argument); domain separation lives in the one-time `DomainSeparator` at construction, and message identity comes from each message type's `Encoding` / `NargSerialize` impls. +- **Prover/verifier split:** the prover side accumulates a NARG byte-string via `prover_message`; the verifier side consumes that same byte-string and terminates with `check_eof`. This replaces jolt-core's single symmetric `Transcript` used on both sides. +- **New impl:** `OptimizedChallenge for ProverState` and the corresponding `VerifierState`, defining a 128-bit challenge for the field-native Poseidon sponge so jolt-core's optimized-challenge callsites compile for all three sponges. +- **Generic parameter:** the `ProofTranscript: Transcript` bound (e.g. `jolt-core/src/zkvm/prover.rs:175`) is re-pointed from `crate::transcripts::Transcript` to the jolt-transcript split traits. + +### Invariants + +1. **Prover/verifier transcript consistency (clean break).** For every proof, the verifier replaying the prover's emitted NARG byte-string must derive identical challenges and `check_eof` must succeed. This is the correctness gate — **not** byte-equality with the pre-migration jolt-core transcript. Proof bytes are expected to change. +2. **Soundness preserved.** The existing `jolt-eval` `soundness` invariant (RedTeam: for any deterministic guest program + input, only one `(output, panic)` pair is accepted by the verifier) must continue to hold. The transcript swap must not introduce a Fiat-Shamir weakness (e.g. missing domain separation, length-extension on `prover_message`). +3. **Per-sponge challenge width, prover/verifier-deterministic.** Challenge derivation is dispatched on the *sponge type*: Blake2b512/Keccak squeeze 128-bit optimized challenges (`OptimizedChallenge::challenge_u128`); PoseidonSponge squeezes genuine full-field `Fr` challenges and deliberately does **not** support `challenge_128` (per maintainer decision on #1586: truncation is costly for recursion and defeats Poseidon's purpose, so `transcript-poseidon` forces `challenge-254-bit` and Poseidon's `challenge_u128` stays `unimplemented!`). For every sponge, prover and verifier must produce the same challenge from the same transcript position. +4. **Single transcript implementation.** After this PR, no workspace crate references `crate::transcripts::*` (deleted) or `jolt_transcript::{Transcript, AppendToTranscript}` (legacy facade, deleted). + +`jolt-eval` framework: +- **Modify existing:** `jolt-eval/src/invariant/transcript_symmetry.rs` defines **three** structs — `TranscriptConsistencyBlake2bInvariant`, `...Keccak...`, `...Poseidon...` (lines 201/229/257), driven by a shared `Op` enum (line 20) whose variants the prover/verifier pair replays. The `Op` enum currently has **no optimized-challenge variant** (the Poseidon `Challenge` op squeezes a full-field `ArkFr` via `verifier_message`, `transcript_symmetry.rs:81-83`). Add a new shared `Op::OptimizedChallenge` variant that calls `challenge_128`, exercised by the Blake2b/Keccak structs; the Poseidon struct **filters it out** (Poseidon has no 128-bit challenge — maintainer decision on #1586; its `challenge_u128` is `unimplemented!`). This is a shared-enum change across the three invariants, not a one-line addition. +- **Add via `/new-invariant`:** a `jolt_core_transcript_roundtrip` invariant — for a small jolt-core proof artifact (or a representative sequence of `public_message` / `prover_message` / `verifier_message` / `challenge_128` calls mirroring jolt-core's preamble + one sumcheck round), the verifier-side replay of the prover's NARG string derives identical challenges and `check_eof` succeeds. This mechanizes Invariant #1 below the full `muldiv` e2e so a regression is caught by a fast `jolt-eval` test, not only by the end-to-end prover. + +### Non-Goals + +1. **Byte-identical Fiat-Shamir output.** This is an explicit clean break. The migrated transcript uses spongefish's duplex-sponge + NARG layout; serialized proofs produced before this PR will not verify after it, and that is acceptable. No backward-compatibility shim for old proof bytes. +2. **Downstream verifier regeneration.** The transpilable verifier (`jolt-core/src/zkvm/transpilable_verifier.rs`, consumed by the gnark transpiler in `transpiler/`) and the Lean extractor (`zklean-extractor/`) consume the transcript byte layout and will need regeneration — these are coordinated follow-up PRs, named as non-goals in the parent spec, and are out of scope here. (There is no Solidity verifier — maintainer correction on this spec's review.) As implemented, the `transpiler` crate is temporarily removed from the workspace and `transpilable_verifier` is gated behind a default-off `transpiler` feature until that regeneration lands. +3. **The a16z/dory transcript PR.** jolt-dory's bridge (`crates/jolt-dory/src/transcript.rs`) is migrated to the split-trait surface in this PR, but the separate `a16z/dory` PR that replaces dory's own `DoryTranscript` with a `crates/jolt-transcript` dependency remains a distinct follow-up. +4. **`JoltProof` structural changes** beyond the unavoidable consequence of the transcript swap (challenge values / proof bytes changing). No new proof fields, no reorganization of proof stages. +5. **Performance optimization of the sponge itself.** Spongefish's duplex-sponge performance is taken as-is; this PR does not tune the permutation or batching. +6. **Behavioral changes to `jolt-sumcheck` / `jolt-openings` / `jolt-crypto` / `jolt-verifier` / `jolt-dory`.** These crates ARE migrated off the facade onto the split-trait surface in this PR (see Architecture → "Facade-consumer migration"), because deleting `legacy.rs` requires it. The migration is an API/role-split transformation only — no change to each crate's protocol logic, proof structure, or test semantics beyond the transcript-call rewrites and the (clean-break) challenge values they now derive. + +## Evaluation + +### Acceptance Criteria + +- [ ] `jolt-core/src/transcripts/` is deleted in its entirety (`mod.rs`, `transcript.rs`, `blake2b.rs`, `keccak.rs`, `poseidon.rs`); `jolt-core/Cargo.toml` gains a dependency on `jolt-transcript`. +- [ ] `jolt-transcript/src/legacy.rs` is deleted. All its re-exports are removed from `jolt-transcript/src/lib.rs`: the `legacy::{...}` re-export block (`lib.rs:29-31`, including `SpongeTranscript`, `MAX_LABEL_LEN`), the `pub mod domain { Label, LabelWithCount, U64Word }` re-export (`lib.rs:37-39`, consumed by jolt-dory), and the `Blake2bTranscript`/`KeccakTranscript`/`PoseidonTranscript = SpongeTranscript<...>` aliases (`lib.rs:48+`). The split-trait surface is the only public transcript API remaining; jolt-sumcheck/jolt-openings/jolt-crypto (bound on the facade's `Transcript: Default + Clone + Sync + Send + 'static`) are migrated to it as part of this deletion. +- [ ] No workspace crate references `crate::transcripts::*`, `jolt_transcript::{Transcript, AppendToTranscript, SpongeTranscript, Label, LabelWithCount, U64Word, MAX_LABEL_LEN}`, or `jolt_transcript::domain::*`. Verified by `grep`. +- [ ] The `ProofTranscript` generic bound on jolt-core's prover/verifier (`jolt-core/src/zkvm/prover.rs:175` and the verifier counterpart) resolves to the jolt-transcript split traits. +- [x] Poseidon challenge width per the maintainer decision: `OptimizedChallenge::challenge_u128` stays `unimplemented!()` for `PoseidonSponge` (impl present only so generic bounds resolve); jolt-core's per-sponge `FsChallenge` gives Poseidon genuine full-field challenges and keeps Blake2b/Keccak at 128-bit. +- [ ] All facade consumers — `jolt-verifier`, `jolt-dory`, `jolt-sumcheck`, `jolt-openings`, `jolt-crypto` — import only the split-trait surface; no `T: Transcript` bound and no `AppendToTranscript` impl remains in any of them. +- [ ] jolt-core's `transcript-blake2b`/`-keccak`/`-poseidon` Cargo features forward to jolt-transcript's; `challenge-254-bit` is retained (it gates `JoltField::Challenge`, not `transcripts/`). +- [ ] `cargo nextest run -p jolt-core muldiv --cargo-quiet --features host` passes. +- [ ] `cargo nextest run -p jolt-core muldiv --cargo-quiet --features host,zk` passes. +- [ ] `cargo nextest run -p jolt-verifier -p jolt-blindfold -p jolt-eval --cargo-quiet` passes (the model-crate and invariant suites). +- [ ] `cargo nextest run -p jolt-sumcheck -p jolt-openings -p jolt-crypto --cargo-quiet` passes. +- [ ] `cargo clippy --all --features host -q --all-targets -- -D warnings` and `cargo clippy --all --features host,zk -q --all-targets -- -D warnings` are clean. +- [ ] The extended `transcript_prover_verifier_consistency` invariant and the new `jolt_core_transcript_roundtrip` invariant pass under `cargo nextest run -p jolt-eval`. + +### Testing Strategy + +Must continue passing: +- `cargo nextest run -p jolt-core muldiv --cargo-quiet --features host` and `--features host,zk` — the primary correctness gate. Because the migration is a clean break, this is where end-to-end prover/verifier consistency under the new transcript is proven (full protocol flow, Blake2b/Dory). +- `cargo nextest run -p jolt-verifier -p jolt-blindfold -p jolt-eval` — the model-crate, BlindFold, and invariant suites that exercise the split-trait surface and the soundness invariant. +- `cargo nextest run -p jolt-sumcheck -p jolt-openings -p jolt-crypto` — the modular consumers, proving the facade deletion did not strand them. +- Existing jolt-core transcript property tests (determinism, append-order sensitivity, label sensitivity) are deleted along with `jolt-core/src/transcripts/`; their intent is subsumed by jolt-transcript's own test suite (`crates/jolt-transcript/tests/`) plus the `jolt-eval` consistency invariant. + +New tests: +- `jolt-eval`: add an `Op::OptimizedChallenge` variant to the shared `Op` enum in `transcript_symmetry.rs:20`, exercised by the Blake2b/Keccak invariants and filtered out by the Poseidon invariant (Poseidon has no 128-bit challenge — maintainer decision on #1586), asserting `challenge_128` prover/verifier agreement on the byte sponges. +- `jolt-eval`: add `jolt_core_transcript_roundtrip` (`/new-invariant`) — prover NARG string replayed by verifier yields identical challenges + `check_eof` for a representative jolt-core-shaped call sequence, with `Test` and `Fuzz` targets. +- `jolt-transcript`: add an `OptimizedChallenge` Poseidon unit test in the existing `poseidon_tests.rs` (squeeze width and prover/verifier agreement) mirroring the Blake2b/Keccak cases. + +Mode coverage: both `--features host` and `--features host,zk` are required for the `muldiv` gate, since the ZK path (`prove_zk` / BlindFold) drives the transcript differently from the standard path. + +### Performance + +Expectation: **no measurable prover/verifier time regression**. Transcript absorb/squeeze is a negligible fraction of prover wall-clock relative to MSM and sumcheck binding, and the optimized 128-bit challenge path is preserved for all sponges (including the newly-added Poseidon impl), so no hot-path challenge widens. + +`jolt-eval` framework: +- **Moves existing objectives:** `lloc` decreases — deleting `jolt-core/src/transcripts/` (~1,370 LOC) and `jolt-transcript/src/legacy.rs` (~287 LOC) outweighs the callsite edits (~180 method calls across 27 files, mostly in-place method-name/positional changes, net small addition). Net expected: a measurable `lloc` reduction in `jolt-core/src/`. `cognitive_complexity_avg` and `halstead_bugs`: minor, expected flat-to-down. Verify via `cargo run -p jolt-eval --bin measure-objectives -- --no-bench`. +- **Performance objectives:** `prover_time_fibonacci_100`, `prover_time_sha2_chain_100`, `prover_time_secp256k1_ecdsa_verify` expected flat (within noise). "No regression" suffices; verify with the existing Criterion benches (`cargo bench -p jolt-eval --bench prover_time_fibonacci`). No new objective required. + +## Design + +### Architecture + +Affected modules: +- **Delete:** `jolt-core/src/transcripts/{mod,transcript,blake2b,keccak,poseidon}.rs`; `jolt-transcript/src/legacy.rs` and its `lib.rs` re-exports. +- **jolt-core/Cargo.toml feature rewiring:** jolt-core already declares `transcript-blake2b` / `transcript-keccak` / `transcript-poseidon` (`Cargo.toml:46-48`), and `transcript-poseidon` additionally enables `challenge-254-bit` (`Cargo.toml:45`); these currently gate `jolt-core/src/transcripts/`. Add `jolt-transcript.workspace = true` and **rewire each jolt-core feature to forward to jolt-transcript's identically-named feature** (`transcript-blake2b = ["jolt-transcript/transcript-blake2b"]`, etc.). **Keep `challenge-254-bit` unconditionally** — it does NOT gate `transcripts/`; it selects `JoltField::Challenge` (`Mont254BitChallenge` vs `MontU128Challenge`) in `field/ark.rs:44-49` and `field/tracked_ark.rs`, which is untouched by this PR. Do not remove it. Open question the implementer must resolve: whether `transcript-poseidon` should still enable `challenge-254-bit`, given the new `OptimizedChallenge for PoseidonSponge` squeezes a full `Fr` and truncates to 128 bits regardless of the field's `Challenge` width — i.e. confirm there is no soundness/consistency coupling between the Poseidon transcript's 128-bit truncation and `JoltField::Challenge` before changing that coupling. The default arm (no transcript feature) must keep resolving to Blake2b, matching `zkvm/mod.rs:323-368`. +- **Transcript construction (session / instance) — as implemented (supersedes the earlier `EmptyInstance` pin):** jolt-core constructs transcripts via `prover_transcript(b"Jolt", instance, sponge)` / `verifier_transcript(b"Jolt", instance, sponge, narg)` with `instance = fiat_shamir_instance(...)` — a 32-byte `Blake2b(CanonicalSerialize(statement))` digest over the full public statement (preprocessing digest, program I/O, `ram_K`, trace length, entry address, rw/one-hot configs, dory layout), per @mmaker's #1455 mandate. This **replaces** the per-field `fiat_shamir_preamble` scatter-absorbs on the production path (the preamble function is retained only for the cfg-gated, currently disabled `transpilable_verifier`, and must keep the same field set/order as `fiat_shamir_instance`). Soundness-critical: the digest must be byte-identical on prover and verifier — both sides call the one `fiat_shamir_instance` helper, the verifier recomputing it from the proof's public tail. +- **Generic bound:** `JoltCpuProver` (`jolt-core/src/zkvm/prover.rs:175`) and the verifier's `ProofTranscript: Transcript` bound (`jolt-core/src/zkvm/verifier.rs:229`, `:259`) re-point to the jolt-transcript split traits. The prover side is bound by `ProverTranscript`; the verifier side by `VerifierTranscript`. The type aliases `RV64IMACProver` / `RV64IMACVerifier` live at **`jolt-core/src/zkvm/mod.rs:323-368`** as **four `cfg`-gated pairs** (Blake2b default arm, Blake2b explicit, Keccak, Poseidon); **all four pairs** must be updated to the spongefish-backed `ProverState` / `VerifierState` instantiations. +- **Public vs prover message rule.** The split-trait surface distinguishes `public_message` (values both prover and verifier already know — they are absorbed by both sides and do NOT enter the NARG string) from `prover_message` (values only the prover holds — absorbed by the prover, serialized into the NARG string, and read back by the verifier via `prover_message::()`). Mapping jolt-core's symmetric `append_*` calls requires classifying each site: preamble/public-parameter appends (`fiat_shamir_preamble`, `zkvm/mod.rs:277-320`) become `public_message`; commitment / proof-scalar / sumcheck-round appends become `prover_message`. +- **Complete callsite mapping (~165–220 method calls across 27 files using `use crate::transcripts`; the exact figure depends on counting method — treat as an estimate, not a contract).** Every method on jolt-core's `Transcript` trait (`jolt-core/src/transcripts/transcript.rs`) must have a defined target: + + | jolt-core method | split-trait target | + |---|---| + | `append_bytes(label, b)`, `append_scalar(label, x)`, `append_scalars`, `append_commitment`, `append_commitments`, `append_serializable`, `append_commitments_serializable` | `public_message(&x)` if both sides know it, else `prover_message(&x)` (per the rule above). Label arg dropped; domain separation moves to the message type's `Encoding` impl and the construction-time `DomainSeparator`. | + | `append_u64(label, n)`, `append_label(label)` | `public_message(&n)` / folded into domain separation. `raw_append_*` (the low-level primitives behind the labeled methods) are removed, not mapped. | + | `challenge_scalar() -> F` | `verifier_message::()` (full-width field challenge). | + | `challenge_vector(n) -> Vec` | `n ×` `verifier_message::()`. | + | `challenge_u128() -> u128` | `verifier_message::()`. | + | `challenge_scalar_optimized() -> F::Challenge` | `challenge_128() -> Fr` (now available for all three sponges). | + | `challenge_vector_optimized(n)` | `n ×` `challenge_128()`. | + | `challenge_scalar_powers(n)` | one `verifier_message::()` then power expansion in a jolt-core helper. | + + Mapped for completeness but with **zero production callsites** (only internal to the deleted `transcripts/` impls and their tests, so no migration work): `challenge_scalar_128_bits` (helper behind `challenge_scalar_optimized`) → `challenge_128()`; `challenge_scalar_powers_optimized(n)` → one `challenge_128()` + power expansion. + + Clustering: `zkvm/mod.rs:277-320` (`fiat_shamir_preamble`), `zkvm/prover.rs`, `zkvm/verifier.rs`, `subprotocols/sumcheck.rs`, `subprotocols/univariate_skip.rs`, `poly/opening_proof.rs`, `poly/commitment/dory/commitment_scheme.rs`, `subprotocols/blindfold/*`. The optimized-challenge family (`challenge_scalar_optimized` / `challenge_vector_optimized`) is ~50 of the production calls — confirm by grep during implementation rather than trusting this estimate. +- **`JoltToDoryTranscript`:** the jolt-core bridge (`jolt-core/src/poly/commitment/dory/wrappers.rs:415`) is rewired to wrap a `ProverTranscript` / `VerifierTranscript` instead of jolt-core's `Transcript`. The jolt-dory bridge (`crates/jolt-dory/src/transcript.rs`) is moved from the facade onto the split-trait surface. +- **Poseidon challenge width — DECIDED (maintainer, #1586 review):** Poseidon does **not** get a 128-bit optimized challenge. Quoting @moodlezoup on this spec's review: "I think we can leave challenge_128 `unimplemented!` for Poseidon. For recursion, the truncation itself is costly and somewhat defeats the purpose of using Poseidon in the first place. So [...] `transcript-poseidon` should still enable `challenge-254-bit`." As implemented: `OptimizedChallenge::challenge_u128` for `PoseidonSponge` is `unimplemented!()` (kept so generic-over-sponge `OptimizedChallenge` bounds resolve, e.g. the jolt-eval symmetry invariant); jolt-core's `FsChallenge` is implemented **per sponge type** — Blake2b/Keccak route through the 16-byte `u128` squeeze, while the `PoseidonSponge` impls squeeze a genuine full-field `Fr` (`verifier_message::`) for both `challenge_field` and `challenge_optimized` (wrapped into the `#[repr(transparent)]` `Mont254BitChallenge`; `transcript-poseidon` force-enables `challenge-254-bit` via a `compile_error!` coupling). The modular `jolt_transcript::FsChallenge` vocabulary is likewise implemented only for the byte sponges, so instantiating a modular verifier over a Poseidon-backed state is a compile error. An earlier draft of this section pinned a low-128-bit truncation rule for Poseidon; that design was rejected in review and is superseded by the above. +- **Facade-consumer migration (jolt-verifier, jolt-sumcheck, jolt-openings, jolt-crypto).** This is a SECOND, structurally distinct migration that the facade deletion forces. These crates do **not** use jolt-core's `crate::transcripts::Transcript`; they bind the **facade** trait `jolt_transcript::Transcript` (`legacy.rs:31`), which has a different shape: an associated `type Challenge: TranscriptChallenge`, plus `challenge()`, `challenge_scalar()`, `challenge_vector(n)`, `challenge_scalar_powers(n)`, `append()`, `append_labeled(label, v)`, `append_values(label, vs)`, `append_bytes`, `state()`, and a companion `AppendToTranscript` trait. There are ~53 `T: Transcript` bound sites (jolt-verifier `verifier.rs:37,273,343` + all `stages/*`; jolt-sumcheck; jolt-openings; jolt-crypto) plus ~166 method usages. The transformation mirrors jolt-core's (role-split + positional), via this mapping: + + | facade construct | split-trait target | + |---|---| + | bound `T: Transcript` | role-split: prover-side code → `P: ProverTranscript (+ OptimizedChallenge where 128-bit challenges are used)`; verifier-side code → `V: VerifierTranscript`. The `Challenge = F` associated-type projection disappears — challenges are `Fr` from `verifier_message::()` / `challenge_128()`. | + | `append(&x)`, `append_labeled(label, &x)` | `public_message(&x)` or `prover_message(&x)` (public-vs-prover rule above). Label arg dropped → message type's `Encoding` / domain separator. | + | `append_values(label, &xs)` | length-prefixed: a `public_message`/`prover_message` of `xs.len()` then each element (or one message over the slice via its `Encoding`). | + | `append_bytes(&b)` | `prover_message(&BytesMsg(b))` / `public_message`. | + | `challenge()`, `challenge_scalar()` | `verifier_message::()`. | + | `challenge_vector(n)` | `n ×` `verifier_message::()`. | + | `challenge_scalar_powers(n)` | one `verifier_message::()` + power expansion. | + | `state() -> [u8;32]` | test/debug only; map to a spongefish state peek or delete the call (verify no production use). | + | `impl AppendToTranscript for F / Commitment / …` | delete the `AppendToTranscript` impls; the absorbed types implement spongefish `Encoding` / `NargSerialize` instead, consumed directly by `public_message` / `prover_message`. | + + Because the facade is used symmetrically in both prove and verify paths of these crates, each callsite must be classified by role exactly as in jolt-core. jolt-verifier specifically also has the `state()`-style helpers and labeled appends introduced during the upstream merge, rewritten accordingly. +- **`jolt-dory` bridge:** `crates/jolt-dory/src/transcript.rs` (uses `jolt_transcript::domain::{Label, LabelWithCount}` + `{AppendToTranscript, Transcript}`) is moved onto the split-trait surface, same transformation. + +Interaction sketch (prover → NARG → verifier): +``` +prover: DomainSeparator(label) -> ProverState + public_message(pp) ; prover_message(commitment) ; c = challenge_128() + ... -> narg_string() ─┐ + │ bytes +verifier: DomainSeparator(label) -> VerifierState(narg_string) <───┘ + public_message(pp) ; prover_message::()? ; c = challenge_128() + ... ; check_eof()? +``` + +### Alternatives Considered + +1. **Migrate jolt-core onto the legacy facade, not the split-trait surface.** Rejected: the facade is explicitly a temporary source-compatibility layer (`jolt-transcript/src/legacy.rs:5-6`, `lib.rs:12`) marked for retirement, and the parent spec states "future jolt-core work should use the split `ProverTranscript` / `VerifierTranscript` API and Spongefish NARG flow directly." Migrating to the facade would consolidate implementations but leave the positional/NARG end-state unreached and the facade undeletable. +2. **Staged: facade first, split-trait later.** Rejected for this spec: the split-trait surface requires a prover/verifier split that is awkward to introduce half-way (a transcript used symmetrically on both sides cannot partially emit a NARG string). Doing the facade step first would mean two large rewrites of the same ~180 callsites. A single migration to the end-state is fewer net changes. +3. **Preserve byte-identical Fiat-Shamir output via a compatibility shim.** Rejected: spongefish's duplex-sponge + NARG byte layout differs structurally from jolt-core's hash-chain transcript; a byte-for-byte shim would defeat the purpose of going spongefish-native and would be a large, fragile surface. The clean break is acceptable because downstream verifiers are coordinated follow-ups and the correctness gate is internal prover/verifier consistency. +4. **Leave Poseidon without `OptimizedChallenge` (Blake2b/Keccak only).** Rejected: jolt-core calls `challenge_scalar_optimized` and supports a Poseidon-backed prover via the `transcript-poseidon` feature (`jolt-core/src/zkvm/mod.rs`); omitting the impl would make the Poseidon configuration fail to compile or silently lose the optimized path. Implementing it keeps all three sponge configurations first-class. +5. **Big-bang vs prover-first/verifier-second split.** Single big-bang chosen: the split-trait prover/verifier separation is atomic by nature, and a prover-first PR would need a temporary bridge with FS bytes stabilizing only after the second PR. One reviewable (if large) diff is preferred. + +## Documentation + +- `CLAUDE.md` `## Architecture → jolt-core` and `transcripts/` references must be updated: the `transcripts/` submodule is deleted, and the description of the three type parameters (`ProofTranscript: Transcript`) must point at `jolt_transcript`'s split traits. The ZK-mode and opening-accumulator notes that mention transcript append behavior should be revised to the NARG flow. +- No `book/` user-facing changes are required: this is an internal refactor of the proving-system plumbing with no change to the guest-facing zkVM API. (If the book documents the proof/transcript byte format anywhere, that section needs a clean-break note; verify during implementation.) + +## Execution + +Suggested order: +1. Implement and unit-test `OptimizedChallenge for PoseidonSponge` in `jolt-transcript` first (smallest, self-contained, unblocks jolt-core's optimized callsites). Pin and document the 128-bit squeeze rule. +2. Add `jolt-transcript` as a jolt-core dependency; introduce the new `RV64IMACProver` / `RV64IMACVerifier` aliases over `ProverState`/`VerifierState` alongside the old ones to keep the tree compiling mid-migration. +3. Migrate jolt-core callsites cluster-by-cluster, starting from `fiat_shamir_preamble` (`zkvm/mod.rs`) then prover, verifier, sumcheck, univariate-skip, opening-proof, dory wrappers, blindfold. Keep prover and verifier edits paired so `muldiv` can be run as a continuous gate. +4. Rewire `JoltToDoryTranscript` (jolt-core) and migrate `jolt-dory` + `jolt-verifier` off the facade. +5. Delete `jolt-core/src/transcripts/` and `jolt-transcript/src/legacy.rs`; remove facade re-exports. +6. Add/extend the `jolt-eval` invariants; run both clippy modes and the full test matrix. + +Fiat-Shamir caution: because absorb order defines challenge values, migrate prover and verifier for each protocol stage together and run `muldiv` (host + zk) frequently — a transposed `prover_message` / `verifier_message` order is silently wrong until the e2e fails. The new `jolt_core_transcript_roundtrip` invariant exists to surface such transpositions faster than the full prover. + +## References + +- Parent spec: `specs/jolt-transcript-spongefish.md` — Non-Goals §1 names this as follow-up PR (b). +- PR #1455 (a16z/jolt) — the jolt-transcript spongefish port; maintainer guidance (@moodlezoup, 2026-04-21) requesting a staged rollout with jolt-core integration deferred. +- spongefish: https://github.com/arkworks-rs/spongefish — duplex-sponge Fiat-Shamir, NARG flow. +- `jolt-transcript/src/{prover,verifier,setup,codec}.rs` — the split-trait surface this migration targets. +- `jolt-eval/README.md` — invariant/objective framework used for the consistency invariants and LLOC objective. diff --git a/transpiler/Cargo.toml b/transpiler/Cargo.toml index 93227e94da..8c4318bcad 100644 --- a/transpiler/Cargo.toml +++ b/transpiler/Cargo.toml @@ -14,25 +14,33 @@ serde_json = "1.0" clap = { version = "4.6", features = ["derive"] } rand_core.workspace = true -jolt-core = { path = "../jolt-core" } +rayon.workspace = true +jolt-core = { path = "../jolt-core", features = ["transpiler"] } +jolt-transcript = { path = "../crates/jolt-transcript" } zklean-extractor = { path = "../zklean-extractor" } common = { path = "../common" } light-poseidon = "0.4.0" num-bigint = "0.4" +[dev-dependencies] +# Build a real proof in-process for the symbolic-pipeline integration test. +jolt-core = { path = "../jolt-core", features = ["host", "transpiler"] } +postcard = { version = "1.0", default-features = false, features = ["alloc"] } +serial_test = "3.0" + [features] default = [] -# Transcript features - must match proof generation -# Only one can be enabled at a time (enforced by jolt-core) +# Must match proof generation; Poseidon is the only sponge the symbolic layout +# (and the Go gadget) model. Featureless builds compile but `run_symbolic_pipeline` +# returns `WrongSpongeFeature`. transcript-poseidon = ["jolt-core/transcript-poseidon"] -transcript-keccak = ["jolt-core/transcript-keccak"] -transcript-blake2b = ["jolt-core/transcript-blake2b"] +# NOT a supported mode — ZK proofs are out of the transpiler's scope (see the +# lib.rs SCOPE section). This forwarding exists only so workspace-wide +# `--features zk` builds compile: it swaps `symbolize_proof` for an +# always-reject stub (jolt-core's zk feature removes the `opening_claims` +# field the real path reads). zk = ["jolt-core/zk"] [[bin]] name = "transpiler" path = "src/main.rs" - -[[bin]] -name = "compute_r_inv" -path = "src/bin/compute_r_inv.rs" diff --git a/transpiler/README.md b/transpiler/README.md index 917b4f4e43..daffd466b0 100644 --- a/transpiler/README.md +++ b/transpiler/README.md @@ -9,6 +9,14 @@ This tool performs **symbolic execution** of the Jolt verifier. Instead of compu 1. **Analyzed directly**: constraint structure, dependency graphs, optimization passes 2. **Transformed to target code**: currently Gnark/Go, but the IR is target-agnostic +### Scope + +- **Non-ZK proofs only** — ZK/BlindFold proofs (`zk_mode == true`) are rejected up front. +- **Poseidon transcript only** — requires the `transcript-poseidon` feature; the Blake2b/Keccak byte sponges used elsewhere in Jolt are intentionally unsupported here. +- **Field-aligned absorption** — each 32-byte NARG word deserializes to one `Fr` absorbed as a single field element; proof scalars are never byte-decomposed in-circuit. The 31-byte chunk rule applies only to genuine byte strings (domain separator, GT commitment bytes) and runs outside the circuit. + +This matches the scope of the pre-spongefish transpiler (commit `f3de3c91`, where `raw_append_scalar` absorbed each scalar as itself). + ### How It Works The Rust verifier is executed normally, but with a "recording" field type: @@ -64,15 +72,7 @@ cd transpiler/go && go test -v -run TestStagesCircuitProveVerify ## Transcript Feature Flags -The transpiler must use the **same transcript** as proof generation: - -| Feature Flag | Hash Function | SNARK-Friendly | -|--------------|---------------|----------------| -| `transcript-poseidon` | Poseidon | Yes | -| `transcript-keccak` | Keccak | No | -| `transcript-blake2b` | Blake2b | No | - -Only Poseidon-generated proofs can be efficiently verified in-circuit. The other transcripts can still be used for IR analysis or if circuit size is not a concern. If no transcript feature is specified, both default to Blake2b. +The transpiler must use the **same transcript** as proof generation. `transcript-poseidon` is the only transcript feature: Poseidon is the sole sponge the symbolic layout (and the Go gadget) model, and the only one efficient in-circuit. Featureless builds compile, but `run_symbolic_pipeline` refuses to run (`WrongSpongeFeature`). ## CLI Options diff --git a/transpiler/go/poseidon/poseidon.go b/transpiler/go/poseidon/poseidon.go index d5802c3a6c..89325ce637 100644 --- a/transpiler/go/poseidon/poseidon.go +++ b/transpiler/go/poseidon/poseidon.go @@ -27,7 +27,7 @@ func NewBN254Chip(api frontend.API) *BN254Chip { } // Hash computes Poseidon hash of 3 field elements. -// API: poseidon.Hash(api, state, n_rounds, data) +// API: poseidon.Hash(api, state, rate_unit_a, data) func Hash(api frontend.API, in1, in2, in3 frontend.Variable) frontend.Variable { chip := NewBN254Chip(api) diff --git a/transpiler/go/poseidon/poseidon_test.go b/transpiler/go/poseidon/poseidon_test.go index 8831426cc8..e5c2f368a3 100644 --- a/transpiler/go/poseidon/poseidon_test.go +++ b/transpiler/go/poseidon/poseidon_test.go @@ -83,4 +83,3 @@ func TestPoseidonConstants(t *testing.T) { t.Errorf("cConstants[0] mismatch!\nGot: %s\nExpected: %s", cConstants[0].String(), expected.String()) } } - diff --git a/transpiler/go/stages_circuit_test.go b/transpiler/go/stages_circuit_test.go index a7125de128..a2bdd7aada 100644 --- a/transpiler/go/stages_circuit_test.go +++ b/transpiler/go/stages_circuit_test.go @@ -85,7 +85,7 @@ func TestStagesCircuitSolver(t *testing.T) { fieldsToShow := []string{ "Stage1_Uni_Skip_Coeff_0", "Stage1_Uni_Skip_Coeff_1", - "Claim_Virtual_UnivariateSkip_SpartanOuter", + "Claim_Polynomial_Virtual_UnivariateSkip_SpartanOuter", "Stage1_Sumcheck_R0_0", "Stage1_Sumcheck_R0_1", "Stage1_Sumcheck_R0_2", @@ -138,9 +138,9 @@ func TestStagesCircuitSolver(t *testing.T) { // Print the UnivariateSkip claim t.Log("\nClaims:") - uniSkipField := v.FieldByName("Claim_Virtual_UnivariateSkip_SpartanOuter") + uniSkipField := v.FieldByName("Claim_Polynomial_Virtual_UnivariateSkip_SpartanOuter") if uniSkipField.IsValid() { - t.Logf(" Claim_Virtual_UnivariateSkip_SpartanOuter = %v", uniSkipField.Interface()) + t.Logf(" Claim_Polynomial_Virtual_UnivariateSkip_SpartanOuter = %v", uniSkipField.Interface()) } // Manually compute the sumcheck verification for Stage1 @@ -160,10 +160,10 @@ func TestStagesCircuitSolver(t *testing.T) { // Let's print some intermediate claim values claimFields := []string{ - "Claim_Virtual_OpFlags_Load_SpartanOuter", - "Claim_Virtual_OpFlags_Store_SpartanOuter", - "Claim_Virtual_RamAddress_SpartanOuter", - "Claim_Virtual_LookupOutput_SpartanOuter", + "Claim_Polynomial_Virtual_OpFlags_Load_SpartanOuter", + "Claim_Polynomial_Virtual_OpFlags_Store_SpartanOuter", + "Claim_Polynomial_Virtual_RamAddress_SpartanOuter", + "Claim_Polynomial_Virtual_LookupOutput_SpartanOuter", } for _, name := range claimFields { field := v.FieldByName(name) @@ -172,15 +172,14 @@ func TestStagesCircuitSolver(t *testing.T) { } } - // Use gnark test solver to check constraints + // Use gnark test solver to check constraints. A solver error means the honest + // witness does NOT satisfy the generated circuit — a real failure, not a debug log. var circuit JoltStagesCircuit err = test.IsSolved(&circuit, assignment, ecc.BN254.ScalarField()) if err != nil { - t.Logf("Solver error: %v", err) - // The error message should tell us which constraint failed - } else { - t.Log("All constraints satisfied!") + t.Fatalf("circuit not satisfied by the honest witness: %v", err) } + t.Log("All constraints satisfied!") } // TestStagesCircuitProveVerify runs the complete Groth16 workflow: compile, setup, prove, verify. @@ -340,35 +339,35 @@ func TestCorruptedWitnessRejected(t *testing.T) { }, { name: "Corrupt PC claim (Spartan outer)", - fieldName: "Claim_Virtual_PC_SpartanOuter", + fieldName: "Claim_Polynomial_Virtual_PC_SpartanOuter", corruptFunc: func(v *big.Int) *big.Int { return new(big.Int).Add(v, big.NewInt(1)) }, }, { name: "Corrupt Rs1 value claim", - fieldName: "Claim_Virtual_Rs1Value_SpartanOuter", + fieldName: "Claim_Polynomial_Virtual_Rs1Value_SpartanOuter", corruptFunc: func(v *big.Int) *big.Int { return new(big.Int).Add(v, big.NewInt(42)) }, }, { name: "Zero out RdInc claim", - fieldName: "Claim_Committed_RdInc_RegistersReadWriteChecking", + fieldName: "Claim_Polynomial_Committed_RdInc_RegistersReadWriteChecking", corruptFunc: func(v *big.Int) *big.Int { return big.NewInt(0) }, }, { name: "Negate RAM booleanity claim", - fieldName: "Claim_Committed_RamRa_0_RamBooleanity", + fieldName: "Claim_Polynomial_Committed_RamRa_0_Booleanity", corruptFunc: func(v *big.Int) *big.Int { return new(big.Int).Neg(v) }, }, { name: "Corrupt lookup output claim", - fieldName: "Claim_Virtual_LookupOutput_SpartanOuter", + fieldName: "Claim_Polynomial_Virtual_LookupOutput_SpartanOuter", corruptFunc: func(v *big.Int) *big.Int { return new(big.Int).Add(v, big.NewInt(999)) }, @@ -387,11 +386,16 @@ func TestCorruptedWitnessRejected(t *testing.T) { t.Fatalf("Failed to reload witness: %v", err) } - // Apply corruption + // Apply corruption. A missing target field means the hardcoded name is stale + // w.r.t. the current witness-naming scheme — fail loudly (t.Errorf reports every + // stale name in one run) instead of silently skipping, which would degrade this + // sanity gate to whichever cases happen to still match. v := reflect.ValueOf(assignment).Elem() field := v.FieldByName(tc.fieldName) if !field.IsValid() { - t.Logf(" ? %s - field %s not found, skipping", tc.name, tc.fieldName) + t.Errorf(" %s - corruption target field %q not found in circuit struct "+ + "(stale test scaffolding; update to the current witness-naming scheme)", + tc.name, tc.fieldName) continue } @@ -424,4 +428,3 @@ func TestCorruptedWitnessRejected(t *testing.T) { t.Log("") t.Log("✓ All corruption tests passed - circuit correctly rejects invalid witnesses") } - diff --git a/transpiler/src/ast_evaluator.rs b/transpiler/src/ast_evaluator.rs index 637f3141b5..57e1e1a4c8 100644 --- a/transpiler/src/ast_evaluator.rs +++ b/transpiler/src/ast_evaluator.rs @@ -6,10 +6,22 @@ use ark_bn254::Fr; use ark_ff::{Field, PrimeField}; use light_poseidon::{Poseidon, PoseidonHasher}; +use std::cell::RefCell; use std::collections::HashMap; use zklean_extractor::ast_bundle::Constraint; use zklean_extractor::mle_ast::{Atom, Edge, Node, NodeId, Scalar, TranscriptHashData}; +thread_local! { + /// Reused width-4 Circom Poseidon hasher for `TranscriptHash` evaluation. + /// `compute_node` runs once per hash node over thousands of nodes per + /// challenge AST, so rebuilding the round-constant tables on each call was a + /// large, avoidable cost. `hash` resets its internal state per call (it is + /// driven statefully by `ConcreteFieldSponge`), so reuse is value-identical. + static POSEIDON_HASHER: RefCell> = RefCell::new( + Poseidon::::new_circom(3).expect("failed to create Poseidon hasher"), + ); +} + /// Evaluate an Edge to a concrete Fr value. fn eval_edge( edge: &Edge, @@ -44,107 +56,114 @@ fn scalar_to_fr(limbs: &Scalar) -> Fr { Fr::from_le_bytes_mod_order(&bytes) } -/// Evaluate a node, caching results. -fn eval_node( - node_id: NodeId, - nodes: &[Node], - cache: &mut HashMap, - witness: &HashMap, -) -> Fr { - if let Some(&val) = cache.get(&node_id) { - return val; - } +/// Evaluate a single AST root to a concrete `Fr` given a witness map. Used by the +/// field-aligned sponge differential gate (`field_aligned_layout_matches_native_sponge`): +/// evaluate the symbolic challenge AST and compare against the native `PoseidonSponge`. +pub fn eval_root(nodes: &[Node], root: NodeId, witness: &HashMap) -> Fr { + let mut cache = HashMap::new(); + eval_node(root, nodes, &mut cache, witness) +} - let result = match &nodes[node_id] { - Node::Atom(atom) => eval_atom(atom, witness), +/// [`eval_root`] over many roots sharing ONE memo cache. Fiat-Shamir challenge +/// ASTs embed the whole sponge chain of every earlier challenge, so evaluating +/// `k` challenges with per-root fresh caches is quadratic in the transcript +/// length; the shared cache makes it linear. Used by the in-CI real-proof +/// challenge differential (`tests/symbolic_pipeline.rs`). +pub fn eval_roots(nodes: &[Node], roots: &[NodeId], witness: &HashMap) -> Vec { + let mut cache = HashMap::new(); + roots + .iter() + .map(|&root| eval_node(root, nodes, &mut cache, witness)) + .collect() +} - Node::Add(a, b) => { - eval_edge(a, nodes, cache, witness) + eval_edge(b, nodes, cache, witness) - } - Node::Sub(a, b) => { - eval_edge(a, nodes, cache, witness) - eval_edge(b, nodes, cache, witness) - } - Node::Mul(a, b) => { - eval_edge(a, nodes, cache, witness) * eval_edge(b, nodes, cache, witness) - } - Node::Div(a, b) => { - let denom = eval_edge(b, nodes, cache, witness); - eval_edge(a, nodes, cache, witness) * denom.inverse().expect("div by zero") - } - Node::Neg(a) => -eval_edge(a, nodes, cache, witness), - Node::Inv(a) => eval_edge(a, nodes, cache, witness) - .inverse() - .expect("inv of zero"), - - Node::TranscriptHash(hash_data, state_edge, rounds_edge) => { - let state = eval_edge(state_edge, nodes, cache, witness); - let rounds = eval_edge(rounds_edge, nodes, cache, witness); - - match hash_data { - TranscriptHashData::Poseidon(data_edge) => { - let data = eval_edge(data_edge, nodes, cache, witness); - let mut hasher = - Poseidon::::new_circom(3).expect("failed to create Poseidon hasher"); - hasher - .hash(&[state, rounds, data]) - .expect("Poseidon hash failed") - } - _ => panic!("only Poseidon transcript is supported for evaluation"), +/// Uncached `NodeRef` children of `node`, pushed onto `out`. Returns `true` +/// if any were pending (i.e. `node` is not ready to compute yet). +fn push_pending_children(node: &Node, cache: &HashMap, out: &mut Vec) -> bool { + let before = out.len(); + let mut push = |e: &Edge| { + if let Edge::NodeRef(id) = e { + if !cache.contains_key(id) { + out.push(*id); } } - - Node::ByteReverse(e) => { - let val = eval_edge(e, nodes, cache, witness); - let bigint = val.into_bigint(); - let mut bytes = [0u8; 32]; - for (i, limb) in bigint.0.iter().enumerate() { - bytes[i * 8..(i + 1) * 8].copy_from_slice(&limb.to_le_bytes()); - } - bytes.reverse(); - Fr::from_le_bytes_mod_order(&bytes) + }; + match node { + Node::Atom(_) => {} + Node::Neg(e) | Node::Inv(e) => push(e), + Node::Add(a, b) | Node::Sub(a, b) | Node::Mul(a, b) | Node::Div(a, b) => { + push(a); + push(b); } - - Node::Truncate128Reverse(e) => { - let val = eval_edge(e, nodes, cache, witness); - let bigint = val.into_bigint(); - let mut le_bytes = [0u8; 32]; - for (i, limb) in bigint.0.iter().enumerate() { - le_bytes[i * 8..(i + 1) * 8].copy_from_slice(&limb.to_le_bytes()); + Node::TranscriptHash(hash_data, state, rate_unit_a) => { + push(state); + push(rate_unit_a); + for e in hash_data.as_slice() { + push(e); } - // Take low 16 bytes, reverse, interpret as field element, multiply by 2^128 - let mut truncated = [0u8; 16]; - truncated.copy_from_slice(&le_bytes[..16]); - truncated.reverse(); - let base = Fr::from_le_bytes_mod_order(&truncated); - let shift = Fr::from(2u64).pow([128]); - base * shift } + } + out.len() > before +} - Node::Truncate128(e) => { - let val = eval_edge(e, nodes, cache, witness); - let bigint = val.into_bigint(); - let mut le_bytes = [0u8; 32]; - for (i, limb) in bigint.0.iter().enumerate() { - le_bytes[i * 8..(i + 1) * 8].copy_from_slice(&limb.to_le_bytes()); - } - let mut truncated = [0u8; 16]; - truncated.copy_from_slice(&le_bytes[..16]); - truncated.reverse(); - Fr::from_le_bytes_mod_order(&truncated) +/// Evaluate a node, caching results. Iterative (explicit work stack): the +/// sponge state chain alone is thousands of nodes deep, so a recursive walk +/// overflows the default test-thread stack on unoptimized builds. +fn eval_node( + node_id: NodeId, + nodes: &[Node], + cache: &mut HashMap, + witness: &HashMap, +) -> Fr { + let mut stack: Vec = vec![node_id]; + while let Some(&id) = stack.last() { + if cache.contains_key(&id) { + let _ = stack.pop(); + continue; + } + if push_pending_children(&nodes[id], cache, &mut stack) { + continue; // children first; `id` stays on the stack below them } + let value = compute_node(&nodes[id], cache, witness); + let _ = cache.insert(id, value); + let _ = stack.pop(); + } + cache[&node_id] +} - Node::AppendU64Transform(e) => { - let val = eval_edge(e, nodes, cache, witness); - // bswap64(x) * 2^192 - let bigint = val.into_bigint(); - let x = bigint.0[0]; // u64 value - let swapped = x.swap_bytes(); - Fr::from(swapped) * Fr::from(2u64).pow([192]) +/// Compute one node whose `NodeRef` children are all cached. +fn compute_node(node: &Node, cache: &HashMap, witness: &HashMap) -> Fr { + let val = |e: &Edge| -> Fr { + match e { + Edge::Atom(atom) => eval_atom(atom, witness), + Edge::NodeRef(id) => cache[id], } }; - cache.insert(node_id, result); - result + match node { + Node::Atom(atom) => eval_atom(atom, witness), + + Node::Add(a, b) => val(a) + val(b), + Node::Sub(a, b) => val(a) - val(b), + Node::Mul(a, b) => val(a) * val(b), + Node::Div(a, b) => val(a) * val(b).inverse().expect("div by zero"), + Node::Neg(a) => -val(a), + Node::Inv(a) => val(a).inverse().expect("inv of zero"), + + Node::TranscriptHash(hash_data, state_edge, rate_unit_a_edge) => { + let state = val(state_edge); + let rate_unit_a = val(rate_unit_a_edge); + + let TranscriptHashData::Poseidon(data_edge) = hash_data; + let data = val(data_edge); + POSEIDON_HASHER.with(|hasher| { + hasher + .borrow_mut() + .hash(&[state, rate_unit_a, data]) + .expect("Poseidon hash failed") + }) + } + } } /// LHS and RHS of one assertion. diff --git a/transpiler/src/bin/compute_r_inv.rs b/transpiler/src/bin/compute_r_inv.rs deleted file mode 100644 index 1aa49fdad2..0000000000 --- a/transpiler/src/bin/compute_r_inv.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! Compute R and R^-1 for BN254 Fr Montgomery arithmetic. -//! -//! # Purpose -//! -//! One-time utility to derive the `bn254RInv` constant used in `poseidon.go`. -//! The constant is now hardcoded; this binary documents how it was computed. -//! -//! # What is R? -//! -//! R is the Montgomery constant: R = 2^256 mod p (for 4×64-bit limb representation). -//! In ark-ff, field elements are stored as `a * R mod p` internally. -//! R^-1 is needed to convert from Montgomery form back to standard form. -//! -//! # Why we need R^-1 -//! -//! Jolt's `from_bigint_unchecked` interprets input as already in Montgomery form -//! and multiplies by R^-1. The Go circuit's `Truncate128Reverse` must apply the -//! same R^-1 multiplication to match Rust's challenge derivation. -//! -//! # Scope -//! -//! This utility is **BN254 Fr specific**: -//! - Hardcodes R = 2^256 (4 limbs × 64 bits) -//! - Uses `ark_bn254::Fr` directly -//! -//! For other fields, you'd need to adjust the limb count and import the -//! appropriate field type. Each field has its own R value. -//! -//! # Usage -//! -//! ```bash -//! cargo run -p transpiler --bin compute_r_inv --release -//! ``` - -use ark_bn254::Fr; -use ark_ff::{BigInt, PrimeField}; -use ark_serialize::CanonicalSerialize; -use num_bigint::BigUint; - -fn fr_to_string(f: &Fr) -> String { - let mut bytes = vec![]; - f.serialize_uncompressed(&mut bytes).unwrap(); - BigUint::from_bytes_le(&bytes).to_string() -} - -fn main() { - println!("=== BN254 Fr Montgomery Parameters ===\n"); - - // BN254 Fr modulus - let modulus = Fr::MODULUS; - println!("Modulus (p): {:?}", modulus.0); - - // Convert modulus to BigUint for display - let mut modulus_bytes = vec![]; - for limb in modulus.0.iter() { - modulus_bytes.extend_from_slice(&limb.to_le_bytes()); - } - let modulus_biguint = BigUint::from_bytes_le(&modulus_bytes); - println!("Modulus (decimal): {modulus_biguint}"); - - // R = 2^256 mod p (Montgomery constant for 4-limb representation) - let two_256 = BigUint::from(1u8) << 256; - let r_value: BigUint = &two_256 % &modulus_biguint; - println!("\nR = 2^256 mod p = {r_value}"); - - // R^-1 = modular inverse of R mod p - let r_inv = r_value.modinv(&modulus_biguint).unwrap(); - println!("R^-1 mod p = {r_inv}"); - - // Verify: R * R^-1 = 1 mod p - let product = (&r_value * &r_inv) % &modulus_biguint; - assert_eq!(product, BigUint::from(1u8), "R * R^-1 should equal 1 mod p"); - println!("\nVerified: R * R^-1 = 1 mod p"); - - // Test from_bigint_unchecked behavior - println!("\n=== Testing from_bigint_unchecked ==="); - let bigint_one = BigInt::new([1u64, 0, 0, 0]); - let fr_unchecked = Fr::from_bigint_unchecked(bigint_one).unwrap(); - println!( - "from_bigint_unchecked([1, 0, 0, 0]) = {}", - fr_to_string(&fr_unchecked) - ); - println!("Expected (R^-1 mod p) = {r_inv}"); - - // Verify MontU128Challenge conversion (used in sumcheck challenges) - println!("\n=== MontU128Challenge Verification ==="); - println!("For a 128-bit masked value, from_bigint_unchecked places it in limbs [2,3]"); - println!("Then multiplies by R^-1 to convert from Montgomery to standard form."); - println!("\nThis matches the Go hint in poseidon.go which:"); - println!("1. Places low 64 bits at position 128 (limb 2)"); - println!("2. Places high 64 bits at position 192 (limb 3)"); - println!("3. Multiplies by bn254RInv = {r_inv}"); - - println!("\n=== Go Constant ==="); - println!("// bn254RInv is R^-1 mod p for BN254 Fr Montgomery arithmetic"); - println!("var bn254RInv = bigInt(\"{r_inv}\") // Used in Truncate128Reverse hint"); -} diff --git a/transpiler/src/gnark_codegen.rs b/transpiler/src/gnark_codegen.rs index 162738116d..8dd3a12174 100644 --- a/transpiler/src/gnark_codegen.rs +++ b/transpiler/src/gnark_codegen.rs @@ -18,7 +18,7 @@ //! The coupling is intentional: we emit target-specific code for gnark. If we add other //! targets (Circom, Plonky2), they'd need separate codegen modules. //! -//! Currently targets: **gnark v0.10.x** with Go 1.21+ +//! Currently targets: **gnark v0.14.0** with Go 1.25+ //! //! # Key Components //! @@ -64,9 +64,7 @@ use zklean_extractor::mle_ast::{ TargetField, TranscriptHashData, }; -// ============================================================================= // Helper Functions -// ============================================================================= /// Extract child node IDs from a Node (used by generate_expr for traversal). fn node_children(node: &Node) -> Vec { @@ -79,12 +77,7 @@ fn node_children(node: &Node) -> Vec { match node { Node::Atom(_) => vec![], - Node::Neg(e) - | Node::Inv(e) - | Node::ByteReverse(e) - | Node::Truncate128Reverse(e) - | Node::Truncate128(e) - | Node::AppendU64Transform(e) => edge_to_child(e).into_iter().collect(), + Node::Neg(e) | Node::Inv(e) => edge_to_child(e).into_iter().collect(), Node::Add(e1, e2) | Node::Mul(e1, e2) | Node::Sub(e1, e2) | Node::Div(e1, e2) => { [edge_to_child(e1), edge_to_child(e2)] .into_iter() @@ -102,9 +95,7 @@ fn node_children(node: &Node) -> Vec { } } -// ============================================================================= // Types -// ============================================================================= /// Statistics about constant assertions detected during codegen. #[derive(Debug, Default)] @@ -212,7 +203,7 @@ impl<'a> GnarkCodeGen<'a> { var_names: &'a HashMap, constraint_idx: usize, cse_bindings: &[usize], - global_node_map: &HashMap, + global_generated: &HashMap, ) -> Self { let mut ref_counts = HashMap::new(); @@ -223,16 +214,13 @@ impl<'a> GnarkCodeGen<'a> { ref_counts.insert(node_id, 2); } - // Pre-populate `generated` for global CSE nodes so they resolve to gcse[i] - let mut generated = HashMap::new(); - for (&node_id, &gcse_idx) in global_node_map { - generated.insert(node_id, format!("gcse[{gcse_idx}]")); - } - + // Seed `generated` with the global CSE nodes' `gcse[i]` names. The strings are + // formatted ONCE by the caller (`global_generated`) and cloned here, so the + // per-global-node `format!` is not repeated for every constraint. Self { nodes, ref_counts, - generated, + generated: global_generated.clone(), bindings: Vec::new(), cse_counter: 0, var_names, @@ -345,40 +333,14 @@ impl<'a> GnarkCodeGen<'a> { // Unary ops Node::Inv(e) => self.unary_op("api.Inverse", e), - Node::ByteReverse(e) => { - self.uses_poseidon = true; - self.unary_op("poseidon.ByteReverse", e) - } - Node::Truncate128Reverse(e) => { - self.uses_poseidon = true; - self.unary_op("poseidon.Truncate128Reverse", e) - } - Node::Truncate128(e) => { - self.uses_poseidon = true; - self.unary_op("poseidon.Truncate128", e) - } - Node::AppendU64Transform(e) => { - self.uses_poseidon = true; - self.unary_op("poseidon.AppendU64Transform", e) - } - // Transcript hash (dispatched by hash_data variant) - Node::TranscriptHash(ref hash_data, state, n_rounds) => { + Node::TranscriptHash(ref hash_data, state, rate_unit_a) => { let s = self.edge_to_gnark_iterative(state); - let r = self.edge_to_gnark_iterative(n_rounds); - match hash_data { - TranscriptHashData::Poseidon(data_edge) => { - self.uses_poseidon = true; - let d = self.edge_to_gnark_iterative(*data_edge); - format!("poseidon.Hash(api, {s}, {r}, {d})") - } - TranscriptHashData::Blake2b(_data_edges) => { - todo!("Blake2b Go codegen will be implemented in Phase 4") - } - TranscriptHashData::Keccak(_data_edges) => { - todo!("Keccak Go codegen will be implemented in Phase 4") - } - } + let r = self.edge_to_gnark_iterative(rate_unit_a); + let TranscriptHashData::Poseidon(data_edge) = hash_data; + self.uses_poseidon = true; + let d = self.edge_to_gnark_iterative(*data_edge); + format!("poseidon.Hash(api, {s}, {r}, {d})") } // zklean base nodes: Neg and Div are part of upstream zklean's Node enum. @@ -455,9 +417,7 @@ impl<'a> GnarkCodeGen<'a> { } } - // ------------------------------------------------------------------------- // Private helper methods - // ------------------------------------------------------------------------- /// Generate a CSE variable name using the configured prefix fn make_cse_name(&self) -> String { @@ -507,9 +467,7 @@ impl<'a> GnarkCodeGen<'a> { } } -// ============================================================================= // Public functions -// ============================================================================= /// Generate a complete Gnark circuit from an AstBundle. /// @@ -620,15 +578,16 @@ pub fn generate_circuit_from_bundle_with_stats( ); } - // Build global CSE node map: NodeId → index in gcse slice - let global_node_map: HashMap = bundle + // Build global CSE node map: NodeId → `gcse[i]` name. Formatted once here and + // cloned per constraint (see `GnarkCodeGen::new`), not re-`format!`ed per constraint. + let global_generated: HashMap = bundle .global_cse .bindings .iter() .enumerate() - .map(|(idx, &node_id)| (node_id, idx)) + .map(|(idx, &node_id)| (node_id, format!("gcse[{idx}]"))) .collect(); - let has_global_cse = !global_node_map.is_empty(); + let has_global_cse = !global_generated.is_empty(); if bundle .global_cse .bindings @@ -646,7 +605,7 @@ pub fn generate_circuit_from_bundle_with_stats( &var_names, constraint_idx, cse_bindings, - &global_node_map, + &global_generated, ); // Generate expression for this constraint. @@ -700,14 +659,12 @@ pub fn generate_circuit_from_bundle_with_stats( stats.uses_poseidon = true; } - // Get bindings (will be empty string if none) - let bindings = if codegen.bindings_code().is_empty() { + // Get bindings (will be empty string if none). Join the binding vec once. + let bindings_code = codegen.bindings_code(); + let bindings = if bindings_code.is_empty() { String::new() } else { - format!( - "\t// CSE bindings for constraint {constraint_idx}\n{}", - codegen.bindings_code() - ) + format!("\t// CSE bindings for constraint {constraint_idx}\n{bindings_code}") }; processed_constraints.push(ProcessedConstraint { @@ -724,23 +681,38 @@ pub fn generate_circuit_from_bundle_with_stats( stats.total_constraints = processed_constraints.len(); - // Collect all struct field names with their target field types - // Using BTreeMap to deduplicate by name while preserving field type - let mut struct_fields: BTreeMap = BTreeMap::new(); + // Collect all struct field names with their target field type and gnark + // visibility. `BTreeMap` deduplicates by name (deterministic order). + // + // Visibility (audit: ~1,400 public inputs ⇒ ~9M gas, ~36× the spec target): only + // `WitnessType::PublicStatement` inputs (program IO) and the stage-8 binding values + // (opening claims, polynomial/advice commitments) are emitted `public`; the bulk — + // sumcheck round polynomials and uni-skip coefficients (`WitnessType::ProofData`) — + // are `secret`. This is sound because the circuit re-derives every Fiat-Shamir + // challenge from those proof bytes in-circuit (they are self-binding via the + // sponge), so the on-chain verifier never needs to see them; the values the + // deferred stage-8 PCS check WILL bind (commitments, final claims) stay public. + let mut struct_fields: BTreeMap = BTreeMap::new(); for input in &bundle.inputs { if input.witness_type == WitnessType::ProofData || input.witness_type == WitnessType::PublicStatement { - struct_fields.insert(sanitize_go_name(&input.name), input.target_field); + let is_public = input.witness_type == WitnessType::PublicStatement; + let entry = struct_fields + .entry(sanitize_go_name(&input.name)) + .or_insert((input.target_field, is_public)); + // A name surfacing as both public and proof-data stays public. + entry.1 |= is_public; } } for constraint in &bundle.constraints { if let Assertion::EqualPublicInput { name } = &constraint.assertion { - // Public inputs from constraints default to Fr (native field) + // Public inputs from constraints default to Fr (native field) and are public. struct_fields .entry(sanitize_go_name(name)) - .or_insert(TargetField::Fr); + .or_insert((TargetField::Fr, true)) + .1 = true; } } @@ -774,18 +746,10 @@ pub fn generate_circuit_from_bundle_with_stats( output.push_str("\treturn n\n"); output.push_str("}\n\n"); - // Circuit struct - deduplicated fields with correct types based on TargetField - // - // All inputs are marked public. Without PCS verification in the circuit - // (stage 8 is deferred pending PCS choice), commitments and proof data - // must be externally verifiable. Once the PCS is integrated, we'll - // determine which inputs can move to private witness. The exact public - // surface requires investigation: some inputs may benefit from staying - // public to avoid in-circuit range checks that the verifier gets for - // free on public inputs. The WitnessType::PublicStatement / ProofData - // distinction in AstBundle is already in place for this split. + // Circuit struct - deduplicated fields with correct types and visibility (see the + // `struct_fields` build above for the public/secret rationale). output.push_str(&format!("type {circuit_name} struct {{\n")); - for (field_name, target_field) in &struct_fields { + for (field_name, (target_field, is_public)) in &struct_fields { let go_type = match target_field { TargetField::Fr => "frontend.Variable", TargetField::Fq => { @@ -796,7 +760,10 @@ pub fn generate_circuit_from_bundle_with_stats( "emulated.Element[emulated.BN254Fp]" } }; - output.push_str(&format!("\t{field_name} {go_type} `gnark:\",public\"`\n")); + let visibility = if *is_public { "public" } else { "secret" }; + output.push_str(&format!( + "\t{field_name} {go_type} `gnark:\",{visibility}\"`\n" + )); } output.push_str("}\n\n"); @@ -849,6 +816,8 @@ pub fn generate_circuit_from_bundle_with_stats( output.push_str(&format!( "func (circuit *{circuit_name}) computeGlobalCsePart{part}(api frontend.API, gcse []frontend.Variable) {{\n" )); + // Global lines are already in slice form (gcse[N] = ...), so parts can + // be split anywhere — every cross-part value flows through the slice. for line in &global_binding_lines[start..end] { output.push_str(line); output.push('\n'); @@ -955,11 +924,10 @@ pub fn generate_circuit_from_bundle_with_stats( "func (circuit *{circuit_name}) {func_name}Bindings{part}(api frontend.API, cse []frontend.Variable{gcse_param}) {{\n" )); + // Rewrite "cse_K_N := expr" to "cse[N] = expr" and "cse_K_M" refs to + // "cse[M]" so the parts can share intermediates through the slice. for line in &binding_lines[start..end] { - // Rewrite "cse_K_N := expr" to "cse[N] = expr" and - // references to "cse_K_M" to "cse[M]" within the expression - let rewritten = rewrite_cse_names_to_slice(line, idx, true); - output.push_str(&rewritten); + output.push_str(&rewrite_cse_names_to_slice(line, idx, true)); output.push('\n'); } @@ -1141,9 +1109,7 @@ pub fn sanitize_go_name(name: &str) -> String { .join("_") } -// ============================================================================= // Private helper functions -// ============================================================================= /// Format a scalar value ([u64; 4]) for Gnark code generation. /// @@ -1179,12 +1145,7 @@ fn is_node_constant_in(nodes: &[Node], node_id: usize) -> bool { Node::Atom(Atom::Scalar(_)) => true, Node::Atom(Atom::Var(_)) => false, Node::Atom(Atom::NamedVar(_)) => false, - Node::Neg(e) - | Node::Inv(e) - | Node::ByteReverse(e) - | Node::Truncate128Reverse(e) - | Node::Truncate128(e) - | Node::AppendU64Transform(e) => is_edge_constant_in(nodes, e), + Node::Neg(e) | Node::Inv(e) => is_edge_constant_in(nodes, e), Node::Add(e1, e2) | Node::Mul(e1, e2) | Node::Sub(e1, e2) | Node::Div(e1, e2) => { is_edge_constant_in(nodes, e1) && is_edge_constant_in(nodes, e2) } @@ -1231,12 +1192,8 @@ fn evaluate_constant_node_in(nodes: &[Node], node_id: usize) -> Scalar { Node::Inv(_) | Node::Div(_, _) => { panic!("Modular inverse not implemented for constant evaluation") } - Node::TranscriptHash(_, _, _) - | Node::ByteReverse(_) - | Node::Truncate128Reverse(_) - | Node::Truncate128(_) - | Node::AppendU64Transform(_) => { - panic!("Hash/transform operations cannot be evaluated as constants") + Node::TranscriptHash(_, _, _) => { + panic!("Hash operations cannot be evaluated as constants") } } } @@ -1262,11 +1219,7 @@ fn node_requires_poseidon_import(nodes: &[Node], root: usize) -> bool { match &nodes[node_id] { Node::Atom(_) => {} - Node::TranscriptHash(TranscriptHashData::Poseidon(_), _, _) - | Node::ByteReverse(_) - | Node::Truncate128Reverse(_) - | Node::Truncate128(_) - | Node::AppendU64Transform(_) => return true, + Node::TranscriptHash(TranscriptHashData::Poseidon(_), _, _) => return true, node => stack.extend(node_children(node)), } } @@ -1274,9 +1227,7 @@ fn node_requires_poseidon_import(nodes: &[Node], root: usize) -> bool { false } -// ============================================================================= // Tests -// ============================================================================= #[cfg(test)] mod tests { @@ -1374,9 +1325,7 @@ mod tests { assert!(code.contains("poseidon.Hash(api,")); } - // ========================================================================= // sanitize_go_name tests - // ========================================================================= // These tests are CRITICAL because sanitize_go_name must produce identical // output for circuit struct fields and witness JSON keys. Any mismatch // causes witness loading to fail silently with all-zero values. diff --git a/transpiler/src/lib.rs b/transpiler/src/lib.rs index 2d277ec08b..a166f6a6b7 100644 --- a/transpiler/src/lib.rs +++ b/transpiler/src/lib.rs @@ -3,13 +3,32 @@ //! This crate transpiles Jolt's verifier (stages 1-7, all sumchecks) into circuit code //! for various proving backends. Currently supported: gnark (Go/Groth16). //! +//! # SCOPE (maintainer-accepted; matches the pre-spongefish transpiler) +//! +//! - **Non-ZK proofs ONLY.** ZK/BlindFold proofs (`proof.zk_mode == true`) are +//! rejected up front ([`narg_parser::NargParseError::ZkProofUnsupported`]). +//! - **Poseidon transcript sponge ONLY.** Requires the `transcript-poseidon` +//! feature; the Blake2b/Keccak byte sponges used elsewhere in Jolt are +//! intentionally unsupported here ([`pipeline::PipelineError::WrongSpongeFeature`]). +//! - **Field-aligned absorption.** Each 32-byte NARG word is deserialized to one +//! `Fr` and absorbed as a single field element — proof scalars are NEVER +//! byte-decomposed in-circuit. The 31-byte chunk rule applies only to genuine +//! byte STRINGS (the domain separator, GT commitment bytes) and is applied +//! OUTSIDE the circuit. +//! +//! This restores the scope of the pre-spongefish transpiler (commit +//! `f3de3c9160498abdd7452740b37869ecbc60f611`, where `raw_append_scalar` +//! absorbed each scalar as itself). +//! //! # Architecture //! //! ```text -//! JoltProof (concrete Fr values) -//! ↓ symbolize_proof() -//! JoltProof (symbolic variables) -//! ↓ TranspilableVerifier::verify() +//! JoltProof (NARG byte-string + structural fields) +//! ↓ symbolize_proof(): claims → vars; NARG → frames (narg_parser) +//! TranspilableVerifier stages 1–7 over SymbolicVerifierFs +//! (frames → witness vars at their exact read positions; +//! absorbs/squeezes → sponge-layout AST nodes) +//! ↓ //! AST in NODE_ARENA (recorded operations) //! ↓ target-specific codegen //! Circuit code (e.g., stages_circuit.go for gnark) @@ -48,19 +67,19 @@ //! //! # Transcript Feature Flags //! -//! The transpiler must use the same transcript as proof generation: -//! - `--features transcript-poseidon`: Poseidon hash (SNARK-friendly, recommended) -//! - `--features transcript-keccak`: Keccak hash -//! - `--features transcript-blake2b`: Blake2b hash (default if none specified) -//! -//! **Note**: Only Poseidon-generated proofs can be efficiently verified in-circuit. +//! `transcript-poseidon` is the only transcript feature: the symbolic sponge layout +//! (and the Go gadget) model Poseidon, the sole sponge that is efficient in-circuit. +//! Featureless builds compile, but `run_symbolic_pipeline` returns +//! `WrongSpongeFeature` — the transpiler must use the same transcript as proof +//! generation. //! //! # Module Overview //! +//! - [`narg_parser`]: split the proof's NARG byte-string into self-delimiting frames //! - [`gnark_codegen`]: AST → Go/gnark code generation with CSE -//! - [`symbolic_proof`]: Convert concrete proofs to symbolic form +//! - [`symbolic_proof`]: symbolize the proof's structural parts (claims, configs) //! - [`symbolic_traits`]: Trait implementations for MleAst transpilation -//! - [`symbolic_traits::poseidon`]: Poseidon transcript for symbolic Fiat-Shamir +//! - [`symbolic_traits::verifier_fs`]: spongefish `VerifierFs` for symbolic Fiat-Shamir //! - [`symbolic_traits::opening_accumulator`]: Symbolic opening accumulator //! - [`symbolic_traits::ast_commitment_scheme`]: Stub commitment scheme for transpilation //! @@ -69,59 +88,25 @@ //! See `main.rs` for the full transpilation pipeline, or use the library directly: //! //! ```ignore -//! use transpiler::{symbolize_proof, gnark_codegen, PoseidonAstTranscript}; +//! use transpiler::symbolic_proof::{symbolize_proof, SymbolizedProof}; +//! use transpiler::symbolic_traits::{FieldAlignedLayout, SymbolicVerifierFs}; //! -//! let (symbolic_proof, accumulator, var_alloc) = symbolize_proof::(&real_proof); -//! // ... run TranspilableVerifier::verify() ... +//! let SymbolizedProof { parsed_narg, accumulator, proof_data } = +//! symbolize_proof(&real_proof, &mut var_alloc)?; +//! // ... build SymbolicVerifierFs over the frames, drive TranspilableVerifier stages ... //! let circuit_code = gnark_codegen::generate_circuit_from_bundle(&bundle, "MyCircuit"); //! ``` pub mod ast_evaluator; pub mod gnark_codegen; +pub mod narg_parser; +pub mod pipeline; +#[cfg(test)] +pub(crate) mod poseidon_model; pub mod symbolic_proof; pub mod symbolic_traits; pub mod symbolize; pub use gnark_codegen::{generate_circuit_from_bundle, sanitize_go_name}; pub use symbolic_proof::{symbolize_proof, VarAllocator}; -pub use symbolic_traits::{ - AstCommitmentScheme, AstCurve, AstOpeningAccumulator, PoseidonAstTranscript, -}; - -// Re-export transcript types based on feature flags (matching jolt-core pattern) -// This allows main.rs to use the selected transcript without conditional imports - -// Compile-time error if multiple transcript features are enabled -#[cfg(any( - all(feature = "transcript-poseidon", feature = "transcript-keccak"), - all(feature = "transcript-poseidon", feature = "transcript-blake2b"), - all(feature = "transcript-keccak", feature = "transcript-blake2b"), - all( - feature = "transcript-poseidon", - feature = "transcript-keccak", - feature = "transcript-blake2b" - ) -))] -compile_error!("Cannot enable multiple transcript features simultaneously. Please choose exactly one of: 'transcript-poseidon', 'transcript-keccak', or 'transcript-blake2b'."); - -/// The selected AST transcript type based on feature flags. -/// For symbolic execution, this determines which transcript implementation to use. -/// -/// Note: Currently only Poseidon is implemented for symbolic execution. -/// Other transcript types will use PoseidonAstTranscript as a fallback -/// (the circuit still uses Poseidon internally regardless of proof transcript). -#[cfg(feature = "transcript-poseidon")] -pub type SelectedAstTranscript = PoseidonAstTranscript; - -#[cfg(feature = "transcript-keccak")] -pub type SelectedAstTranscript = PoseidonAstTranscript; // TODO: KeccakAstTranscript when implemented - -#[cfg(feature = "transcript-blake2b")] -pub type SelectedAstTranscript = PoseidonAstTranscript; // TODO: Blake2bAstTranscript when implemented - -#[cfg(not(any( - feature = "transcript-poseidon", - feature = "transcript-keccak", - feature = "transcript-blake2b" -)))] -pub type SelectedAstTranscript = PoseidonAstTranscript; // Default to Poseidon +pub use symbolic_traits::{AstCommitmentScheme, AstCurve, AstOpeningAccumulator}; diff --git a/transpiler/src/main.rs b/transpiler/src/main.rs index 084a2b2456..49f2fef254 100644 --- a/transpiler/src/main.rs +++ b/transpiler/src/main.rs @@ -18,8 +18,11 @@ //! - Stages 1-6: Standard sumcheck verifications //! - Stage 7: HammingWeight and AdviceClaimReduction sumchecks //! -//! Stage 8 (PCS/Hyrax verification) is NOT transpiled because Hyrax requires -//! native elliptic curve operations (MSM on Grumpkin). +//! Stage 8 (PCS/Dory verification) is NOT transpiled because pairing operations +//! are too expensive in-circuit. +//! +//! Supports non-ZK proofs and the Poseidon transcript only, with field-aligned +//! absorption — see the crate-level SCOPE section in `lib.rs`. //! //! # Pipeline //! @@ -33,26 +36,16 @@ use ark_serialize::CanonicalDeserialize; use clap::{Parser, ValueEnum}; -use jolt_core::field::JoltField; use std::collections::HashMap; use std::path::PathBuf; use common::jolt_device::JoltDevice; use jolt_core::curve::Bn254Curve; use jolt_core::poly::commitment::dory::{ArkGT, DoryCommitmentScheme}; -use jolt_core::transcripts::Transcript; -use jolt_core::zkvm::transpilable_verifier::TranspilableVerifier; use jolt_core::zkvm::verifier::JoltVerifierPreprocessing; use jolt_core::zkvm::RV64IMACProof; -use transpiler::{ - gnark_codegen, symbolize_proof, AstCommitmentScheme, AstCurve, AstOpeningAccumulator, - SelectedAstTranscript, -}; -use zklean_extractor::mle_ast::{ - enable_constraint_mode, take_constraints as take_assertions, AstBundle, MleAst, TargetField, - WitnessType, -}; -use zklean_extractor::AstCommitment; +use transpiler::gnark_codegen; +use transpiler::pipeline::{bundle_needs_non_native, run_symbolic_pipeline}; // Output file names (bundle is target-agnostic) const BUNDLE_FILENAME: &str = "stages_bundle.json"; @@ -112,9 +105,7 @@ fn main() { args.target ); - // ========================================================================= // Step 1: Load input files - // ========================================================================= // These files are generated by running a Jolt program with --save flag: // cargo run -p fibonacci --features transcript-poseidon -- --save 50 // @@ -128,7 +119,7 @@ fn main() { let real_proof: RV64IMACProof = CanonicalDeserialize::deserialize_compressed(&proof_bytes[..]) .expect("Failed to deserialize proof"); println!(" trace_length: {}", real_proof.trace_length); - println!(" commitments: {}", real_proof.commitments.len()); + println!(" narg: {} bytes", real_proof.narg.len()); println!("\nLoading io_device from: {:?}", args.io_device); let io_device_bytes = std::fs::read(&args.io_device) @@ -159,292 +150,78 @@ fn main() { real_preprocessing.shared.memory_layout ); - // ========================================================================= - // Step 2: Convert proof to symbolic representation - // ========================================================================= - // symbolize_proof() converts each proof field (commitments, sumcheck polynomials, - // opening proofs) into MleAst symbolic variables. Each variable gets a unique ID - // and descriptive name (e.g., "Stage1_Sumcheck_R0_0" for round 0, coefficient 0). - // - // Returns: - // - symbolic_proof: JoltProof with symbolic variables instead of Fr values - // - accumulator: AstOpeningAccumulator to collect polynomial opening claims - // - var_alloc: Tracks variable IDs and names for witness generation - println!("\n=== Symbolizing Proof ==="); - let (symbolic_proof, accumulator, mut var_alloc) = - symbolize_proof::(&real_proof); - println!(" Total symbolic variables: {}", var_alloc.next_idx()); - - // Load and symbolize trusted advice commitment (if provided). - // The commitment is serialized separately from the proof because it's pre-committed - // by the host before proving (not included in JoltProof). - // The commitment file contains Option (the return type of commit_trusted_advice_*). - let symbolic_trusted_advice = if let Some(ref path) = args.trusted_advice { + // Step 2: Load trusted advice commitment (optional), then run the pipeline + // The commitment is serialized separately from the proof (pre-committed by + // the host); the file holds Option. + let real_trusted_advice = if let Some(ref path) = args.trusted_advice { println!("\nLoading trusted advice commitment from: {path:?}"); let advice_bytes = std::fs::read(path) .unwrap_or_else(|e| panic!("Failed to read trusted advice file {path:?}: {e}")); let real_commitment: Option = CanonicalDeserialize::deserialize_compressed(&advice_bytes[..]) .expect("Failed to deserialize trusted advice commitment"); - let real_commitment = - real_commitment.expect("Trusted advice file contains None (no commitment)"); - let chunks = var_alloc.alloc_commitment(&real_commitment, "trusted_advice_commitment"); - println!(" Symbolized as {} chunks", chunks.len()); - Some(AstCommitment::new(chunks)) + Some(real_commitment.expect("Trusted advice file contains None (no commitment)")) } else { None }; - // Create transcript for Fiat-Shamir challenges. - // IMPORTANT: The proof must have been generated with the same transcript feature, - // otherwise challenge values will mismatch and verification will fail. - let transcript: SelectedAstTranscript = Transcript::new(b"Jolt"); - - // ========================================================================= - // Step 2b: Symbolize IO device and preprocessing - // ========================================================================= - // Make inputs/outputs/panic into witness variables instead of constants. - // This sets up two override mechanisms: - // 1. PENDING_BYTES_OVERRIDES (FIFO): consumed by PoseidonAstTranscript::raw_append_bytes - // during fiat_shamir_preamble - // 2. PENDING_IO_MLE: consumed by eval_io_mle during output sumcheck - println!("\n=== Symbolizing IO Device ==="); - let (eval_input_words, _eval_output_words) = - transpiler::symbolize::symbolize_io_device(&io_device, &mut var_alloc); - println!(" IO input words: {}", eval_input_words.len()); - - // Set PENDING_INITIAL_RAM: bytecode as constants, inputs as symbolic. - // In committed mode the verifier gets the bytecode contribution from a - // claim-reduction sumcheck, so PENDING_INITIAL_RAM only needs input words. - { - use jolt_core::zkvm::ram::{set_pending_initial_ram, PendingInitialRamValues}; - let bytecode_words: Vec = if real_preprocessing.shared.program.is_full() { - real_preprocessing - .shared - .program - .as_full() - .unwrap() - .ram - .bytecode_words - .iter() - .map(|&w| MleAst::from_u64(w)) - .collect() - } else { - Vec::new() - }; - set_pending_initial_ram(PendingInitialRamValues { - bytecode_words, - input_words: eval_input_words, - }); - } - println!( - " Total symbolic variables after IO: {}", - var_alloc.next_idx() - ); - - // Convert to symbolic preprocessing: replace Dory generators with AstVerifierSetup stub. - // AstCommitmentScheme satisfies the CommitmentScheme trait but performs no cryptographic - // operations. PCS verification is skipped in stages 1-7. - // - // For Full mode: ProgramPreprocessing::Full is PCS-independent, just wrap it. - // For Committed mode: symbolize the trusted bytecode/program commitments so the - // transpiled circuit can include them as witness inputs. - println!("\n=== Converting Preprocessing ==="); - let symbolic_shared = { - use jolt_core::zkvm::bytecode::TrustedBytecodeCommitments; - use jolt_core::zkvm::program::{ - CommittedProgramPreprocessing, ProgramPreprocessing, TrustedProgramCommitments, - }; - - let symbolic_program: ProgramPreprocessing = match &real_preprocessing - .shared - .program - { - ProgramPreprocessing::Full(full) => { - println!(" Mode: Full"); - ProgramPreprocessing::Full(full.clone()) - } - ProgramPreprocessing::Committed(committed) => { - println!(" Mode: Committed (symbolizing trusted commitments)"); - let bytecode_commitments = TrustedBytecodeCommitments { - commitments: committed - .bytecode_commitments - .commitments - .iter() - .enumerate() - .map(|(i, c)| { - let chunks = - var_alloc.alloc_commitment(c, &format!("trusted_bytecode_{i}")); - AstCommitment::new(chunks) - }) - .collect(), - num_columns: committed.bytecode_commitments.num_columns, - log_k_chunk: committed.bytecode_commitments.log_k_chunk, - bytecode_chunk_count: committed.bytecode_commitments.bytecode_chunk_count, - bytecode_len: committed.bytecode_commitments.bytecode_len, - bytecode_T: committed.bytecode_commitments.bytecode_T, - }; - let program_commitments = TrustedProgramCommitments { - program_image_commitment: { - let chunks = var_alloc.alloc_commitment( - &committed.program_commitments.program_image_commitment, - "trusted_program_image", - ); - AstCommitment::new(chunks) - }, - program_image_num_columns: committed - .program_commitments - .program_image_num_columns, - program_image_num_words: committed.program_commitments.program_image_num_words, - }; - ProgramPreprocessing::Committed(CommittedProgramPreprocessing { - meta: committed.meta.clone(), - bytecode_commitments, - program_commitments, - }) - } - }; - - // Construct directly — don't use new_committed() because it calls - // PCS::setup_prover + program.commit, which panics for AstCommitmentScheme. - // The symbolic program already has the commitments symbolized above. - jolt_core::zkvm::verifier::JoltSharedPreprocessing:: { - program_meta: symbolic_program.meta(), - program: symbolic_program, - memory_layout: real_preprocessing.shared.memory_layout.clone(), - max_padded_trace_length: real_preprocessing.shared.max_padded_trace_length, - bytecode_chunk_count: real_preprocessing.shared.bytecode_chunk_count, - } - }; - let symbolic_preprocessing: JoltVerifierPreprocessing = - JoltVerifierPreprocessing::new( - symbolic_shared, - transpiler::symbolic_traits::ast_commitment_scheme::AstVerifierSetup, - None, - ); - - // ========================================================================= - // Step 3: Set up symbolic verifier - // ========================================================================= - // TranspilableVerifier is the same as the normal Jolt verifier, but parameterized - // with symbolic types (MleAst, AstCommitmentScheme, etc.) instead of concrete ones. - // When verify() is called (Step 4), it executes the verification logic but records - // all arithmetic operations to the AST instead of computing actual results. - println!("\n=== Creating TranspilableVerifier ==="); - let verifier = TranspilableVerifier::< - MleAst, - AstCurve, - AstCommitmentScheme, - SelectedAstTranscript, - AstOpeningAccumulator, - >::new_with_accumulator( - &symbolic_preprocessing, - symbolic_proof, + // Steps 2b-5: symbolize structural parts, split + replay the NARG through the + // symbolic verifier (stages 1-7), and build the AstBundle. Same path the + // integration test (`tests/symbolic_pipeline.rs`) drives. + println!("\n=== Running Symbolic Pipeline (Stages 1-7) ==="); + let pipeline_output = run_symbolic_pipeline( + &real_proof, io_device, - symbolic_trusted_advice, - transcript, - accumulator, + &real_preprocessing, + real_trusted_advice, + ) + .unwrap_or_else(|e| panic!("Symbolic pipeline failed: {e}")); + let deployment_blockers = pipeline_output.deployment_blockers(); + let bundle = pipeline_output.bundle; + let var_alloc = pipeline_output.var_alloc; + println!(" NARG frames: {}", pipeline_output.num_frames); + println!(" Assertions: {}", pipeline_output.num_assertions); + println!(" Inputs: {}", bundle.inputs.len()); + println!(" Constraints: {}", bundle.constraints.len()); + println!( + " Canonicalize: {} nodes -> {} ({} duplicates merged, {} dead dropped)", + pipeline_output.canon_stats.nodes_before, + pipeline_output.canon_stats.nodes_after, + pipeline_output.canon_stats.duplicates_merged, + pipeline_output.canon_stats.dead_nodes_dropped, ); - // ========================================================================= - // Step 4: Run symbolic verification - // ========================================================================= - // Enable constraint mode so that equality comparisons (==) on MleAst values - // register as constraints instead of returning bool. This captures the verifier's - // assert_eq! calls as circuit constraints. - enable_constraint_mode(); - - // Execute verification stages 1-7. This performs symbolic execution: - // - All field arithmetic (add, mul, sub, inv) builds up the AST - // - Poseidon hashes for Fiat-Shamir challenges are computed symbolically - // - Equality assertions are recorded as constraints - // - // Stages 1-7 cover all sumcheck verifications. Stage 8 (PCS/Hyrax verification) - // is not transpiled, it requires native curve operations in Gnark. - println!("\n=== Running Symbolic Verification (Stages 1-7) ==="); - match verifier.verify() { - Ok(()) => println!(" Verification completed successfully"), - Err(e) => { - println!(" Verification error: {e:?}"); - return; + // Loud, unmissable deploy guard. This binary produces a TEST artifact only; the + // generated circuit is NOT a sound on-chain verifier while any blocker remains + // (spec §7.12 no-partial-artifact rule) — the stage-8 (Dory PCS) blocker in + // particular stands until stage 8 is transpiled. + if !deployment_blockers.is_empty() { + eprintln!("\n!!! NOT DEPLOYABLE — generated circuit is a TEST artifact, not a verifier:"); + for blocker in &deployment_blockers { + eprintln!(" - {blocker}"); } + eprintln!(); } - // Collect the assertions that were registered during verification. - // Each assertion is an equality check: (lhs == rhs) becomes (lhs - rhs == 0). - // These become api.AssertIsEqual(lhs, rhs) calls in the Gnark circuit. - let assertions = take_assertions(); - println!("\n=== Accumulated Assertions ==="); - println!(" Total assertions: {}", assertions.len()); - - // ========================================================================= - // Step 5: Build AstBundle for code generation - // ========================================================================= - // AstBundle is the intermediate representation that bridges symbolic execution - // and code generation. It contains: - // - inputs: All symbolic variables (proof data that becomes circuit witness) - // - constraints: Equality assertions that the circuit must enforce - // - arena snapshot: The AST nodes representing all computed expressions - println!("\n=== Building AstBundle ==="); - let mut bundle = AstBundle::new(); - bundle.snapshot_arena(); - - // Register all symbolic variables as circuit inputs (witness values). - // These will become struct fields in the generated Gnark circuit. - // Use descriptions_with_fields() to include target field metadata. - for (idx, name, target_field) in var_alloc.descriptions_with_fields() { - bundle.add_input_with_field(*idx, name.clone(), WitnessType::ProofData, *target_field); - } - - // Early warning if non-native field arithmetic is needed (not yet implemented) - if bundle.has_inputs_for_field(TargetField::Fq) { - let fr_count = bundle.count_inputs_for_field(TargetField::Fr); - let fq_count = bundle.count_inputs_for_field(TargetField::Fq); + // Non-native (Fq) arithmetic has no codegen yet — bail cleanly here rather than + // warn and then panic deep inside codegen with no context. Stages 1-7 are + // Fr-only, so this never fires on a well-formed proof. (code-review #9) + if bundle_needs_non_native(&bundle) { eprintln!( - "⚠️ Bundle contains {fq_count} non-native field variables (and {fr_count} native Fr variables).", + "error: bundle contains non-native (Fq) field variables; Fq codegen is not \ + implemented. Stages 1-7 should be Fr-only — this indicates an unexpected proof." ); - eprintln!(" Non-native field codegen is not yet implemented. Codegen will panic."); - } - println!(" Inputs: {}", bundle.inputs.len()); - - // Register all equality assertions as circuit constraints. - // Each assertion (lhs == rhs) becomes api.AssertIsEqual(lhs, rhs) in Gnark. - for (i, assertion) in assertions.iter().enumerate() { - bundle.add_constraint_eq_zero(format!("assertion_{i}"), assertion.root()); + std::process::exit(1); } - println!(" Constraints: {}", bundle.constraints.len()); - - // Run global CSE: identify nodes shared across multiple constraints - // (primarily TranscriptHash chains) and hoist them to a single computation block. - bundle.run_global_cse(); - println!( - " Global CSE: {} nodes hoisted across constraints", - bundle.global_cse.bindings.len() - ); - - // Run per-constraint CSE (Common Subexpression Elimination) at the AST level. - // This pre-computes which nodes should be hoisted to named variables, - // making codegen simpler (just reads pre-computed decisions). - // Nodes already in global CSE are excluded. - bundle.run_cse(); - println!( - " Per-constraint CSE bindings: {} total across {} constraints", - bundle - .constraint_cse - .iter() - .map(|c| c.bindings.len()) - .sum::(), - bundle.constraint_cse.len() - ); - // ========================================================================= // Step 6: Resolve output directory and write bundle - // ========================================================================= let default_output_dir = match args.target { TranspilationTarget::Gnark => PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("go"), TranspilationTarget::AstBundle => PathBuf::from("."), }; let output_dir = args.output_dir.clone().unwrap_or(default_output_dir); + std::fs::create_dir_all(&output_dir) + .unwrap_or_else(|e| panic!("Failed to create output directory {output_dir:?}: {e}")); // Save bundle to JSON (common to all targets) let bundle_path = output_dir.join(BUNDLE_FILENAME); @@ -453,9 +230,7 @@ fn main() { .unwrap_or_else(|e| panic!("Failed to write bundle file {bundle_path:?}: {e}")); println!(" Bundle written to: {bundle_path:?}"); - // ========================================================================= // Step 7: Target-specific code generation - // ========================================================================= match args.target { TranspilationTarget::Gnark => { // Generate gnark circuit @@ -499,9 +274,25 @@ fn main() { let witness_values = var_alloc.witness_values(); let mut witness_map: HashMap = HashMap::new(); + // Track the original (pre-sanitization) name behind each key so a + // collision can name both culprits. `sanitize_go_name` is many-to-one, + // so two distinct descriptions can map to the same Go field name; a + // silent overwrite would feed the circuit a wrong witness value. + let mut origin: HashMap = HashMap::new(); for (idx, name, _target_field) in var_alloc.descriptions_with_fields() { let sanitized = gnark_codegen::sanitize_go_name(name); if let Some(value) = witness_values.get(&(*idx as usize)) { + if let Some(existing) = witness_map.get(&sanitized) { + if existing != value { + let prev = origin.get(&sanitized).map(String::as_str).unwrap_or("?"); + panic!( + "witness key collision: sanitize_go_name mapped distinct \ + descriptions {prev:?} and {name:?} to the same Go field \ + {sanitized:?} with different values ({existing} != {value})" + ); + } + } + origin.insert(sanitized.clone(), name.clone()); witness_map.insert(sanitized, value.clone()); } } diff --git a/transpiler/src/narg_parser.rs b/transpiler/src/narg_parser.rs new file mode 100644 index 0000000000..8d3e34c168 --- /dev/null +++ b/transpiler/src/narg_parser.rs @@ -0,0 +1,214 @@ +//! Offline splitter for the spongefish NARG byte-string (non-ZK proofs). +//! +//! In non-ZK mode the NARG is a pure concatenation of self-delimiting frames +//! (8-byte LE length prefix + body): jolt-core writes NARG frames via THREE +//! operations (`jolt-core/src/transcript_msgs.rs` is the authoritative +//! inventory) — `write_scalars` for sumcheck/uni-skip round polys, +//! `write_commitments` for the witness + advice-presence frames, and +//! `write_slice` for the ZK/BlindFold data (out of scope here, non-ZK only). +//! All three produce `BytesMsg`-identical 8-byte-LE length framing, which is +//! why uniform frame-splitting is sound; absorbs and challenge squeezes append +//! no bytes. Frame *meaning* is deliberately not assigned here — it comes from +//! the symbolic replay's read order, which is the single source of protocol +//! truth (`notes/transpiler-deviations-from-spec.md` TDEV-1). + +use std::fmt; + +use jolt_transcript::read_length_prefixed_body; + +/// Width of the `BytesMsg` little-endian length prefix +/// (`crates/jolt-transcript/src/codec.rs`). +pub const FRAME_LEN_PREFIX_BYTES: usize = 8; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NargParseError { + /// Guardrail 4 (spec §16): a ZK NARG carries extra frames (Pedersen round + /// commitments, per-round degrees, output-claim commitments, BlindFold data). + /// It would *split* fine — which is exactly why it must be refused explicitly: + /// the non-ZK replay would silently mis-assign its frames. + ZkProofUnsupported, + /// Fewer than 8 bytes remained where a frame length prefix was expected. + TruncatedLengthPrefix { offset: usize, remaining: usize }, + /// A frame body extends past the end of the NARG. + TruncatedFrameBody { + offset: usize, + expected: u64, + remaining: usize, + }, +} + +impl fmt::Display for NargParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ZkProofUnsupported => write!( + f, + "ZK proofs are not supported by the transpiler (non-ZK only; spec §16 guardrail 4)" + ), + Self::TruncatedLengthPrefix { offset, remaining } => write!( + f, + "truncated NARG: {remaining} byte(s) at offset {offset}, expected an 8-byte frame length prefix" + ), + Self::TruncatedFrameBody { + offset, + expected, + remaining, + } => write!( + f, + "truncated NARG: frame at offset {offset} declares {expected} byte(s) but only {remaining} remain" + ), + } + } +} + +impl std::error::Error for NargParseError {} + +/// The NARG split into its ordered frames. Holds raw frame bodies only; +/// interpretation happens at replay time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedNarg { + frames: Vec>, +} + +impl ParsedNarg { + pub fn frames(&self) -> &[Vec] { + &self.frames + } + + pub fn into_frames(self) -> Vec> { + self.frames + } + + /// Reassemble the exact original NARG bytes (T1 acceptance: byte-exact + /// round-trip). + pub fn reserialize(&self) -> Vec { + let total: usize = self + .frames + .iter() + .map(|f| FRAME_LEN_PREFIX_BYTES + f.len()) + .sum(); + let mut out = Vec::with_capacity(total); + for frame in &self.frames { + out.extend_from_slice(&(frame.len() as u64).to_le_bytes()); + out.extend_from_slice(frame); + } + out + } +} + +/// Split a non-ZK NARG into its frames. `zk_mode` must come from the proof's +/// `zk_mode` field; `true` is refused (see [`NargParseError::ZkProofUnsupported`]). +/// Every byte must belong to a complete frame — trailing or truncated bytes are +/// errors (the offline analogue of the verifier's `check_eof`). +pub fn parse_narg(narg: &[u8], zk_mode: bool) -> Result { + if zk_mode { + return Err(NargParseError::ZkProofUnsupported); + } + let mut frames = Vec::new(); + let mut cursor = narg; + while !cursor.is_empty() { + let offset = narg.len() - cursor.len(); + let remaining = cursor.len(); + if remaining < FRAME_LEN_PREFIX_BYTES { + return Err(NargParseError::TruncatedLengthPrefix { offset, remaining }); + } + #[expect(clippy::unwrap_used)] // 8-byte slice into [u8; 8] is infallible + let declared_len = u64::from_le_bytes(cursor[..FRAME_LEN_PREFIX_BYTES].try_into().unwrap()); + let frame = read_length_prefixed_body(&mut cursor).map_err(|_| { + NargParseError::TruncatedFrameBody { + offset, + expected: declared_len, + remaining: remaining - FRAME_LEN_PREFIX_BYTES, + } + })?; + frames.push(frame.to_vec()); + } + Ok(ParsedNarg { frames }) +} + +#[cfg(test)] +#[expect(clippy::unwrap_used)] +mod tests { + use super::*; + use ark_bn254::Fr; + use ark_std::test_rng; + use jolt_core::field::JoltField; + use jolt_core::transcript_msgs::ProverFs; + use jolt_transcript::{prover_transcript, Blake2b512}; + + /// Frames produced by the REAL jolt-transcript `write_slice` path split and + /// round-trip byte-exactly. + #[test] + fn real_write_slice_frames_round_trip() { + let mut rng = test_rng(); + let frame_a: Vec = (0..3).map(|_| Fr::random(&mut rng)).collect(); + let frame_b: Vec = vec![Fr::random(&mut rng)]; + let frame_c: Vec = (0..7).map(|_| Fr::random(&mut rng)).collect(); + + let mut p = prover_transcript(b"narg-parser-test", [3u8; 32], Blake2b512::default()); + ProverFs::::write_slice(&mut p, &frame_a); + ProverFs::::write_slice(&mut p, &frame_b); + ProverFs::::write_slice(&mut p, &frame_c); + // An empty frame (e.g. the untrusted-advice "absent" presence frame). + ProverFs::::write_slice::(&mut p, &[]); + let narg = p.narg_string().to_vec(); + + let parsed = parse_narg(&narg, false).unwrap(); + assert_eq!(parsed.frames().len(), 4); + assert_eq!(parsed.frames()[0].len(), 3 * 32); + assert_eq!(parsed.frames()[1].len(), 32); + assert_eq!(parsed.frames()[2].len(), 7 * 32); + assert!(parsed.frames()[3].is_empty()); + assert_eq!(parsed.reserialize(), narg, "round-trip must be byte-exact"); + } + + #[test] + fn empty_narg_parses_to_zero_frames() { + let parsed = parse_narg(&[], false).unwrap(); + assert_eq!(parsed.frames().len(), 0); + assert!(parsed.reserialize().is_empty()); + } + + #[test] + fn zk_mode_is_refused() { + assert_eq!( + parse_narg(&[], true), + Err(NargParseError::ZkProofUnsupported) + ); + } + + #[test] + fn truncated_length_prefix_is_rejected() { + let narg = [1u8, 2, 3]; // < 8 bytes of prefix + assert_eq!( + parse_narg(&narg, false), + Err(NargParseError::TruncatedLengthPrefix { + offset: 0, + remaining: 3 + }) + ); + } + + #[test] + fn truncated_frame_body_is_rejected() { + let mut narg = 5u64.to_le_bytes().to_vec(); + narg.extend_from_slice(&[0u8; 2]); // body declares 5, only 2 present + assert_eq!( + parse_narg(&narg, false), + Err(NargParseError::TruncatedFrameBody { + offset: 0, + expected: 5, + remaining: 2 + }) + ); + } + + /// A huge declared length must error cleanly, not allocate. + #[test] + fn adversarial_length_does_not_allocate() { + let narg = u64::MAX.to_le_bytes().to_vec(); + assert!(matches!( + parse_narg(&narg, false), + Err(NargParseError::TruncatedFrameBody { .. }) + )); + } +} diff --git a/transpiler/src/pipeline.rs b/transpiler/src/pipeline.rs new file mode 100644 index 0000000000..15f5518147 --- /dev/null +++ b/transpiler/src/pipeline.rs @@ -0,0 +1,484 @@ +//! The symbolic transpilation pipeline as a callable function, so both the +//! `transpiler` binary and the integration test (`tests/symbolic_pipeline.rs`) +//! drive the *identical* path: symbolize the proof's structural parts, replay +//! the NARG frames through `TranspilableVerifier` stages 1–7 over the symbolic +//! `SymbolicVerifierFs` + `AstOpeningAccumulator`, and build the `AstBundle`. +//! +//! Non-ZK only (spec §16/§17): `symbolize_proof` refuses `zk_mode`. + +use std::cell::RefCell; +use std::rc::Rc; + +use ark_bn254::Fr; +use jolt_core::curve::Bn254Curve; +use jolt_core::field::JoltField; +use jolt_core::poly::commitment::dory::{DoryCommitmentScheme, DoryContext, DoryGlobals}; +use jolt_core::zkvm::fiat_shamir_instance; +use jolt_core::zkvm::transpilable_verifier::TranspilableVerifier; +use jolt_core::zkvm::verifier::JoltVerifierPreprocessing; +use jolt_core::zkvm::RV64IMACProof; +use zklean_extractor::mle_ast::{ + disable_constraint_mode, enable_constraint_mode, take_constraints as take_assertions, + AstBundle, MleAst, TargetField, WitnessType, +}; +use zklean_extractor::AstCommitment; + +use crate::symbolic_proof::{symbolize_proof, SymbolizedProof, VarAllocator}; +use crate::symbolic_traits::{FrameLabel, SymbolicVerifierFs}; +use crate::{AstCommitmentScheme, AstCurve, AstOpeningAccumulator}; + +type RealPreprocessing = JoltVerifierPreprocessing; + +#[derive(Debug)] +pub enum PipelineError { + Symbolize(crate::narg_parser::NargParseError), + VerifierConstruction(jolt_core::utils::errors::ProofVerifyError), + Replay(jolt_core::utils::errors::ProofVerifyError), + /// Replay finished but left NARG frames unread — a frame/read-order mismatch. + UnconsumedFrames(usize), + /// The witness allocator's `Rc` was still shared after replay (a symbolizer + /// closure or `fs` clone outlived the replay) — an internal invariant violation. + VarAllocatorStillShared, + /// The build's `transcript-poseidon` feature is off, so jolt-core proves/verifies + /// under a byte sponge while the symbolic mirror models the field-aligned Poseidon + /// sponge — every circuit challenge would diverge from the native verifier's. + WrongSpongeFeature, + /// The single-threaded rayon pool the replay runs in could not be built. + ThreadPool(rayon::ThreadPoolBuildError), +} + +impl std::fmt::Display for PipelineError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Symbolize(e) => write!(f, "symbolize_proof failed: {e}"), + Self::VerifierConstruction(e) => write!(f, "verifier construction failed: {e:?}"), + Self::Replay(e) => write!(f, "symbolic replay failed: {e:?}"), + Self::UnconsumedFrames(n) => { + write!(f, "{n} NARG frame(s) left unconsumed after stage 7") + } + Self::VarAllocatorStillShared => { + write!( + f, + "var_alloc Rc still shared after replay (internal invariant)" + ) + } + Self::WrongSpongeFeature => { + write!( + f, + "transpiler built without the `transcript-poseidon` feature: jolt-core's \ + transcript is a byte sponge, but the symbolic mirror models the \ + field-aligned Poseidon sponge — circuit challenges would diverge from \ + the native verifier. Rebuild with `--features transcript-poseidon` \ + (and a proof generated under it)." + ) + } + Self::ThreadPool(e) => { + write!(f, "failed to build the single-threaded replay pool: {e}") + } + } + } +} + +impl std::error::Error for PipelineError {} + +/// Result of a symbolic pipeline run. +pub struct PipelineOutput { + pub bundle: AstBundle, + pub var_alloc: VarAllocator, + pub num_assertions: usize, + pub num_frames: usize, + /// Tier-2 canonicalization counters (`canonicalize_and_sweep`), printed by the binary. + pub canon_stats: zklean_extractor::ast_bundle::CanonicalizeStats, + /// `true` iff the symbolic sponge layout is value-faithful to the native + /// transcript — the circuit's Fiat-Shamir challenges match the real verifier's. + /// Driven by the layout's `SymbolicSpongeLayout::FAITHFUL` const; `true` with + /// the field-aligned layout (T-O5), gated by the `poseidon_model` + + /// `field_aligned_layout_matches_native_sponge` differential tests. + /// (A1 guard — deviations-doc TDEV-6.) + pub sponge_faithful: bool, + /// Root NodeId (into `bundle.nodes`) of every Fiat-Shamir challenge the symbolic + /// replay squeezed, in squeeze order (`SymbolicVerifierFs::squeezed_challenges`). + /// Stored as post-`canonicalize_and_sweep` NodeIds — the pass remaps them along + /// with the constraint roots, so they stay valid against the compacted arena. + /// Consumed by the in-CI real-proof challenge differential in + /// `tests/symbolic_pipeline.rs`, which evaluates each AST against the witness and + /// asserts equality with the native verifier's challenge sequence on the same proof. + pub squeezed_challenge_roots: Vec, +} + +impl PipelineOutput { + /// Human-readable reasons this bundle must NOT be deployed as an on-chain verifier + /// (empty ⇒ no known blocker). The transpiler binary prints these prominently; a + /// future deploy path MUST hard-refuse while any remain. Mirrors the spec's + /// no-partial-artifact rule (§7.12 / §8 Phase 3). + pub fn deployment_blockers(&self) -> Vec<&'static str> { + let mut blockers = Vec::new(); + if !self.sponge_faithful { + blockers.push( + "Fiat-Shamir sponge layout is not value-faithful; circuit challenges \ + diverge from the native verifier.", + ); + } + if !cfg!(feature = "transcript-poseidon") { + // Unreachable through `run_symbolic_pipeline` (it errors with + // `WrongSpongeFeature` first); kept so a `PipelineOutput` can never claim + // deployability under a non-Poseidon build. + blockers.push( + "built without `transcript-poseidon`: jolt-core's transcript is a byte \ + sponge, the symbolic mirror models Poseidon — challenges diverge.", + ); + } + // Structural: stages 1-7 only — the deferred stage-8 PCS check is what binds the + // committed polynomials and final opening claims (spec §7.12 no-partial-artifact). + blockers.push( + "stage 8 (Dory PCS) is not transpiled; committed polynomials and final opening \ + claims are unbound (spec §7.12 / §8 Phase 3).", + ); + blockers + } +} + +/// Run the full symbolic transpilation pipeline (stages 1–7) over a real non-ZK +/// proof, producing the `AstBundle` and witness allocator. +/// +/// `real_trusted_advice` is the host-side `Option` (symbolized +/// internally if present), matching the binary's CLI-loaded commitment. +/// +/// The replay drives jolt-core verifier code whose internal `par_iter` reductions +/// (e.g. `EqPolynomial::mle`) allocate AST nodes into the process-global arena; +/// under a multi-threaded rayon pool the allocation ORDER — and therefore every +/// NodeId, the `gcse[i]` slot assignment, and the emitted Go circuit — is +/// scheduling-dependent and permutes between runs (so would the Groth16 keys +/// compiled from it). The whole pipeline therefore runs inside a single-threaded +/// rayon pool, which makes node creation deterministic. Running the entire body +/// on the pool's one worker thread also keeps every thread-local hook pair +/// (read symbolizer, pending initial RAM, constraint mode + accumulated +/// assertions) set and consumed on the same thread. +pub fn run_symbolic_pipeline( + real_proof: &RV64IMACProof, + io_device: common::jolt_device::JoltDevice, + real_preprocessing: &RealPreprocessing, + real_trusted_advice: Option<::Commitment>, +) -> Result { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(1) + // 64MiB worker stack: the replay previously ran on the ~8MiB main + // stack, but rayon workers default to ~2MiB — and this codebase has + // known stack-depth sensitivity (deep recursive verifier/AST paths; + // cf. the RUST_MIN_STACK=1GiB requirement for the opt-0 ZK suite). + .stack_size(64 * 1024 * 1024) + .build() + .map_err(PipelineError::ThreadPool)?; + pool.install(|| { + run_symbolic_pipeline_inner( + real_proof, + io_device, + real_preprocessing, + real_trusted_advice, + ) + }) +} + +fn run_symbolic_pipeline_inner( + real_proof: &RV64IMACProof, + io_device: common::jolt_device::JoltDevice, + real_preprocessing: &RealPreprocessing, + real_trusted_advice: Option<::Commitment>, +) -> Result { + // The symbolic sponge mirrors the field-aligned `U = Fr` Poseidon transcript; a + // non-Poseidon jolt-core build proves/verifies under a byte sponge, so the mirror + // (and any bundle built from it) would be challenge-divergent garbage. A runtime + // error — not `compile_error!` — because the lib must still compile featureless + // (workspace clippy builds the transpiler without transcript features). + if !cfg!(feature = "transcript-poseidon") { + return Err(PipelineError::WrongSpongeFeature); + } + let mut var_alloc = VarAllocator::new(); + let SymbolizedProof { + parsed_narg, + accumulator, + proof_data, + } = symbolize_proof(real_proof, &mut var_alloc).map_err(PipelineError::Symbolize)?; + + let symbolic_trusted_advice = real_trusted_advice.as_ref().map(|commitment| { + let chunks = var_alloc.alloc_commitment(commitment, "trusted_advice_commitment"); + AstCommitment::new(chunks) + }); + + // Symbolize IO + set the initial-RAM override (consumed by the verifier math). + let (eval_input_words, _eval_output_words) = + crate::symbolize::symbolize_io_device(&io_device, &mut var_alloc); + { + use jolt_core::zkvm::ram::{set_pending_initial_ram, PendingInitialRamValues}; + let bytecode_words: Vec = if real_preprocessing.shared.program.is_full() { + #[expect(clippy::unwrap_used)] + real_preprocessing + .shared + .program + .as_full() + .unwrap() + .ram + .bytecode_words + .iter() + .map(|&w| MleAst::from_u64(w)) + .collect() + } else { + Vec::new() + }; + set_pending_initial_ram(PendingInitialRamValues { + bytecode_words, + input_words: eval_input_words, + }); + } + + let symbolic_preprocessing = build_symbolic_preprocessing(real_preprocessing, &mut var_alloc); + + let mut verifier = + TranspilableVerifier::::new( + &symbolic_preprocessing, + proof_data, + io_device, + symbolic_trusted_advice, + accumulator, + ) + .map_err(PipelineError::VerifierConstruction)?; + + let preprocessing_digest = real_preprocessing.shared.digest(); + let instance = fiat_shamir_instance( + &verifier.program_io, + real_proof.ram_K, + real_proof.trace_length, + real_preprocessing.shared.program_meta.entry_address, + &real_proof.rw_config, + &real_proof.one_hot_config, + real_proof.dory_layout, + &preprocessing_digest, + ); + + // T-O5 field-aligned sponge: the circuit's Fiat-Shamir challenges reproduce the + // native `U = Fr` `PoseidonSponge` value-exactly (verified by the differential + // gates: `poseidon_model::model_matches_real_field_aligned_poseidon_transcript` + // level 1, `field_aligned_layout_matches_native_sponge` level 2). The A1 guard + // (deviations-doc TDEV-6) is driven by the layout's `FAITHFUL` const, so swapping + // the layout type is the single switch — and would auto-re-arm the warning if + // a non-faithful layout were ever substituted. + type Layout = crate::symbolic_traits::FieldAlignedLayout; + let sponge_faithful = ::FAITHFUL; + if !sponge_faithful { + eprintln!( + "WARNING: symbolic sponge layout is not value-faithful; the circuit's \ + Fiat-Shamir challenges DIVERGE from the native verifier — bundle must NOT \ + be deployed on-chain (oracle: poseidon_model.rs)." + ); + } + + let var_alloc = Rc::new(RefCell::new(var_alloc)); + let layout = Layout::new(b"Jolt", &instance); + let mut fs = SymbolicVerifierFs::new(layout, parsed_narg, Rc::clone(&var_alloc)); + let num_frames = fs.remaining_frames(); + + // RAII so the process-global constraint mode is disabled on EVERY exit path — + // including the `?` early returns below (replay error / unconsumed frames) — not + // just the happy path. (code-review #10: the explicit disable leaked on error.) + struct ConstraintModeGuard; + impl Drop for ConstraintModeGuard { + fn drop(&mut self) { + disable_constraint_mode(); + } + } + enable_constraint_mode(); + let _constraint_mode = ConstraintModeGuard; + + let _dory_guard = DoryGlobals::initialize_context( + 1 << verifier.one_hot_params.log_k_chunk, + verifier.proof_data.trace_length.next_power_of_two(), + DoryContext::Main, + Some(verifier.proof_data.dory_layout), + ); + + let replay = (|| -> Result<(), jolt_core::utils::errors::ProofVerifyError> { + fs.set_label(FrameLabel::Prestage); + verifier.read_commitment_frames(&mut fs)?; + fs.set_label(FrameLabel::StageWithUniskip(1)); + verifier.verify_stage1(&mut fs)?; + fs.set_label(FrameLabel::StageWithUniskip(2)); + verifier.verify_stage2(&mut fs)?; + fs.set_label(FrameLabel::Rounds("stage3_sumcheck".into())); + verifier.verify_stage3(&mut fs)?; + fs.set_label(FrameLabel::Rounds("stage4_sumcheck".into())); + verifier.verify_stage4(&mut fs)?; + fs.set_label(FrameLabel::Rounds("stage5_sumcheck".into())); + verifier.verify_stage5(&mut fs)?; + let (bytecode_read_raf_params, booleanity_params) = { + fs.set_label(FrameLabel::Rounds("stage6a_sumcheck".into())); + verifier.verify_stage6a(&mut fs)? + }; + fs.set_label(FrameLabel::Rounds("stage6b_sumcheck".into())); + verifier.verify_stage6b(&mut fs, bytecode_read_raf_params, booleanity_params)?; + fs.set_label(FrameLabel::Rounds("stage7_sumcheck".into())); + verifier.verify_stage7(&mut fs)?; + Ok(()) + })(); + replay.map_err(PipelineError::Replay)?; + + let remaining = fs.remaining_frames(); + if remaining != 0 { + return Err(PipelineError::UnconsumedFrames(remaining)); + } + let squeezed_challenges = std::mem::take(&mut fs.squeezed_challenges); + drop(fs); + + let assertions = take_assertions(); + // (constraint mode is disabled by `_constraint_mode`'s Drop at scope exit.) + let num_assertions = assertions.len(); + + let mut bundle = AstBundle::new(); + bundle.snapshot_arena(); + let var_alloc = Rc::try_unwrap(var_alloc) + .map_err(|_| PipelineError::VarAllocatorStillShared)? + .into_inner(); + for (idx, name, target_field) in var_alloc.descriptions_with_fields() { + bundle.add_input_with_field(*idx, name.clone(), input_visibility(name), *target_field); + } + for (i, assertion) in assertions.iter().enumerate() { + bundle.add_constraint_eq_zero(format!("assertion_{i}"), assertion.root()); + } + // Tier-2 algebra passes (spec §5.1/§5.3): structural hash-consing + dead-node + // sweep, post-hoc on the snapshot, BEFORE the CSE passes (CSE bindings are + // computed on the canonicalized arena, so there is no stale-binding hazard). + // The squeezed-challenge roots ride along so the challenge-differential ASTs + // stay valid across the NodeId compaction. + let mut squeezed_challenge_roots: Vec<_> = + squeezed_challenges.iter().map(|c| c.root()).collect(); + let canon_stats = bundle.canonicalize_and_sweep(&mut squeezed_challenge_roots); + bundle.run_global_cse(); + bundle.run_cse(); + + Ok(PipelineOutput { + bundle, + var_alloc, + num_assertions, + num_frames, + canon_stats, + sponge_faithful, + squeezed_challenge_roots, + }) +} + +/// Circuit visibility for a symbolic input, keyed on the frozen Era-2 witness names +/// (`verifier_fs::FrameLabel`, `symbolize`, `symbolic_proof`). +/// +/// `PublicStatement` (emitted as a gnark public input) is reserved for the program IO +/// that forms the public statement and the stage-8 binding values — opening claims and +/// polynomial/advice/trusted commitments — that the deferred native PCS check must see. +/// Everything else (sumcheck round polynomials, uni-skip coefficients) is `ProofData` +/// (secret witness): the circuit re-derives every Fiat-Shamir challenge from those +/// bytes in-circuit, so they are self-binding and need not be public. This is what +/// keeps the on-chain public-input count (and thus gas) bounded. +fn input_visibility(name: &str) -> WitnessType { + const PUBLIC_PREFIXES: [&str; 5] = [ + "io_", + "claim_", + "commitment_", + "untrusted_advice_commitment", + "trusted_", + ]; + // Self-binding proof bytes: sumcheck round polynomials and uni-skip coefficients, + // named from the `FrameLabel::{StageWithUniskip,Rounds}` prefixes (`stage{n}_…`) or a + // `Prestage` overflow frame (`prestage_f{k}_…`). + // + // WARNING: the bare "stage" prefix shadows ANY future `stage8_*` witness name — + // a stage-8 public binding value named through the `FrameLabel` scheme would be + // silently classified as secret proof data, unbindable by the on-chain wrapper. + // The stage-8 PR must add an explicit carve-out for its public binding values + // BEFORE reusing the FrameLabel naming scheme (spec §6.2). + const SECRET_PREFIXES: [&str; 2] = ["stage", "prestage"]; + if PUBLIC_PREFIXES.iter().any(|p| name.starts_with(p)) { + WitnessType::PublicStatement + } else if SECRET_PREFIXES.iter().any(|p| name.starts_with(p)) { + WitnessType::ProofData + } else { + // Fail loud rather than silently flip a renamed input to the wrong visibility: + // a public commitment/claim leaking to `secret` would be unbindable by the future + // on-chain wrapper, and proof data promoted to `public` would blow up gas. A new + // witness name must be classified here explicitly. + panic!( + "input_visibility: unrecognized witness name {name:?}; add it to PUBLIC_PREFIXES \ + or SECRET_PREFIXES (did the frozen Era-2 naming scheme change?)" + ) + } +} + +/// True iff the bundle needs non-native (Fq) arithmetic, which codegen does not +/// yet support. +pub fn bundle_needs_non_native(bundle: &AstBundle) -> bool { + bundle.has_inputs_for_field(TargetField::Fq) +} + +/// Build the symbolic `JoltVerifierPreprocessing` (Dory generators → stub), +/// symbolizing trusted commitments in committed mode. +fn build_symbolic_preprocessing( + real_preprocessing: &RealPreprocessing, + var_alloc: &mut VarAllocator, +) -> JoltVerifierPreprocessing { + use jolt_core::zkvm::bytecode::TrustedBytecodeCommitments; + use jolt_core::zkvm::program::{ + CommittedProgramPreprocessing, ProgramPreprocessing, TrustedProgramCommitments, + }; + + let symbolic_program: ProgramPreprocessing = match &real_preprocessing + .shared + .program + { + ProgramPreprocessing::Full(full) => ProgramPreprocessing::Full(full.clone()), + ProgramPreprocessing::Committed(committed) => { + let bytecode_commitments = TrustedBytecodeCommitments { + commitments: committed + .bytecode_commitments + .commitments + .iter() + .enumerate() + .map(|(i, c)| { + let chunks = + var_alloc.alloc_commitment(c, &format!("trusted_bytecode_{i}")); + AstCommitment::new(chunks) + }) + .collect(), + num_columns: committed.bytecode_commitments.num_columns, + log_k_chunk: committed.bytecode_commitments.log_k_chunk, + bytecode_chunk_count: committed.bytecode_commitments.bytecode_chunk_count, + bytecode_len: committed.bytecode_commitments.bytecode_len, + bytecode_T: committed.bytecode_commitments.bytecode_T, + }; + let program_commitments = TrustedProgramCommitments { + program_image_commitment: { + let chunks = var_alloc.alloc_commitment( + &committed.program_commitments.program_image_commitment, + "trusted_program_image", + ); + AstCommitment::new(chunks) + }, + program_image_num_columns: committed.program_commitments.program_image_num_columns, + program_image_num_words: committed.program_commitments.program_image_num_words, + }; + ProgramPreprocessing::Committed(CommittedProgramPreprocessing { + meta: committed.meta.clone(), + bytecode_commitments, + program_commitments, + }) + } + }; + + let symbolic_shared = jolt_core::zkvm::verifier::JoltSharedPreprocessing:: { + program_meta: symbolic_program.meta(), + program: symbolic_program, + memory_layout: real_preprocessing.shared.memory_layout.clone(), + max_padded_trace_length: real_preprocessing.shared.max_padded_trace_length, + bytecode_chunk_count: real_preprocessing.shared.bytecode_chunk_count, + }; + JoltVerifierPreprocessing::new( + symbolic_shared, + crate::symbolic_traits::ast_commitment_scheme::AstVerifierSetup, + None, + ) +} diff --git a/transpiler/src/poseidon_model.rs b/transpiler/src/poseidon_model.rs new file mode 100644 index 0000000000..83ebe25e83 --- /dev/null +++ b/transpiler/src/poseidon_model.rs @@ -0,0 +1,258 @@ +//! Concrete reference model of jolt-transcript's field-aligned `PoseidonSponge` +//! (`U = Fr`, spec §4), used to VERIFY the unit schedule the in-circuit +//! `FieldAlignedLayout` must reproduce (spec §4.5 / T-O5). +//! +//! The model re-implements the compression-chain sponge of +//! `crates/jolt-transcript/src/poseidon.rs` over concrete `Fr` (one `Fr` +//! state; `permute(a, b)` = width-4 Circom Poseidon over `[state, a, b]`; +//! absorb feeds unit pairs zero-padding an odd tail; one squeezed unit = one +//! permute, challenge = the new state). The message-level unit encodings are +//! IMPORTED from `jolt_transcript` (`push_byte_rule_units`, +//! `push_field_frame_units`, `push_commitments_frame_header`, +//! `poseidon_domain_separator_msgs`) so the model cannot drift from the +//! typed codec it mirrors. +//! +//! Two-level verification (spec §10.1, same shape as the T6 gate it +//! replaces): the differential test below proves the model derives the SAME +//! challenges as a real spongefish `ProverState` driven +//! through the typed `ProverFs`/`FsAbsorb` vocabulary; the symbolic-layout +//! test in `symbolic_traits::verifier_fs` then proves `FieldAlignedLayout`'s +//! challenge ASTs evaluate to the model's challenges. + +use ark_bn254::Fr; +use ark_ff::Zero; +use jolt_transcript::{ + poseidon_domain_separator_msgs, push_byte_rule_units, push_commitments_frame_header, + push_field_frame_units, +}; +use light_poseidon::{Poseidon, PoseidonHasher}; + +/// Concrete reimplementation of the `U = Fr` `PoseidonSponge` compression +/// chain. +pub struct ConcreteFieldSponge { + hasher: Poseidon, + state: Fr, +} + +impl ConcreteFieldSponge { + #[expect(clippy::expect_used)] + pub fn new() -> Self { + Self { + hasher: Poseidon::::new_circom(3).expect("width-4 init"), + state: Fr::zero(), + } + } + + #[expect(clippy::expect_used)] + fn permute(&mut self, a: Fr, b: Fr) { + self.state = self + .hasher + .hash(&[self.state, a, b]) + .expect("poseidon hash"); + } + + /// One `DuplexSpongeInterface::absorb(units)`: unit pairs fed through the + /// permutation, zero-padding an odd tail. Every call starts a fresh pair + /// (the sponge has no buffering), exactly like the real sponge. + pub fn absorb_units(&mut self, units: &[Fr]) { + for pair in units.chunks(2) { + let a = pair[0]; + let b = pair.get(1).copied().unwrap_or_else(Fr::zero); + self.permute(a, b); + } + } + + /// One squeezed native unit = exactly one permute; the challenge is the + /// new state (`NativeChallenge` identity decode). + pub fn squeeze_unit(&mut self) -> Fr { + self.permute(Fr::zero(), Fr::zero()); + self.state + } +} + +impl Default for ConcreteFieldSponge { + fn default() -> Self { + Self::new() + } +} + +/// One high-level transcript op, as the verifier issues it through the typed +/// `FsAbsorb`/`ProverFs`/`VerifierFs` vocabulary. Each op's unit encoding is +/// the codec's (spec §4.2): +#[derive(Clone, Debug)] +pub enum HighLevelOp { + /// `absorb` / `absorb_bytes` / `absorb_commitment` / `absorb_commitment_bytes` + /// / `read_slice`: the byte rule `[Fr(2L), ceil(L/31) 31-byte-LE chunks]` + /// over the value's bytes ([`jolt_transcript::RawBytesMsg`]). + AbsorbBytes(Vec), + /// `absorb_scalar(s)` / `write_scalars`+`read_scalars`: the count-led + /// field frame `[Fr(2k+1), e₁, …, e_k]` ([`jolt_transcript::FieldFrameMsg`]). + AbsorbScalars(Vec), + /// `write_commitments`+`read_commitments`: frame count unit `Fr(2k+1)` + /// (zero-padded to a pair) then one byte-rule group per commitment's + /// canonical compressed bytes ([`jolt_transcript::CommitmentsMsg`]). + AbsorbCommitments(Vec>), + /// One full-field `Fr` challenge: one squeezed native unit. + ChallengeFr, +} + +impl HighLevelOp { + /// The complete unit stream (leading tag + payload + even padding) this + /// op absorbs, built with the codec's exported unit builders. + pub fn units(&self) -> Vec { + let mut units = Vec::new(); + match self { + Self::AbsorbBytes(bytes) => push_byte_rule_units(&mut units, bytes), + Self::AbsorbScalars(elems) => push_field_frame_units(&mut units, elems), + Self::AbsorbCommitments(groups) => { + push_commitments_frame_header(&mut units, groups.len()); + for bytes in groups { + push_byte_rule_units(&mut units, bytes); + } + } + Self::ChallengeFr => unreachable!("ChallengeFr is a squeeze, not an absorb"), + } + units + } +} + +/// Replay a sequence of high-level ops through the concrete model, returning +/// the `Fr` challenges. Construction seeds the sponge with the SAME three +/// domain-separator byte strings the native factories absorb +/// ([`poseidon_domain_separator_msgs`]), each under the byte rule. +pub fn model_challenges(session: &[u8], instance: &[u8; 32], ops: &[HighLevelOp]) -> Vec { + let mut sponge = ConcreteFieldSponge::new(); + for msg in poseidon_domain_separator_msgs(session, *instance) { + sponge.absorb_units(HighLevelOp::AbsorbBytes(msg.0).units().as_slice()); + } + let mut out = Vec::new(); + for op in ops { + match op { + HighLevelOp::ChallengeFr => out.push(sponge.squeeze_unit()), + absorb => sponge.absorb_units(&absorb.units()), + } + } + out +} + +#[cfg(test)] +#[expect(clippy::unwrap_used)] +mod tests { + use super::*; + use ark_ff::UniformRand; + use jolt_core::transcript_msgs::{FsAbsorb, FsChallenge, ProverFs}; + use jolt_transcript::{prover_transcript, PoseidonSponge}; + + /// THE LEVEL-1 ORACLE GATE (spec §10.1): the concrete model reproduces a + /// real `ProverState`'s `Fr` challenges across a mixed + /// schedule driven through the typed `ProverFs`/`FsAbsorb` vocabulary — + /// single scalars, scalar frames, commitment frames (incl. the empty + /// advice-presence frame), a lone commitment absorb, raw byte messages, + /// and back-to-back challenges. Proves the field-aligned unit schedule + /// (domain sep + tagged messages + 1-permute squeeze) is understood + /// exactly; the in-circuit `FieldAlignedLayout` targets this oracle. + #[test] + fn model_matches_real_field_aligned_poseidon_transcript() { + let mut rng = ark_std::test_rng(); + let session = b"Jolt"; + let instance = [0x5Cu8; 32]; + + let scalars: Vec = (0..3).map(|_| Fr::rand(&mut rng)).collect(); + let frame: Vec = (0..5).map(|_| Fr::rand(&mut rng)).collect(); + // Dory commitments are GT (Fq12) elements: 384 canonical bytes each. + let commitments: Vec = + (0..2).map(|_| ark_bn254::Fq12::rand(&mut rng)).collect(); + let ser = |c: &ark_bn254::Fq12| { + let mut b = Vec::new(); + ark_serialize::CanonicalSerialize::serialize_compressed(c, &mut b).unwrap(); + assert_eq!(b.len(), 384, "Dory GT must serialize to 384 bytes"); + b + }; + + let mut real = prover_transcript(session, instance, PoseidonSponge::default()); + let mut ops: Vec = Vec::new(); + let mut real_challenges: Vec = Vec::new(); + let challenge = |real: &mut _, ops: &mut Vec| { + let c: Fr = FsChallenge::::challenge_field(real); + ops.push(HighLevelOp::ChallengeFr); + c + }; + + // Commitments frame (write_commitments → read_commitments). + ProverFs::::write_commitments(&mut real, &commitments); + ops.push(HighLevelOp::AbsorbCommitments( + commitments.iter().map(ser).collect(), + )); + // Empty commitments frame (the absent untrusted-advice presence frame). + ProverFs::::write_commitments::(&mut real, &[]); + ops.push(HighLevelOp::AbsorbCommitments(Vec::new())); + real_challenges.push(challenge(&mut real, &mut ops)); + + // Single-scalar absorbs (sumcheck input claims / flushed opening claims). + for s in &scalars { + FsAbsorb::absorb_scalar(&mut real, s); + ops.push(HighLevelOp::AbsorbScalars(vec![*s])); + } + real_challenges.push(challenge(&mut real, &mut ops)); + + // A scalar frame (write_scalars → read_scalars) then two back-to-back + // challenges. + ProverFs::::write_scalars(&mut real, &frame); + ops.push(HighLevelOp::AbsorbScalars(frame.clone())); + real_challenges.push(challenge(&mut real, &mut ops)); + real_challenges.push(challenge(&mut real, &mut ops)); + + // A multi-scalar absorb (absorb_scalars: one count-led frame). + FsAbsorb::absorb_scalars(&mut real, &scalars); + ops.push(HighLevelOp::AbsorbScalars(scalars.clone())); + real_challenges.push(challenge(&mut real, &mut ops)); + + // A lone commitment absorb (trusted commitments: byte rule, NO frame + // count) and a raw byte message. + FsAbsorb::absorb_commitment(&mut real, &commitments[0]); + ops.push(HighLevelOp::AbsorbBytes(ser(&commitments[0]))); + FsAbsorb::absorb_bytes(&mut real, b"jolt-model-test"); + ops.push(HighLevelOp::AbsorbBytes(b"jolt-model-test".to_vec())); + real_challenges.push(challenge(&mut real, &mut ops)); + + // An empty byte message ([Fr(0)] — distinct from the empty + // commitments frame's [Fr(1)] absorbed above). + FsAbsorb::absorb_bytes(&mut real, &[]); + ops.push(HighLevelOp::AbsorbBytes(Vec::new())); + real_challenges.push(challenge(&mut real, &mut ops)); + + let modeled = model_challenges(session, &instance, &ops); + assert_eq!( + modeled, real_challenges, + "field-aligned model diverged from the real PoseidonSponge transcript" + ); + } + + /// Negative control: a wrongly-regrouped schedule (scalar frame absorbed + /// without its count unit) must NOT match — the tagged encoding is + /// load-bearing, not coincidental. + #[test] + fn model_negative_control_untagged_frame_diverges() { + let mut rng = ark_std::test_rng(); + let session = b"Jolt"; + let instance = [0x5Cu8; 32]; + let frame: Vec = (0..4).map(|_| Fr::rand(&mut rng)).collect(); + + let mut real = prover_transcript(session, instance, PoseidonSponge::default()); + ProverFs::::write_scalars(&mut real, &frame); + let real_c: Fr = FsChallenge::::challenge_field(&mut real); + + // Untagged variant: absorb the raw elements with no leading count unit. + let mut sponge = ConcreteFieldSponge::new(); + for msg in poseidon_domain_separator_msgs(session, instance) { + sponge.absorb_units(HighLevelOp::AbsorbBytes(msg.0).units().as_slice()); + } + sponge.absorb_units(&frame); + let wrong_c = sponge.squeeze_unit(); + + assert_ne!( + wrong_c, real_c, + "untagged absorb also matched — the count tag would be vacuous" + ); + } +} diff --git a/transpiler/src/symbolic_proof.rs b/transpiler/src/symbolic_proof.rs index 54798fd796..786363cf42 100644 --- a/transpiler/src/symbolic_proof.rs +++ b/transpiler/src/symbolic_proof.rs @@ -1,68 +1,41 @@ -//! Convert a real JoltProof to a symbolic JoltProof for transpilation. +//! Symbolize a real `RV64IMACProof` for NARG-replay transpilation. //! //! # Overview //! -//! This module creates symbolic versions of proof data structures. During symbolic -//! execution, we need a `JoltProof` where each field element is replaced -//! with an `MleAst::Var(index)`, a unique symbolic variable. +//! Under the spongefish/NARG proof format, only the proof's *structural* parts are +//! symbolized here up front: //! -//! # Key Function +//! - the non-ZK `opening_claims` (named `claim_{OpeningId:?}`), pre-seeded into the +//! [`AstOpeningAccumulator`]; +//! - the structural scalars (`trace_length`, `ram_K`, configs) carried as +//! [`TranspilableProofData`]. //! -//! - [`symbolize_proof`]: Convert a concrete `RV64IMACProof` to symbolic form +//! Everything else (witness commitments, advice presence, uni-skip coefficients, +//! sumcheck round polynomials) lives in the NARG byte-string: it is split into frames +//! by [`crate::narg_parser::parse_narg`] and symbolized *lazily during replay* by +//! `SymbolicVerifierFs::read_slice`, which allocates witness variables from the real +//! frame bytes at the exact protocol position the verifier reads them. //! -//! # How It Works -//! -//! The `VarAllocator` simultaneously: -//! 1. Allocates fresh symbolic variables (`MleAst::Var(index)`) -//! 2. Records the corresponding concrete witness values -//! -//! This single-pass approach makes witness/symbolization mismatches structurally -//! impossible - both are recorded in the same function call. +//! The `VarAllocator` still guarantees the core invariant: symbolic variables and +//! their concrete witness values are recorded in the same call, making +//! witness/symbolization mismatches structurally impossible. //! //! # Commitment Serialization //! -//! Dory commitments are 384-byte elliptic curve points. They're split into 12 chunks -//! of 32 bytes each (to fit in BN254 field elements). The serialization uses: -//! -//! - `serialize_uncompressed` (not compressed) -//! - LE byte order (no reversal needed for circuit) -//! -//! Dory will probably be replaced in future iterations, -//! the transpilation code will need to be updated in that case. -//! -//! This must match exactly how the Poseidon transcript hashes commitments. +//! Dory commitments are 384-byte GT elements, split into 13 byte-rule chunks +//! (12 × 31 bytes + one 12-byte tail; each chunk < 2²⁴⁸ < r so the map to `Fr` +//! is injective — spec §4.2); `serialize_compressed` (== uncompressed for GT), +//! LE order, no reversal. MUST match `jolt_transcript`'s byte rule exactly. -use crate::symbolic_traits::ast_commitment_scheme::AstCommitmentScheme; #[cfg(not(feature = "zk"))] -use crate::symbolic_traits::ast_commitment_scheme::AstProof; -use crate::symbolic_traits::ast_curve::AstCurve; +use crate::narg_parser::parse_narg; +use crate::narg_parser::{NargParseError, ParsedNarg}; use crate::symbolic_traits::opening_accumulator::AstOpeningAccumulator; use ark_ff::PrimeField; use ark_serialize::CanonicalSerialize; -#[cfg(not(feature = "zk"))] -use jolt_core::curve::Bn254Curve; -#[cfg(not(feature = "zk"))] -use jolt_core::curve::JoltCurve; -#[cfg(not(feature = "zk"))] -use jolt_core::poly::opening_proof::OpeningPoint; -#[cfg(not(feature = "zk"))] -use jolt_core::poly::unipoly::CompressedUniPoly; -#[cfg(not(feature = "zk"))] -use jolt_core::subprotocols::sumcheck::SumcheckInstanceProof; -#[cfg(not(feature = "zk"))] -use jolt_core::subprotocols::univariate_skip::{ - UniSkipFirstRoundProof, UniSkipFirstRoundProofVariant, -}; -use jolt_core::transcripts::Transcript; -#[cfg(not(feature = "zk"))] -use jolt_core::zkvm::proof_serialization::Claims; -use jolt_core::zkvm::proof_serialization::JoltProof; +use jolt_core::zkvm::transpilable_verifier::TranspilableProofData; use jolt_core::zkvm::RV64IMACProof; -#[cfg(not(feature = "zk"))] -use std::collections::BTreeMap; use zklean_extractor::mle_ast::{MleAst, TargetField}; -#[cfg(not(feature = "zk"))] -use zklean_extractor::AstCommitment; /// Tracks variable index allocation and witness values during symbolization. /// @@ -79,14 +52,17 @@ use zklean_extractor::AstCommitment; /// /// The allocator records: /// - Human-readable descriptions for Go struct field names (e.g., `Stage1_Sumcheck_R0_0`) -/// - Concrete witness values as decimal strings (for JSON serialization to Go) +/// - Concrete witness values as native `Fr` (rendered to decimal strings on demand +/// for JSON serialization to Go) /// - Field kind per variable (Fr for native, Fq for emulated arithmetic) pub struct VarAllocator { next_idx: u16, /// (index, name, target_field) tuples for each allocated variable. descriptions: Vec<(u16, String, TargetField)>, - /// Witness values indexed by variable index, stored as decimal strings. - witness_values: Vec, + /// Witness values indexed by variable index, as native `Fr`: consumed by + /// in-process AST evaluation (the challenge differential in + /// `tests/symbolic_pipeline.rs`) and rendered to decimal by [`Self::witness_values`]. + witness_frs: Vec, } impl VarAllocator { @@ -94,7 +70,7 @@ impl VarAllocator { Self { next_idx: 0, descriptions: Vec::new(), - witness_values: Vec::new(), + witness_frs: Vec::new(), } } @@ -109,11 +85,11 @@ impl VarAllocator { /// /// # Arguments /// * `description`: Human-readable name for codegen - /// * `value`: Concrete witness value (as Fr, converted to decimal string) + /// * `value`: Concrete witness value (as Fr) /// * `target_field`: Target field (Fr for native, Fq for emulated) /// /// # Note - /// The value is stored as a decimal string regardless of field. + /// The value is stored as `Fr` regardless of field. /// For Fq values, ensure the value fits in the Fq modulus. pub fn alloc_with_value_and_field( &mut self, @@ -121,12 +97,21 @@ impl VarAllocator { value: &ark_bn254::Fr, target_field: TargetField, ) -> MleAst { - use ark_ff::PrimeField; let idx = self.next_idx; self.descriptions .push((idx, description.to_string(), target_field)); - self.witness_values.push(format!("{}", value.into_bigint())); - self.next_idx += 1; + self.witness_frs.push(*value); + // `MleAst::from_var` indexes witness variables with a `u16`. A NARG large + // enough to need >65 535 variables (very large traces) would otherwise wrap + // silently in release and reuse indices, corrupting the witness↔symbol map. + // Fail loudly instead (code-review #2). Lifting the cap is an MleAst change. + self.next_idx = self.next_idx.checked_add(1).unwrap_or_else(|| { + panic!( + "VarAllocator exceeded u16::MAX ({}) symbolic variables — NARG too large \ + for the current MleAst u16 var-index width", + u16::MAX + ) + }); MleAst::from_var(idx) } @@ -154,6 +139,7 @@ impl VarAllocator { .collect() } + #[cfg(test)] pub fn next_idx(&self) -> u16 { self.next_idx } @@ -163,31 +149,39 @@ impl VarAllocator { &self.descriptions } - /// Get descriptions without field kinds (backward compatible iterator). - pub fn descriptions(&self) -> impl Iterator + '_ { - self.descriptions + /// Witness values as native `Fr` keyed by variable index — the map + /// `ast_evaluator::eval_root`/`eval_roots` consume (challenge differential, + /// `tests/symbolic_pipeline.rs`). + pub fn witness_fr_map(&self) -> std::collections::HashMap { + self.witness_frs .iter() - .map(|(idx, name, _)| (*idx, name.as_str())) + .enumerate() + .map(|(i, v)| (i as u16, *v)) + .collect() } - /// Get witness values as a HashMap for JSON serialization. + /// Get witness values as a HashMap of decimal strings for JSON serialization. pub fn witness_values(&self) -> std::collections::HashMap { - self.witness_values + self.witness_frs .iter() .enumerate() - .map(|(i, v)| (i, v.clone())) + .map(|(i, v)| (i, v.into_bigint().to_string())) .collect() } /// Check if any variables with the specified field kind were allocated. + #[cfg(test)] pub fn has_variables_for_field(&self, field: TargetField) -> bool { self.descriptions.iter().any(|(_, _, tf)| *tf == field) } - /// Allocate variables for a commitment's 12 chunks and record witness values (Fr field). + /// Allocate variables for a commitment's byte-rule chunks (13 for a Dory + /// GT) and record witness values (Fr field). /// - /// Commitments are serialized as uncompressed LE bytes, - /// then split into 12 × 32-byte chunks (each fits in a BN254 field element). + /// Commitments are serialized as compressed LE bytes, then split into + /// 31-byte chunks (12 × 31B + one 12-byte tail for the 384-byte GT; each + /// chunk < 2²⁴⁸ < r) — the exact byte rule the field-aligned sponge + /// absorbs them under. pub fn alloc_commitment( &mut self, commitment: &T, @@ -204,284 +198,99 @@ impl Default for VarAllocator { } } -/// Number of bytes per chunk (one BN254 field element) -const BYTES_PER_CHUNK: usize = 32; +/// Bytes per byte-rule chunk (spec §4.2: 31-byte LE chunks, each < 2²⁴⁸ < r +/// so `from_le_bytes_mod_order` is exact/injective). Mirrors +/// `jolt_transcript::BYTE_RULE_CHUNK` / `zklean_extractor::BYTES_PER_CHUNK`. +/// Only the chunking tests reference the width directly now that +/// `commitment_to_field_chunks` delegates to `jolt_transcript::commitment_to_chunks`. +#[cfg(test)] +const BYTES_PER_CHUNK: usize = jolt_transcript::BYTE_RULE_CHUNK; -/// Serialize a commitment to bytes in the format used by Poseidon transcript. -/// MUST match the Poseidon transcript serialization exactly: -/// 1. Use serialize_uncompressed (not compressed) -/// 2. LE bytes directly (no byte reversal needed for circuit) +/// Serialize a commitment to bytes in the format the field-aligned Poseidon +/// transcript absorbs: `serialize_compressed` (== uncompressed for Dory GT), +/// LE bytes directly — must match `jolt_transcript::CommitmentsMsg` / +/// `FsAbsorb::absorb_commitment` exactly. fn commitment_to_bytes(commitment: &T) -> Vec { let mut bytes = Vec::new(); commitment - .serialize_uncompressed(&mut bytes) + .serialize_compressed(&mut bytes) .expect("serialization failed"); bytes } -/// Convert commitment bytes to field element chunks. -/// -/// The number of chunks is derived from the serialized size: -/// `num_chunks = ceil(serialized_size / 32)` -/// -/// This is PCS-agnostic: Dory (384 bytes) produces 12 chunks, -/// other PCS types produce different chunk counts based on their commitment size. +/// Convert commitment bytes to byte-rule field element chunks +/// (`num_chunks = ceil(serialized_size / 31)`; Dory's 384 bytes → 13 chunks = +/// 12 × 31B + one 12-byte tail). Chunk values are exactly what the native +/// sponge's `push_byte_rule_units` absorbs for the same bytes. fn commitment_to_field_chunks(commitment: &T) -> Vec { - let bytes = commitment_to_bytes(commitment); - let num_chunks = bytes.len().div_ceil(BYTES_PER_CHUNK); - - (0..num_chunks) - .map(|i| { - let start = i * BYTES_PER_CHUNK; - let end = std::cmp::min(start + BYTES_PER_CHUNK, bytes.len()); - ark_bn254::Fr::from_le_bytes_mod_order(&bytes[start..end]) - }) - .collect() + jolt_transcript::commitment_to_chunks(&commitment_to_bytes(commitment)) } -/// Convert a real proof to a symbolic proof for transpilation. -/// -/// This is the main entry point for proof symbolization. It creates symbolic -/// variables for every field element in the proof structure. -/// -/// # Variable Naming Convention -/// -/// Variables are named by their semantic role in the proof: -/// - `commitment_{n}_{chunk}` - Chunk (0-11) of commitment n -/// - `claim_{key:?}` - Opening claim for polynomial key -/// - `stage{n}_uni_skip_coeff_{i}` - Uni-skip polynomial coefficient i -/// - `stage{n}_sumcheck_r{round}_{coeff}` - Sumcheck round polynomial coefficient -/// - `untrusted_advice_commitment_{chunk}` - Advice commitment chunk (if present) -/// -/// These names appear in the witness JSON and are transformed by `sanitize_go_name` -/// for Go struct field names. -/// -/// # Returns -/// -/// - `JoltProof`: The symbolic proof with variables instead of concrete values -/// - `AstOpeningAccumulator`: Accumulator pre-populated with symbolic opening claims -/// - `VarAllocator`: Tracks all allocated variables and their descriptions -/// -/// # Type Parameter -/// -/// `OutputTranscript` specifies the transcript type for the symbolic proof. -/// Use `PoseidonAstTranscript` for Poseidon-based proofs (current default). -pub fn symbolize_proof( - real_proof: &RV64IMACProof, -) -> ( - JoltProof, - AstOpeningAccumulator, - VarAllocator, -) { - // The transpiler doesn't support zk mode (JoltProof fields differ). - // Panic early so clippy is happy with both feature sets. - #[cfg(feature = "zk")] - { - let _ = real_proof; - unimplemented!("Transpiler does not support zk mode"); - } - - #[cfg(not(feature = "zk"))] - { - let mut alloc = VarAllocator::new(); - - // === Symbolize commitments (with witness values) === - let commitments: Vec = real_proof - .commitments - .iter() - .enumerate() - .map(|(c, commitment)| { - let chunks = alloc.alloc_commitment(commitment, &format!("commitment_{c}")); - AstCommitment::new(chunks) - }) - .collect(); - - // === Symbolize opening claims (with witness values) === - let symbolic_claims = { - let mut claims = BTreeMap::new(); - for (key, (_point, claim)) in &real_proof.opening_claims.0 { - let symbolic_claim = alloc.alloc_with_value(&format!("claim_{key:?}"), claim); - claims.insert(*key, (OpeningPoint::default(), symbolic_claim)); - } - claims - }; - - // === Symbolize stage 1 uni-skip proof === - let stage1_uni_skip = symbolize_uni_skip_variant::( - &real_proof.stage1_uni_skip_first_round_proof, - &mut alloc, - "stage1_uni_skip", - ); - - // === Symbolize stage 1 sumcheck proof === - let stage1_sumcheck = symbolize_sumcheck_variant::( - &real_proof.stage1_sumcheck_proof, - &mut alloc, - "stage1_sumcheck", - ); - - // === Symbolize stage 2 uni-skip proof === - let stage2_uni_skip = symbolize_uni_skip_variant::( - &real_proof.stage2_uni_skip_first_round_proof, - &mut alloc, - "stage2_uni_skip", - ); - - // === Symbolize stage 2 sumcheck proof === - let stage2_sumcheck = symbolize_sumcheck_variant::( - &real_proof.stage2_sumcheck_proof, - &mut alloc, - "stage2_sumcheck", - ); - - // === Symbolize stage 3 sumcheck proof === - let stage3_sumcheck = symbolize_sumcheck_variant::( - &real_proof.stage3_sumcheck_proof, - &mut alloc, - "stage3_sumcheck", - ); - - // === Symbolize stage 4 sumcheck proof === - let stage4_sumcheck = symbolize_sumcheck_variant::( - &real_proof.stage4_sumcheck_proof, - &mut alloc, - "stage4_sumcheck", - ); - - // === Symbolize stage 5 sumcheck proof === - let stage5_sumcheck = symbolize_sumcheck_variant::( - &real_proof.stage5_sumcheck_proof, - &mut alloc, - "stage5_sumcheck", - ); - - // === Symbolize stage 6a sumcheck proof === - let stage6a_sumcheck = symbolize_sumcheck_variant::( - &real_proof.stage6a_sumcheck_proof, - &mut alloc, - "stage6a_sumcheck", - ); - - // === Symbolize stage 6b sumcheck proof === - let stage6b_sumcheck = symbolize_sumcheck_variant::( - &real_proof.stage6b_sumcheck_proof, - &mut alloc, - "stage6b_sumcheck", - ); - - // === Symbolize stage 7 sumcheck proof === - let stage7_sumcheck = symbolize_sumcheck_variant::( - &real_proof.stage7_sumcheck_proof, - &mut alloc, - "stage7_sumcheck", - ); - - // === Symbolize advice commitment (if present, with witness values) === - let untrusted_advice_commitment = - real_proof - .untrusted_advice_commitment - .as_ref() - .map(|commitment| { - let chunks = alloc.alloc_commitment(commitment, "untrusted_advice_commitment"); - AstCommitment::new(chunks) - }); - - // Build the symbolic proof - let symbolic_proof = JoltProof { - opening_claims: Claims(symbolic_claims), - commitments, - stage1_uni_skip_first_round_proof: stage1_uni_skip, - stage1_sumcheck_proof: stage1_sumcheck, - stage2_uni_skip_first_round_proof: stage2_uni_skip, - stage2_sumcheck_proof: stage2_sumcheck, - stage3_sumcheck_proof: stage3_sumcheck, - stage4_sumcheck_proof: stage4_sumcheck, - stage5_sumcheck_proof: stage5_sumcheck, - stage6a_sumcheck_proof: stage6a_sumcheck, - stage6b_sumcheck_proof: stage6b_sumcheck, - stage7_sumcheck_proof: stage7_sumcheck, - joint_opening_proof: AstProof::default(), - untrusted_advice_commitment, - trace_length: real_proof.trace_length, - ram_K: real_proof.ram_K, - rw_config: real_proof.rw_config.clone(), - one_hot_config: real_proof.one_hot_config.clone(), - dory_layout: real_proof.dory_layout, - }; - - // Build the opening accumulator with the symbolic claims we created - #[allow(non_snake_case)] // Match VerifierOpeningAccumulator naming - let log_T = (real_proof.trace_length as f64).log2().ceil() as usize; - let mut accumulator = AstOpeningAccumulator::new(log_T); - for (key, (_, claim)) in &symbolic_proof.opening_claims.0 { - accumulator.openings.insert(*key, (vec![], *claim)); - } - - (symbolic_proof, accumulator, alloc) - } +/// The structural parts of a symbolized proof; the NARG frames are symbolized lazily +/// during replay (see module docs). +pub struct SymbolizedProof { + /// The proof's NARG split into ordered frames (zk_mode already refused). + pub parsed_narg: ParsedNarg, + /// Accumulator pre-seeded with the symbolic opening claims, exactly as + /// `JoltVerifier::new` seeds its accumulator from `proof.opening_claims`. + pub accumulator: AstOpeningAccumulator, + /// The structural proof fields `TranspilableVerifier::new` validates and uses. + pub proof_data: TranspilableProofData, } -// ============================================================================= -// Symbolization Helpers -// ============================================================================= -// -// These functions convert concrete proof components (Fr values) to symbolic form -// (MleAst variables) while simultaneously recording witness values in VarAllocator. - +/// Symbolize a real proof's structural parts for NARG-replay transpilation. +/// +/// Allocates one named variable per non-ZK opening claim (`claim_{OpeningId:?}` — +/// the frozen Era-2 naming the Go side keys on) and splits the NARG into frames. +/// All other proof values become variables during replay, named by +/// `SymbolicVerifierFs`'s `FrameLabel` context (`commitment_{c}_{chunk}`, +/// `stage{n}_uni_skip_coeff_{i}`, `stage{n}_sumcheck_r{round}_{i}`, ...). #[cfg(not(feature = "zk"))] -fn symbolize_uni_skip_variant, T: Transcript, OutT: Transcript>( - real: &UniSkipFirstRoundProofVariant, +pub fn symbolize_proof( + real_proof: &RV64IMACProof, alloc: &mut VarAllocator, - prefix: &str, -) -> UniSkipFirstRoundProofVariant { - match real { - UniSkipFirstRoundProofVariant::Standard(inner) => { - let coeffs = - alloc.alloc_n_with_values(&inner.uni_poly.coeffs, &format!("{prefix}_coeff")); - UniSkipFirstRoundProofVariant::Standard(UniSkipFirstRoundProof::new( - jolt_core::poly::unipoly::UniPoly::from_coeff(coeffs), - )) - } - UniSkipFirstRoundProofVariant::Zk(_) => { - panic!("ZK uni-skip proofs are not supported in symbolic transpilation") - } - } +) -> Result { + let parsed_narg = parse_narg(&real_proof.narg, real_proof.zk_mode)?; + + let claims: Vec<_> = real_proof + .opening_claims + .0 + .iter() + .map(|(key, (_point, claim))| { + ( + *key, + alloc.alloc_with_value(&format!("claim_{key:?}"), claim), + ) + }) + .collect(); + + // Exact integer log2, matching the real verifier (`proof.trace_length.log_2()`, + // verifier.rs) — trace_length is a validated power of two. (code-review #5) + use jolt_core::utils::math::Math; + #[expect(non_snake_case, reason = "matches VerifierOpeningAccumulator naming")] + let log_T = real_proof.trace_length.log_2(); + let accumulator = AstOpeningAccumulator::new_with_claims(claims, log_T); + + let proof_data = TranspilableProofData::from_proof(real_proof); + + Ok(SymbolizedProof { + parsed_narg, + accumulator, + proof_data, + }) } -#[cfg(not(feature = "zk"))] -fn symbolize_sumcheck_variant, T: Transcript, OutT: Transcript>( - real: &SumcheckInstanceProof, - alloc: &mut VarAllocator, - prefix: &str, -) -> SumcheckInstanceProof { - match real { - SumcheckInstanceProof::Clear(clear_proof) => { - let compressed_polys: Vec> = clear_proof - .compressed_polys - .iter() - .enumerate() - .map(|(round, poly)| { - let coeffs = alloc.alloc_n_with_values( - &poly.coeffs_except_linear_term, - &format!("{prefix}_r{round}"), - ); - CompressedUniPoly { - coeffs_except_linear_term: coeffs, - } - }) - .collect(); - - SumcheckInstanceProof::new_standard(compressed_polys) - } - SumcheckInstanceProof::Zk(_) => { - panic!("ZK sumcheck proofs are not supported in symbolic transpilation") - } - } +/// ZK proofs are out of scope (spec §16 guardrail 4 / §17): their NARG carries extra +/// frames that the non-ZK replay would silently mis-assign. +#[cfg(feature = "zk")] +pub fn symbolize_proof( + _real_proof: &RV64IMACProof, + _alloc: &mut VarAllocator, +) -> Result { + Err(NargParseError::ZkProofUnsupported) } -// ============================================================================= // Tests -// ============================================================================= #[cfg(test)] mod tests { @@ -490,9 +299,7 @@ mod tests { use ark_ff::PrimeField; use zklean_extractor::mle_ast::{get_node, Atom, Node}; - // ========================================================================= // VarAllocator Tests - // ========================================================================= #[test] fn test_var_allocator_index_increments() { @@ -671,9 +478,7 @@ mod tests { assert!(descriptions.len() == chunks.len()); } - // ========================================================================= // Commitment Chunking Tests - // ========================================================================= #[test] fn test_commitment_to_field_chunks_g1() { @@ -687,7 +492,7 @@ mod tests { let chunks = commitment_to_field_chunks(&point); let bytes = commitment_to_bytes(&point); - // Verify chunk count matches ceil(bytes.len() / 32) + // Verify chunk count matches ceil(bytes.len() / 31) (field-aligned byte rule) let expected_chunks = bytes.len().div_ceil(BYTES_PER_CHUNK); assert_eq!( chunks.len(), @@ -708,10 +513,10 @@ mod tests { } #[test] - fn test_commitment_chunking_matches_poseidon() { - // CRITICAL: Verify chunk values match what Poseidon transcript expects + fn test_commitment_chunking_matches_byte_rule() { + // CRITICAL: Verify chunk values match the field-aligned byte rule. // This ensures commitment_to_field_chunks produces identical chunks - // to what Poseidon transcript uses when hashing commitments + // to what the transcript absorbs when hashing commitments. use ark_bn254::G1Affine; use ark_std::UniformRand; @@ -729,7 +534,7 @@ mod tests { assert_eq!( *chunk, expected_chunk, - "Chunk {i} should match bytes[{start}..{end}] (Poseidon format)" + "Chunk {i} should match bytes[{start}..{end}] (byte rule)" ); } } diff --git a/transpiler/src/symbolic_traits/ast_commitment_scheme.rs b/transpiler/src/symbolic_traits/ast_commitment_scheme.rs index fcc12659e3..ce53c3d027 100644 --- a/transpiler/src/symbolic_traits/ast_commitment_scheme.rs +++ b/transpiler/src/symbolic_traits/ast_commitment_scheme.rs @@ -15,7 +15,6 @@ //! let verifier = TranspilableVerifier::< //! MleAst, // Symbolic field (records operations) //! AstCommitmentScheme, // This stub (satisfies trait bounds) -//! PoseidonAstTranscript, // Symbolic transcript //! AstOpeningAccumulator, // Collects opening claims //! >::new(...); //! @@ -47,7 +46,7 @@ use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; use jolt_core::field::JoltField; use jolt_core::poly::commitment::commitment_scheme::CommitmentScheme; use jolt_core::poly::multilinear_polynomial::MultilinearPolynomial; -use jolt_core::transcripts::Transcript; +use jolt_core::transcript_msgs::{ProverFs, VerifierFs}; use jolt_core::utils::errors::ProofVerifyError; use std::borrow::Borrow; use zklean_extractor::mle_ast::MleAst; @@ -131,20 +130,20 @@ impl CommitmentScheme for AstCommitmentScheme { todo!("AstCommitmentScheme::combine_commitments - implement for stage 8") } - fn prove( + fn prove>( _setup: &Self::ProverSetup, _poly: &MultilinearPolynomial, _opening_point: &[::Challenge], _hint: Option, - _transcript: &mut ProofTranscript, + _transcript: &mut T, ) -> (Self::Proof, Option) { panic!("AstCommitmentScheme::prove should never be called during verification") } - fn verify( + fn verify>( _proof: &Self::Proof, _setup: &Self::VerifierSetup, - _transcript: &mut ProofTranscript, + _transcript: &mut T, _opening_point: &[::Challenge], _opening: &Self::Field, _commitment: &Self::Commitment, diff --git a/transpiler/src/symbolic_traits/io_replay.rs b/transpiler/src/symbolic_traits/io_replay.rs deleted file mode 100644 index 616869b1bb..0000000000 --- a/transpiler/src/symbolic_traits/io_replay.rs +++ /dev/null @@ -1,30 +0,0 @@ -//! FIFO queue that intercepts `raw_append_bytes` in the symbolic Poseidon transcript. -//! -//! Problem: `fiat_shamir_preamble` calls `transcript.append_bytes(b"inputs", &bytes)` -//! which hashes the concrete IO bytes into the transcript. In the generated circuit, -//! we need those bytes to be witness variables, not hardcoded constants. -//! -//! Solution: before running `verifier.verify()`, `symbolize_io_device()` pushes -//! symbolic variables into this FIFO (one per 32-byte chunk of inputs + outputs). -//! Our modified `PoseidonAstTranscript::raw_append_bytes` checks this FIFO on each -//! chunk — if there's an override, it uses the symbolic variable instead of the -//! concrete bytes. -//! -//! Important: `raw_append_label_with_len` is overridden separately to bypass this -//! FIFO, because labels are always concrete and must not consume IO overrides. - -use std::cell::RefCell; -use std::collections::VecDeque; -use zklean_extractor::mle_ast::MleAst; - -thread_local! { - static PENDING_BYTES_OVERRIDES: RefCell> = const { RefCell::new(VecDeque::new()) }; -} - -pub fn push_bytes_override(val: MleAst) { - PENDING_BYTES_OVERRIDES.with(|cell| cell.borrow_mut().push_back(val)); -} - -pub fn pop_bytes_override() -> Option { - PENDING_BYTES_OVERRIDES.with(|cell| cell.borrow_mut().pop_front()) -} diff --git a/transpiler/src/symbolic_traits/mod.rs b/transpiler/src/symbolic_traits/mod.rs index 5acac899c6..8cd614dc5e 100644 --- a/transpiler/src/symbolic_traits/mod.rs +++ b/transpiler/src/symbolic_traits/mod.rs @@ -8,7 +8,7 @@ //! //! - [`commitment_scheme`]: `CommitmentScheme` implementation (`AstCommitmentScheme`) //! - [`opening_accumulator`]: `OpeningAccumulator` implementation (`AstOpeningAccumulator`) -//! - [`transcript`]: `Transcript` implementation (`PoseidonAstTranscript`) +//! - [`verifier_fs`]: spongefish `VerifierFs` implementation (`SymbolicVerifierFs`) //! //! # Usage //! @@ -16,27 +16,23 @@ //! //! ```ignore //! use transpiler::symbolic_traits::{ -//! AstCommitmentScheme, AstOpeningAccumulator, PoseidonAstTranscript +//! AstCommitmentScheme, AstOpeningAccumulator, FieldAlignedLayout, SymbolicVerifierFs, //! }; //! use jolt_core::zkvm::transpilable_verifier::TranspilableVerifier; //! -//! let verifier = TranspilableVerifier::< -//! MleAst, // Symbolic field (records operations) -//! AstCommitmentScheme, // Stub commitment scheme -//! PoseidonAstTranscript, // Symbolic Poseidon transcript -//! AstOpeningAccumulator, // Collects opening claims -//! >::new(...); -//! -//! verifier.verify(&proof, ...); // Runs stages 1-7, records AST +//! // See `transpiler::pipeline::run_symbolic_pipeline` for the real driver. +//! let layout = FieldAlignedLayout::new(b"Jolt", &instance); +//! let mut fs = SymbolicVerifierFs::new(layout, parsed_narg, var_alloc); +//! let mut verifier = TranspilableVerifier::::new(...)?; +//! verifier.verify_stage1(&mut fs)?; // ... stages 1-7, recording the AST //! ``` pub mod ast_commitment_scheme; pub mod ast_curve; -pub mod io_replay; pub mod opening_accumulator; -pub mod poseidon; +pub mod verifier_fs; pub use ast_commitment_scheme::AstCommitmentScheme; pub use ast_curve::AstCurve; pub use opening_accumulator::AstOpeningAccumulator; -pub use poseidon::PoseidonAstTranscript; +pub use verifier_fs::{FieldAlignedLayout, FrameLabel, SymbolicSpongeLayout, SymbolicVerifierFs}; diff --git a/transpiler/src/symbolic_traits/opening_accumulator.rs b/transpiler/src/symbolic_traits/opening_accumulator.rs index ebce8cdf80..9ef3629ad2 100644 --- a/transpiler/src/symbolic_traits/opening_accumulator.rs +++ b/transpiler/src/symbolic_traits/opening_accumulator.rs @@ -16,7 +16,7 @@ use jolt_core::poly::opening_proof::{ AbstractVerifierOpeningAccumulator, OpeningAccumulator, OpeningId, OpeningPoint, PolynomialId, SumcheckId, BIG_ENDIAN, }; -use jolt_core::transcripts::Transcript; +use jolt_core::transcript_msgs::VerifierFs; use jolt_core::zkvm::claim_reductions::AdviceKind; use jolt_core::zkvm::witness::{CommittedPolynomial, VirtualPolynomial}; use std::collections::BTreeMap; @@ -258,9 +258,14 @@ impl AbstractVerifierOpeningAccumulator for AstOpeningAccumulator { } } - fn flush_to_transcript(&mut self, transcript: &mut T) { + // Mirrors `VerifierOpeningAccumulator::flush_to_transcript`: a TYPED per-claim + // `absorb_scalar` (the real accumulator's call — under the field-aligned + // Poseidon sponge a scalar absorb is the count-led `[Fr(3), v]` frame, NOT + // the byte rule, so the untyped `absorb` would diverge the transcript). + // Flushed claims are SHARED values, absorbed on both sides — never in the NARG. + fn flush_to_transcript>(&mut self, transcript: &mut T) { for claim in self.pending_claims.drain(..) { - transcript.append_scalar(b"opening_claim", &claim); + transcript.absorb_scalar(&claim); } } diff --git a/transpiler/src/symbolic_traits/poseidon.rs b/transpiler/src/symbolic_traits/poseidon.rs deleted file mode 100644 index bac9fa29f7..0000000000 --- a/transpiler/src/symbolic_traits/poseidon.rs +++ /dev/null @@ -1,543 +0,0 @@ -//! Poseidon transcript for symbolic execution (MleAst). -//! -//! # Overview -//! -//! This module implements the `Transcript` trait for symbolic execution. Instead of -//! computing actual Poseidon hashes, it records hash operations as AST nodes that -//! will be converted to Gnark code. -//! -//! # Why Poseidon? -//! -//! The Jolt proof must use Poseidon (not Blake2b/Keccak) because: -//! - Poseidon is SNARK-friendly: ~250 constraints per hash vs ~150,000 for Keccak -//! - The circuit recomputes all Fiat-Shamir challenges from the transcript -//! - Using Blake2b would make the circuit infeasibly large -//! -//! # Structure (Must Match jolt-core Exactly) -//! -//! - **Width-3 Poseidon**: `hash(state, n_rounds, data)` with 3 inputs -//! - **Domain separation**: `n_rounds` counter increments on each append operation -//! - **State update**: `new_state = poseidon(old_state, n_rounds, data)` -//! - **Challenge derivation**: `challenge = truncate_128(poseidon(state, n_rounds, 0))` -//! -//! # Fiat-Shamir Challenge Types -//! -//! Two challenge derivation methods exist: -//! - `challenge_scalar_128_bits()`: For batching coefficients. Uses `Truncate128`. -//! - `challenge_scalar_optimized()`: For sumcheck challenges. Uses `Truncate128Reverse`. -//! -//! The "Reverse" variant reverses bytes before truncation (matching jolt-core's byte order). -//! -//! # Critical: Transcript Matching -//! -//! The proof MUST be generated with `--features transcript-poseidon`. If generated -//! with Blake2b (the default), all challenge values will differ and verification -//! will fail silently (assertions won't be zero). - -use ark_serialize::CanonicalSerialize; -use jolt_core::field::JoltField; -use jolt_core::transcripts::Transcript; -use zklean_extractor::mle_ast::{ - set_pending_challenge, take_pending_append, take_pending_commitment_chunks, MleAst, -}; - -use super::io_replay::pop_bytes_override; - -/// Symbolic Poseidon transcript for AST-based transpilation. -#[derive(Clone)] -pub struct PoseidonAstTranscript { - /// Current state (symbolic field element) - state: MleAst, - /// Round counter for domain separation - n_rounds: u32, -} - -impl PoseidonAstTranscript { - /// Convert a label to a field element, matching jolt-core's behavior. - /// - /// jolt-core does: label_padded[..label.len()].copy_from_slice(label); - /// Fr::from_le_bytes_mod_order(&label_padded) - /// - /// For symbolic execution, we compute the actual integer value that - /// the label bytes represent in little-endian order. - fn label_to_field(label: &[u8]) -> MleAst { - assert!(label.len() <= 32, "Label must be <= 32 bytes"); - - // Pad label to 32 bytes and convert to [u64; 4] - let mut padded = [0u8; 32]; - padded[..label.len()].copy_from_slice(label); - let limbs = bytes_to_scalar(&padded); - - MleAst::from(limbs) - } - - /// Hash a field element with domain separation. - /// - /// Mirrors jolt-core: poseidon(state, n_rounds, element) - fn hash_and_update(&mut self, element: MleAst) { - let round = MleAst::from_u64(self.n_rounds as u64); - self.state = MleAst::poseidon(&self.state, &round, &element); - self.n_rounds += 1; - } - - /// Derive a challenge: `poseidon(state, n_rounds, 0)`, then update state. - pub fn challenge_ast(&mut self) -> MleAst { - let round = MleAst::from_u64(self.n_rounds as u64); - let zero = MleAst::from_u64(0); - let challenge = MleAst::poseidon(&self.state, &round, &zero); - self.state = challenge; - self.n_rounds += 1; - challenge - } - - /// Append symbolic field elements with Poseidon chaining. - /// - /// First element: `poseidon(state, n_rounds, elem)` (domain separation). - /// Remaining: `poseidon(prev, 0, elem)` (chained). - /// Used by `raw_append_bytes` (concrete) and `append_serializable` (commitments). - pub fn append_field_elements(&mut self, elements: &[MleAst]) { - let round = MleAst::from_u64(self.n_rounds as u64); - let zero = MleAst::from_u64(0); - - let mut iter = elements.iter(); - - // First element: includes n_rounds for domain separation - let mut current = if let Some(first) = iter.next() { - MleAst::poseidon(&self.state, &round, first) - } else { - // Empty: just hash state with n_rounds and zero - MleAst::poseidon(&self.state, &round, &zero) - }; - - // Remaining elements: no n_rounds (already accounted for) - for elem in iter { - current = MleAst::poseidon(¤t, &zero, elem); - } - - self.state = current; - self.n_rounds += 1; - } -} - -/// Implement Jolt's Transcript trait for PoseidonAstTranscript. -/// -/// The challenge methods return MleAst when F = MleAst. -impl Transcript for PoseidonAstTranscript { - fn new(label: &'static [u8]) -> Self { - // Mirror jolt-core: initial_state = poseidon(label, 0, 0) - let label_field = Self::label_to_field(label); - let initial_state = MleAst::poseidon( - &label_field, - &MleAst::from_u64(0), // n_rounds = 0 - &MleAst::from_u64(0), // zero - ); - Self { - state: initial_state, - n_rounds: 0, - } - } - - // === Internal raw_append_* methods === - - fn raw_append_label(&mut self, label: &'static [u8]) { - assert!(label.len() <= 32); - let field = Self::label_to_field(label); - self.hash_and_update(field); - } - - fn raw_append_label_with_len(&mut self, label: &'static [u8], len: u64) { - // The default impl calls raw_append_bytes(&packed), which would check the - // FIFO and steal an IO override meant for actual input/output data. - // We do the same packing + hashing but call append_field_elements directly. - assert!(label.len() <= 24); - let mut packed = [0u8; 32]; - packed[..label.len()].copy_from_slice(label); - packed[24..32].copy_from_slice(&len.to_be_bytes()); - let element = MleAst::from(bytes_to_scalar(&packed)); - self.append_field_elements(&[element]); - } - - fn raw_append_bytes(&mut self, bytes: &[u8]) { - let elements: Vec = bytes - .chunks(32) - .map(|chunk| { - // If symbolize_io_device pre-loaded a FIFO override for this chunk, - // use the symbolic variable instead of the concrete bytes. - if let Some(symbolic) = pop_bytes_override() { - symbolic - } else { - let mut padded = [0u8; 32]; - padded[..chunk.len()].copy_from_slice(chunk); - MleAst::from(bytes_to_scalar(&padded)) - } - }) - .collect(); - self.append_field_elements(&elements); - } - - fn raw_append_u64(&mut self, x: u64) { - // PoseidonTranscript::raw_append_u64 packs x as LE in first 8 bytes of 32-byte word. - // from_le_bytes_mod_order gives x directly. No transform needed. - self.hash_and_update(MleAst::from_u64(x)); - } - - fn raw_append_scalar(&mut self, scalar: &F) { - // Trigger serialization which stores MleAst in thread-local (if F = MleAst) - let mut buf = vec![]; - let _ = scalar.serialize_uncompressed(&mut buf); - - if let Some(mle_ast) = take_pending_append() { - // PoseidonTranscript hashes LE bytes directly (no byte-reversal). - // from_le_bytes_mod_order(LE serialization) = the scalar itself. - self.hash_and_update(mle_ast); - } else { - panic!("PoseidonAstTranscript::raw_append_scalar called but no pending MleAst found — serialize_uncompressed must store the symbolic value via set_pending_append()") - } - } - - // === Override append_serializable to handle AstCommitment chunks === - - fn append_serializable(&mut self, label: &'static [u8], data: &T) { - // For symbolic execution, serialization stores values in thread-local. - let mut buf = vec![]; - let _ = data.serialize_uncompressed(&mut buf); - - // Check for commitment chunks first (MleAst chunks for commitment hashing). - // AstCommitment::serialize stores chunks in PENDING_COMMITMENT_CHUNKS. - // Chunk count is PCS-dependent (e.g., Dory: 12 chunks for 384-byte G1Affine). - if let Some(chunks) = take_pending_commitment_chunks() { - // CRITICAL: Match Transcript::append_serializable behavior: - // 1. Compute byte length from chunk count (each chunk = 32 bytes). - // Note: buf.len() is 0 because AstCommitment::serialize doesn't write bytes. - let commitment_byte_len = chunks.len() * 32; - self.raw_append_label_with_len(label, commitment_byte_len as u64); - - // 2. Commitment bytes are LE (no reversal). Chunked into N × 32-byte pieces. - // Hashed in order. No ByteReverse needed. - self.append_field_elements(&chunks); - return; - } - - // Fallback: single MleAst (existing behavior for non-commitment types) - if let Some(mle_ast) = take_pending_append() { - self.raw_append_label_with_len(label, buf.len() as u64); - // LE bytes directly. No byte-reversal needed. - self.hash_and_update(mle_ast); - } else { - // Fallback: use default implementation for concrete types - self.raw_append_label_with_len(label, buf.len() as u64); - // LE bytes directly, no byte reversal (Groth16 circuit, not EVM) - self.raw_append_bytes(&buf); - } - } - - // === Challenge generation methods === - - fn challenge_u128(&mut self) -> u128 { - let _ = self.challenge_ast(); - 0u128 - } - - fn challenge_scalar(&mut self) -> F { - self.challenge_scalar_128_bits() - } - - fn challenge_scalar_128_bits(&mut self) -> F { - // Full Fr challenge. Hash output directly, no truncation. - let hash = self.challenge_ast(); - set_pending_challenge(hash); - F::from_bytes(&[0u8; 32]) - } - - fn challenge_vector(&mut self, len: usize) -> Vec { - (0..len).map(|_| self.challenge_scalar::()).collect() - } - - fn challenge_scalar_powers(&mut self, len: usize) -> Vec { - let base: F = self.challenge_scalar(); - let mut powers = Vec::with_capacity(len); - let mut current = F::one(); - for _ in 0..len { - powers.push(current); - current *= base; - } - powers - } - - fn challenge_scalar_optimized(&mut self) -> F::Challenge { - // Full Fr challenge. Hash output directly, no truncation. - let hash = self.challenge_ast(); - set_pending_challenge(hash); - let f_val: F = F::from_bytes(&[0u8; 32]); - // Safe because for MleAst, F = F::Challenge = MleAst - unsafe { std::mem::transmute_copy::(&f_val) } - } - - fn challenge_vector_optimized(&mut self, len: usize) -> Vec { - (0..len) - .map(|_| self.challenge_scalar_optimized::()) - .collect() - } - - fn challenge_scalar_powers_optimized(&mut self, len: usize) -> Vec { - let q: F::Challenge = self.challenge_scalar_optimized::(); - let mut q_powers = vec![F::one(); len]; - for i in 1..len { - q_powers[i] = q * q_powers[i - 1]; - } - q_powers - } -} - -impl Default for PoseidonAstTranscript { - fn default() -> Self { - Self { - state: MleAst::from_u64(0), - n_rounds: 0, - } - } -} - -/// Convert 32 little-endian bytes to `[u64; 4]` limbs (no mod reduction). -fn bytes_to_scalar(bytes: &[u8; 32]) -> [u64; 4] { - let mut limbs = [0u64; 4]; - for (i, chunk) in bytes.chunks(8).enumerate() { - limbs[i] = u64::from_le_bytes(chunk.try_into().unwrap()); - } - limbs -} - -#[cfg(test)] -mod tests { - use super::*; - - // ========================================================================= - // Helper Methods Tests - // ========================================================================= - - #[test] - fn test_label_to_field() { - // "jolt" = [0x6a, 0x6f, 0x6c, 0x74] in ASCII - // As little-endian u64 (padded to 8 bytes): 0x746c6f6a = 1953263466 - let label_field = PoseidonAstTranscript::label_to_field(b"jolt"); - - // Check it's a scalar with the expected value - let root = label_field.root(); - let node = zklean_extractor::mle_ast::get_node(root); - match node { - zklean_extractor::mle_ast::Node::Atom(zklean_extractor::mle_ast::Atom::Scalar(v)) => { - // "jolt" bytes: [0x6a, 0x6f, 0x6c, 0x74, 0, 0, 0, 0] - // As little-endian u64: 0x00_00_00_00_74_6c_6f_6a = 1953263466 - // As [u64; 4]: [1953263466, 0, 0, 0] - let expected: [u64; 4] = [1953263466, 0, 0, 0]; - assert_eq!( - v, expected, - "Label 'jolt' should be {expected:?} but got {v:?}" - ); - } - _ => panic!("Expected Scalar atom, got {node:?}"), - } - } - - #[test] - fn test_hash_and_update() { - let mut transcript: PoseidonAstTranscript = Transcript::new(b"test"); - let initial_rounds = transcript.n_rounds; - - transcript.hash_and_update(MleAst::from_u64(42)); - - // Verify n_rounds incremented - assert_eq!(transcript.n_rounds, initial_rounds + 1); - - // Verify state is now a Poseidon hash node - let root = transcript.state.root(); - let node = zklean_extractor::mle_ast::get_node(root); - assert!( - matches!( - node, - zklean_extractor::mle_ast::Node::TranscriptHash(_, _, _) - ), - "Expected TranscriptHash node after hash_and_update" - ); - } - - #[test] - fn test_challenge_ast() { - let mut transcript: PoseidonAstTranscript = Transcript::new(b"test"); - let initial_rounds = transcript.n_rounds; - - let challenge = transcript.challenge_ast(); - - // Verify n_rounds incremented - assert_eq!(transcript.n_rounds, initial_rounds + 1); - - // Verify challenge is a Poseidon hash node - let root = challenge.root(); - let node = zklean_extractor::mle_ast::get_node(root); - assert!( - matches!( - node, - zklean_extractor::mle_ast::Node::TranscriptHash(_, _, _) - ), - "Expected TranscriptHash node for challenge" - ); - - // Verify state was updated to challenge value - assert_eq!(transcript.state.root(), challenge.root()); - } - - #[test] - fn test_append_field_elements_chaining() { - // CRITICAL: First element uses n_rounds for domain separation, - // remaining elements use 0 for chaining (matching jolt-core) - let mut transcript: PoseidonAstTranscript = Transcript::new(b"test"); - let initial_rounds = transcript.n_rounds; - - let elements = vec![ - MleAst::from_u64(10), - MleAst::from_u64(20), - MleAst::from_u64(30), - ]; - - transcript.append_field_elements(&elements); - - // Verify n_rounds incremented once (not per element) - assert_eq!(transcript.n_rounds, initial_rounds + 1); - - // Verify state is a Poseidon hash node - let root = transcript.state.root(); - let node = zklean_extractor::mle_ast::get_node(root); - assert!( - matches!( - node, - zklean_extractor::mle_ast::Node::TranscriptHash(_, _, _) - ), - "Expected TranscriptHash node after append_field_elements" - ); - } - - // ========================================================================= - // Transcript Trait Tests - // ========================================================================= - - #[test] - fn test_new_with_label() { - // Verify that creating transcript with "Jolt" label produces a Poseidon node - let transcript: PoseidonAstTranscript = Transcript::new(b"Jolt"); - assert_eq!(transcript.n_rounds, 0); - - // The initial state should be a Poseidon hash node - let root = transcript.state.root(); - let node = zklean_extractor::mle_ast::get_node(root); - match node { - zklean_extractor::mle_ast::Node::TranscriptHash(_, _, _) => { - // Expected: poseidon(label, 0, 0) - } - _ => panic!("Expected TranscriptHash node for initial state, got {node:?}"), - } - } - - #[test] - fn test_raw_append_u64() { - use jolt_core::transcripts::Transcript as _; - - let mut transcript: PoseidonAstTranscript = Transcript::new(b"test"); - let initial_rounds = transcript.n_rounds; - - transcript.raw_append_u64(12345); - - // Verify n_rounds incremented - assert_eq!(transcript.n_rounds, initial_rounds + 1); - - // Verify state contains a Poseidon hash with the u64 value - let root = transcript.state.root(); - let node = zklean_extractor::mle_ast::get_node(root); - assert!( - matches!( - node, - zklean_extractor::mle_ast::Node::TranscriptHash(_, _, _) - ), - "Expected TranscriptHash node after raw_append_u64" - ); - } - - #[test] - fn test_raw_append_scalar_with_mle_ast() { - use jolt_core::transcripts::Transcript as _; - - let mut transcript: PoseidonAstTranscript = Transcript::new(b"test"); - - // Create a variable (not a constant) - let var = MleAst::from_var(42); - - // Append it to transcript via append_scalar (which calls raw_append_scalar) - transcript.append_scalar(b"test_scalar", &var); - - // Check that the state now contains a Poseidon node with Var(42) directly - // append_scalar does: hash(var). No byte-reversal. - let root = transcript.state.root(); - let node = zklean_extractor::mle_ast::get_node(root); - - match node { - zklean_extractor::mle_ast::Node::TranscriptHash( - zklean_extractor::mle_ast::TranscriptHashData::Poseidon(data_edge), - _, - _, - ) => { - // Poseidon data element should be Var(42) directly (no ByteReverse) - match data_edge { - zklean_extractor::mle_ast::Edge::Atom( - zklean_extractor::mle_ast::Atom::Var(idx), - ) => { - assert_eq!(idx, 42, "Expected Var(42), got Var({idx})"); - } - other => panic!("Expected Atom(Var(42)) as Poseidon data arg, got {other:?}"), - } - } - _ => panic!("Expected Poseidon node, got {node:?}"), - } - } - - #[test] - fn test_append_serializable_with_commitment_chunks() { - use jolt_core::transcripts::Transcript as _; - use zklean_extractor::mle_ast::{set_pending_commitment_chunks, AstCommitment}; - - let mut transcript: PoseidonAstTranscript = Transcript::new(b"test"); - let initial_rounds = transcript.n_rounds; - - // Create mock commitment chunks (simulating what AstCommitment::serialize does) - let chunks = vec![ - MleAst::from_u64(100), - MleAst::from_u64(200), - MleAst::from_u64(300), - ]; - set_pending_commitment_chunks(chunks.clone()); - - // Create a dummy AstCommitment (its serialize will have already set the chunks above) - let commitment = AstCommitment::default(); - - // Append the commitment - transcript.append_serializable(b"commitment", &commitment); - - // Verify n_rounds incremented by 2: - // 1. raw_append_label_with_len (label + length) - // 2. append_field_elements (commitment chunks) - assert_eq!( - transcript.n_rounds, - initial_rounds + 2, - "n_rounds should increment by 2 after append_serializable (label+len + chunks)" - ); - - // Verify state is a Poseidon hash node (contains the commitment chunks) - let root = transcript.state.root(); - let node = zklean_extractor::mle_ast::get_node(root); - assert!( - matches!( - node, - zklean_extractor::mle_ast::Node::TranscriptHash(_, _, _) - ), - "Expected TranscriptHash node after appending commitment" - ); - } -} diff --git a/transpiler/src/symbolic_traits/verifier_fs.rs b/transpiler/src/symbolic_traits/verifier_fs.rs new file mode 100644 index 0000000000..468671e768 --- /dev/null +++ b/transpiler/src/symbolic_traits/verifier_fs.rs @@ -0,0 +1,802 @@ +//! Symbolic implementation of jolt-core's spongefish verifier surface +//! (`FsChallenge` / `FsAbsorb` / `VerifierFs`) for `F = MleAst`. +//! +//! Mirrors the **field-aligned** Poseidon transcript (spec §4, T-O5): the +//! native `PoseidonSponge` is a `U = Fr` compression chain and every absorb is +//! a tagged unit message (see `crates/jolt-transcript/src/codec.rs`), so the +//! symbolic sponge absorbs witness variables as native units and a challenge +//! is ONE `TranscriptHash` node — proof scalars are never byte-decomposed +//! in-circuit. +//! +//! Structure: +//! +//! 1. **Challenges return directly.** `FsChallenge`'s methods return +//! `F = MleAst`; a squeeze is a single `MleAst::poseidon(state, 0, 0)` node. +//! 2. **Frame reads are typed.** `read_scalars`/`read_commitments` pop the next +//! pre-parsed NARG frame (`narg_parser::ParsedNarg`), symbolize the real +//! proof bytes into fresh witness variables via the `set_read_symbolizer` +//! hook, and absorb them through the matching typed layout hook — exactly +//! the kinds jolt-core's `VerifierFs` reads on the non-ZK stages-1–7 path. +//! 3. **The sponge layout is a test seam.** [`SymbolicSpongeLayout`] hides how +//! absorbs/squeezes update the in-circuit sponge; [`FieldAlignedLayout`] is +//! the only production implementation, gated by the differential tests +//! below plus the `poseidon_model` oracle. The trait exists so recording +//! test doubles can observe absorb routing (C7) and so `FAITHFUL` acts as +//! a faithfulness tripwire (A1 deployment-blocker guard). + +use std::cell::{Cell, RefCell}; +use std::collections::VecDeque; +use std::rc::Rc; + +use ark_bn254::Fr; +use ark_ff::PrimeField; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use jolt_core::field::JoltField; +use jolt_core::transcript_msgs::{FsAbsorb, FsChallenge, VerifierFs}; +use jolt_transcript::{ + poseidon_domain_separator_msgs, push_byte_rule_units, push_commitments_frame_header, + push_field_frame_units, VerificationError, VerificationResult, +}; +use zklean_extractor::mle_ast::{ + clear_read_symbolizer, set_read_symbolizer, take_pending_append, + take_pending_commitment_chunks, MleAst, +}; +use zklean_extractor::{COMMITMENT_BYTES, COMMITMENT_CHUNKS}; + +use crate::narg_parser::ParsedNarg; +use crate::symbolic_proof::VarAllocator; + +/// How one absorb/squeeze updates the symbolic sponge, in the typed message +/// vocabulary of spec §4.2. [`FieldAlignedLayout`] is the only production +/// implementation; the trait is a test seam that lets recording test doubles +/// observe absorb routing (the C7 absorb-routing regression test) and ties the +/// pipeline's faithfulness flag to a compile-time constant. +pub trait SymbolicSpongeLayout { + /// `true` iff this layout reproduces the native `PoseidonSponge` + /// value-exactly, so the circuit's Fiat-Shamir challenges match the real + /// verifier's. Drives the pipeline's `sponge_faithful` flag (A1 + /// deployment-blocker guard); a non-faithful test layout must say `false`. + const FAITHFUL: bool; + + /// One `absorb_scalar` — the count-led single-element field frame + /// `[Fr(3), value]` (`FieldFrameMsg` of one element). + fn absorb_scalar_message(&mut self, value: &MleAst); + + /// One field frame of `k` elements — `[Fr(2k+1), e₁, …, e_k]` + /// (`FieldFrameMsg`; `write_scalars`/`read_scalars`, `absorb_scalars`). + fn absorb_field_frame(&mut self, elements: &[MleAst]); + + /// One commitments frame (`CommitmentsMsg`): the frame count unit + /// `Fr(2k+1)` zero-padded to a whole permute pair, then one 14-unit + /// byte-rule group per commitment — `[Fr(2·384), 13 chunks]`, the chunk + /// units being the witness variables directly. An empty frame is the + /// count-led `[Fr(1), 0]` pair. + fn absorb_commitments_frame(&mut self, groups: &[Vec]); + + /// One lone commitment absorb (`absorb_commitment`, e.g. trusted + /// commitments): the byte rule over ONE canonical serialization — + /// `[Fr(2·384), 13 chunks]` with NO frame count unit, mirroring + /// `messages.rs`'s Poseidon `absorb_commitment` (= `RawBytesMsg` over the + /// serialization). + fn absorb_commitment_message(&mut self, chunks: &[MleAst]); + + /// One byte-rule message over concrete bytes both sides know + /// (`RawBytesMsg`: `[Fr(2L), ceil(L/31) 31-byte-LE chunks]`), hashed into + /// the circuit as constants. + fn absorb_byte_message(&mut self, bytes: &[u8]); + + /// Squeeze one native-unit field challenge: ONE permute, challenge = the + /// new state. + fn squeeze(&mut self) -> MleAst; +} + +// The byte-rule chunk width exists in two crates (`zklean_extractor::BYTES_PER_CHUNK` +// drives the commitment re-chunking, `jolt_transcript::BYTE_RULE_CHUNK` drives the +// native sponge encoding). They MUST be the same 31 bytes or the symbolic chunk +// witness values silently diverge from the units the native sponge absorbs — tie the +// split brains together at compile time. +const _: () = assert!( + zklean_extractor::BYTES_PER_CHUNK == jolt_transcript::BYTE_RULE_CHUNK, + "zklean_extractor::BYTES_PER_CHUNK != jolt_transcript::BYTE_RULE_CHUNK: the \ + commitment re-chunking no longer matches the native byte-rule encoding" +); + +/// An `Fr` constant as an `MleAst` scalar node (Montgomery-free canonical +/// limbs). +fn fr_const(value: Fr) -> MleAst { + MleAst::from(value.into_bigint().0) +} + +/// PRODUCTION sponge layout (spec §4.5): mirrors the field-aligned +/// `PoseidonSponge` (`U = Fr` compression chain) over `MleAst::poseidon` +/// nodes, absorbing exactly the codec's tagged unit streams. Every constant +/// unit (message tags, frame counts, even-padding) is produced by the +/// IMPORTED `jolt_transcript` encoders ([`push_byte_rule_units`], +/// [`push_field_frame_units`], [`push_commitments_frame_header`]) and lifted +/// via [`fr_const`]; only the payload positions are symbolic values. +pub struct FieldAlignedLayout { + state: MleAst, +} + +impl FieldAlignedLayout { + /// Seed with the spongefish domain separator: the same three byte strings + /// the native factories absorb ([`poseidon_domain_separator_msgs`]: + /// `PROTOCOL_ID` ‖ session-`BytesMsg` ‖ 32-byte instance digest), each + /// under the byte rule, all as baked constants — the digest value is baked + /// into the circuit like every other native tag. + pub fn new(session: &[u8], instance: &[u8; 32]) -> Self { + let mut layout = Self { + state: MleAst::from_u64(0), + }; + for msg in poseidon_domain_separator_msgs(session, *instance) { + layout.absorb_byte_message(&msg.0); + } + layout + } + + /// One sponge `absorb(units)`: unit pairs through the permutation, + /// zero-padding an odd tail — `state' = poseidon(state, a, b)`. Every + /// message the layout absorbs is a complete tagged group (already padded + /// to whole pairs by the codec rules), so message boundaries bind. + fn absorb_units(&mut self, units: &[MleAst]) { + let zero = MleAst::from_u64(0); + for pair in units.chunks(2) { + let a = pair[0]; + let b = pair.get(1).copied().unwrap_or(zero); + self.state = MleAst::poseidon(&self.state, &a, &b); + } + } + + /// The 14-unit per-commitment byte-rule group `[Fr(2·384), 13 chunks]` + /// (one Dory GT = 384 canonical bytes ↦ 12×31B + 1×12B chunks). The tag + /// is a constant because the schedule fixes every commitment's byte + /// length; the chunk units are the witness variables directly. The tag is + /// taken from the IMPORTED [`push_byte_rule_units`] over a 384-byte + /// placeholder, so the encoding cannot drift from the native sponge's. + fn push_commitment_group(units: &mut Vec, chunks: &[MleAst]) { + assert_eq!( + chunks.len(), + COMMITMENT_CHUNKS, + "commitment group must be the {COMMITMENT_CHUNKS}-chunk Dory GT re-chunking" + ); + let mut fr_units: Vec = Vec::with_capacity(1 + COMMITMENT_CHUNKS); + push_byte_rule_units(&mut fr_units, &[0u8; COMMITMENT_BYTES]); + // 1 tag + 13 chunks = 14 units (even), so the native encoder adds no pad; + // every unit after the tag is payload (replaced by the witness chunk vars). + assert_eq!( + fr_units.len(), + 1 + COMMITMENT_CHUNKS, + "native byte rule no longer encodes a Dory GT as [tag, {COMMITMENT_CHUNKS} chunks]" + ); + units.push(fr_const(fr_units[0])); + units.extend_from_slice(chunks); + } +} + +impl SymbolicSpongeLayout for FieldAlignedLayout { + // Value-faithful to the native field-aligned `PoseidonSponge`: proven by + // the differential gates — `poseidon_model` vs a real + // `ProverState` (level 1), and + // `field_aligned_layout_matches_native_sponge` below, which evaluates this + // layout's challenge ASTs against a witness and asserts equality with the + // model (level 2). + const FAITHFUL: bool = true; + + fn absorb_scalar_message(&mut self, value: &MleAst) { + self.absorb_field_frame(std::slice::from_ref(value)); + } + + fn absorb_field_frame(&mut self, elements: &[MleAst]) { + // Encode a zero-placeholder frame of the same arity through the IMPORTED + // native encoder, then splice the symbolic payload into the element + // positions — the count tag and even-pad units (positions 0 and, when + // present, the tail) come from `jolt_transcript` verbatim. + let placeholder = vec![Fr::from(0u64); elements.len()]; + let mut fr_units: Vec = Vec::with_capacity(2 + elements.len()); + push_field_frame_units(&mut fr_units, &placeholder); + let units: Vec = fr_units + .iter() + .enumerate() + .map(|(i, unit)| match i.checked_sub(1) { + Some(k) if k < elements.len() => elements[k], + _ => fr_const(*unit), + }) + .collect(); + self.absorb_units(&units); + } + + fn absorb_commitments_frame(&mut self, groups: &[Vec]) { + let mut units = Vec::with_capacity(2 + (1 + COMMITMENT_CHUNKS) * groups.len()); + // Frame count unit padded to a whole permute pair (review fix F1), so + // the per-GT groups stay pair-aligned — header units from the IMPORTED + // native encoder. + let mut header: Vec = Vec::with_capacity(2); + push_commitments_frame_header(&mut header, groups.len()); + units.extend(header.into_iter().map(fr_const)); + for chunks in groups { + Self::push_commitment_group(&mut units, chunks); + } + self.absorb_units(&units); + } + + fn absorb_commitment_message(&mut self, chunks: &[MleAst]) { + let mut units = Vec::with_capacity(1 + COMMITMENT_CHUNKS); + Self::push_commitment_group(&mut units, chunks); + self.absorb_units(&units); + } + + fn absorb_byte_message(&mut self, bytes: &[u8]) { + let mut fr_units = Vec::with_capacity(2 + bytes.len() / 31); + push_byte_rule_units(&mut fr_units, bytes); + let units: Vec = fr_units.into_iter().map(fr_const).collect(); + self.absorb_units(&units); + } + + fn squeeze(&mut self) -> MleAst { + let zero = MleAst::from_u64(0); + self.state = MleAst::poseidon(&self.state, &zero, &zero); + self.state + } +} + +/// Witness-variable naming context, set by the transpiler driver between stages so +/// names follow the frozen Era-2 contract (spec §13.6): `commitment_{c}_{chunk}`, +/// `stage{n}_uni_skip_coeff_{i}`, `stage{n}_sumcheck_r{round}_{i}`. +#[derive(Clone, Debug)] +pub enum FrameLabel { + /// Pre-stage frames: frame 0 = witness commitments, frame 1 = advice presence. + /// Commitment elements are the 13 byte-rule chunks per Dory GT (spec §4.5 + /// re-chunking: 12×31B + 1×12B). + Prestage, + /// A stage that begins with a uni-skip first-round frame (stages 1–2): + /// frame 0 → `stage{n}_uni_skip_coeff_{i}`, frame k → `stage{n}_sumcheck_r{k-1}_{i}`. + StageWithUniskip(u8), + /// Every frame is a sumcheck round: `{prefix}_r{frame}_{i}` (e.g. + /// `Rounds("stage6a_sumcheck")`). + Rounds(String), +} + +impl FrameLabel { + fn element_name(&self, frame_in_label: usize, element: usize) -> String { + match self { + Self::Prestage => match frame_in_label { + 0 => format!( + "commitment_{}_{}", + element / COMMITMENT_CHUNKS, + element % COMMITMENT_CHUNKS + ), + 1 => { + // The advice presence frame carries AT MOST one commitment + // (`read_commitment_frames` rejects len > 1), so element == + // chunk index. Assert rather than `% COMMITMENT_CHUNKS`: the + // chunk-only name is the frozen Go contract, and silently + // wrapping a second commitment onto the same names would + // corrupt the witness map. + assert!( + element < COMMITMENT_CHUNKS, + "untrusted-advice presence frame symbolized element {element} — more \ + than one commitment in a frame the verifier caps at one" + ); + format!("untrusted_advice_commitment_{element}") + } + k => format!("prestage_f{k}_{element}"), + }, + Self::StageWithUniskip(n) => { + if frame_in_label == 0 { + format!("stage{n}_uni_skip_coeff_{element}") + } else { + format!("stage{n}_sumcheck_r{}_{element}", frame_in_label - 1) + } + } + Self::Rounds(prefix) => format!("{prefix}_r{frame_in_label}_{element}"), + } + } +} + +/// Symbolic verifier transcript: replays pre-parsed NARG frames as witness +/// variables, records absorbs/squeezes through the sponge layout, and returns +/// challenges as AST nodes. Drop-in for `&mut impl VerifierFs` in +/// `TranspilableVerifier`. +pub struct SymbolicVerifierFs { + layout: L, + frames: VecDeque>, + var_alloc: Rc>, + label: FrameLabel, + frames_read_in_label: usize, + /// Squeezed challenge nodes, in order. Consumed by the in-CI real-proof + /// challenge differential (`symbolic_pipeline_runs_on_real_muldiv_poseidon_proof`, + /// `transpiler/tests/symbolic_pipeline.rs`): the pipeline surfaces this record + /// through `PipelineOutput::squeezed_challenges`, the test evaluates each AST + /// against the recorded witness and asserts element-wise equality with the + /// challenges the NATIVE `TranspilableVerifier` + real Poseidon transcript + /// squeeze on the same proof — closing the dispatch-layer schedule gap the + /// layout differential (`FieldAlignedLayout` vs the `poseidon_model` oracle) + /// cannot see. + pub squeezed_challenges: Vec, +} + +impl SymbolicVerifierFs { + pub fn new(layout: L, parsed: ParsedNarg, var_alloc: Rc>) -> Self { + Self { + layout, + frames: parsed.into_frames().into(), + var_alloc, + label: FrameLabel::Prestage, + frames_read_in_label: 0, + squeezed_challenges: Vec::new(), + } + } + + /// Set the naming context for subsequent frame reads (driver calls this + /// between stages). + pub fn set_label(&mut self, label: FrameLabel) { + self.label = label; + self.frames_read_in_label = 0; + } + + /// Frames not yet consumed — must be 0 after stage 7 (the offline analogue of + /// `check_eof`; in non-ZK mode stage 8 reads no frames). + pub fn remaining_frames(&self) -> usize { + self.frames.len() + } + + /// Pop the next NARG frame and decode it with the read symbolizer + /// installed: each element the decode consumes becomes a fresh named + /// witness variable carrying its concrete value (the shared `nodes` + /// collector returns them in read order — 1 per scalar, 13 chunk vars per + /// commitment). + fn pop_symbolized_frame( + &mut self, + ) -> VerificationResult<(Vec, Vec)> { + let frame = self.frames.pop_front().ok_or(VerificationError)?; + let frame_in_label = self.frames_read_in_label; + self.frames_read_in_label += 1; + + let label = self.label.clone(); + let alloc = Rc::clone(&self.var_alloc); + let element_counter = Rc::new(Cell::new(0usize)); + let nodes: Rc>> = Rc::new(RefCell::new(Vec::new())); + let (counter_hook, nodes_hook) = (Rc::clone(&element_counter), Rc::clone(&nodes)); + set_read_symbolizer(Box::new(move |bytes: &[u8; 32]| { + let witness = Fr::from_le_bytes_mod_order(bytes); + let i = counter_hook.get(); + counter_hook.set(i + 1); + let name = label.element_name(frame_in_label, i); + let var = alloc.borrow_mut().alloc_with_value(&name, &witness); + nodes_hook.borrow_mut().push(var); + var + })); + + // RAII so the thread-local symbolizer is cleared on EVERY exit path — + // including the `?` early-return below and any panic inside + // `deserialize_compressed` — so a stale hook can never leak into a later + // `MleAst`/`AstCommitment` deserialize. (Code-review #6.) + struct SymbolizerGuard; + impl Drop for SymbolizerGuard { + fn drop(&mut self) { + clear_read_symbolizer(); + } + } + let symbolizer_guard = SymbolizerGuard; + + // Standard self-delimiting decode loop — mirrors jolt-core's `read_all`. + let mut cursor = frame.as_slice(); + let mut out: Vec = Vec::new(); + while !cursor.is_empty() { + match T::deserialize_compressed(&mut cursor) { + Ok(value) => out.push(value), + Err(_) => return Err(VerificationError), + } + } + + // Clear the hook (dropping its `nodes` Rc clone) so the collector can + // be unwrapped. + drop(symbolizer_guard); + #[expect(clippy::expect_used)] // sole owner: the hook's clone was just dropped + let nodes = Rc::try_unwrap(nodes) + .expect("read symbolizer still holds the node collector") + .into_inner(); + Ok((out, nodes)) + } +} + +impl FsChallenge for SymbolicVerifierFs { + fn challenge_field(&mut self) -> MleAst { + let c = self.layout.squeeze(); + self.squeezed_challenges.push(c); + c + } + + // Poseidon semantics (transcript_msgs): full-field squeeze; `challenge_field` + // and `challenge_optimized` return the SAME value (no 128-bit masking), and + // `MleAst::Challenge = MleAst`. + fn challenge_optimized(&mut self) -> ::Challenge { + self.challenge_field() + } +} + +/// Drain the pending-value channels for one serialized `value`, mapping a +/// symbolic scalar to its node and a concrete 32-byte-multiple serialization +/// to constant nodes. A pending COMMITMENT here is a stage-logic bug: a +/// commitment reached a scalar-typed absorb. +fn scalar_nodes_for(value: &T, nodes: &mut Vec) { + // Both pending channels must be empty BEFORE serializing (same guard pattern as + // `absorb`/`absorb_slice`): a node left behind by an earlier serialize would + // otherwise be silently consumed as if it were THIS value when `value` is + // concrete (channel-hygiene bug, not data). + assert!( + take_pending_commitment_chunks().is_none() && take_pending_append().is_none(), + "absorb_scalar(s): stale pending symbolic value before serialization \ + (channel-hygiene bug)" + ); + let mut buf = Vec::new(); + let _ = value.serialize_compressed(&mut buf); + if take_pending_commitment_chunks().is_some() { + panic!("absorb_scalar(s): a commitment reached a scalar-typed absorb (stage-logic bug)"); + } + if let Some(node) = take_pending_append() { + nodes.push(node); + return; + } + // Concrete value: its canonical bytes must be a sequence of 32-byte field + // elements (mirrors the native `parse_scalar_units` contract). + assert!( + buf.len().is_multiple_of(32), + "absorb_scalar(s): concrete value is not a sequence of 32-byte field elements \ + ({} bytes)", + buf.len() + ); + for chunk in buf.chunks_exact(32) { + #[expect(clippy::expect_used)] // caller-contract violation, not data + let fr = Fr::deserialize_compressed(chunk).expect("non-canonical scalar absorbed"); + nodes.push(fr_const(fr)); + } +} + +impl FsAbsorb for SymbolicVerifierFs { + fn absorb(&mut self, value: &T) { + // Untyped absorb = the byte rule over the serialization (native + // Poseidon `absorb`). A symbolic value here is unmirrorable without + // byte decomposition — the field-aligned protocol moved every + // symbolic absorb to the typed methods, so this is a stage-logic bug. + let mut buf = Vec::new(); + let _ = value.serialize_compressed(&mut buf); + assert!( + take_pending_commitment_chunks().is_none() && take_pending_append().is_none(), + "untyped absorb of a symbolic value — use absorb_scalar/absorb_commitment \ + (field-aligned transcript, spec §4.4)" + ); + self.layout.absorb_byte_message(&buf); + } + + fn absorb_slice(&mut self, values: &[T]) { + // One byte-rule message of concatenated serializations (NOT N messages). + let mut bytes = Vec::new(); + for value in values { + let mut buf = Vec::new(); + let _ = value.serialize_compressed(&mut buf); + assert!( + take_pending_commitment_chunks().is_none() && take_pending_append().is_none(), + "untyped absorb_slice of a symbolic value — use absorb_scalars \ + (field-aligned transcript, spec §4.4)" + ); + bytes.extend_from_slice(&buf); + } + self.layout.absorb_byte_message(&bytes); + } + + fn absorb_bytes(&mut self, bytes: &[u8]) { + self.layout.absorb_byte_message(bytes); + } + + fn absorb_scalar(&mut self, value: &T) { + let mut nodes = Vec::with_capacity(1); + scalar_nodes_for(value, &mut nodes); + self.layout.absorb_field_frame(&nodes); + } + + // CRITICAL (review C7): the inherited default `absorb_scalars(values)` = + // `absorb(&values.to_vec())` serializes the whole Vec through the + // single-slot `PENDING_APPEND` thread-local, silently DROPPING k-1 + // symbolic values. Route per-element instead, then absorb ONE count-led + // field frame — matching the native Poseidon `absorb_scalars`. + fn absorb_scalars(&mut self, values: &[T]) { + let mut nodes = Vec::with_capacity(values.len()); + for value in values { + scalar_nodes_for(value, &mut nodes); + } + self.layout.absorb_field_frame(&nodes); + } + + fn absorb_commitment(&mut self, value: &T) { + // Symbolic commitments route their chunk vars through the + // pending-chunks channel; concrete ones fall back to the byte rule + // over their real serialization (both match the native + // `absorb_commitment` = byte rule over the compressed bytes). + let mut buf = Vec::new(); + let _ = value.serialize_compressed(&mut buf); + if let Some(chunks) = take_pending_commitment_chunks() { + self.layout.absorb_commitment_message(&chunks); + } else if take_pending_append().is_some() { + panic!("absorb_commitment of a symbolic scalar — use absorb_scalar (spec §4.4)"); + } else { + self.layout.absorb_byte_message(&buf); + } + } + + fn absorb_commitment_bytes(&mut self, bytes: &[u8]) { + self.layout.absorb_byte_message(bytes); + } +} + +impl VerifierFs for SymbolicVerifierFs { + fn read_slice(&mut self) -> VerificationResult> { + // Untyped frame read: deliberately UNSUPPORTED. The only sound symbolic + // mirror would bake the frame's exact proof bytes into the circuit as + // CONSTANTS (the native Poseidon `read_slice` absorbs them as a + // `RawBytesMsg`), producing a circuit valid for that ONE proof — a + // proof-specific artifact that must never be generated silently. No + // frame on the non-ZK stages-1–7 path reaches this (uni-skip and + // sumcheck rounds are `read_scalars`; the pre-stage frames are + // `read_commitments`); only concrete-valued ZK-era frames would, and + // ZK proofs are refused up front (spec §16 guardrail 4 / §17). + panic!( + "SymbolicVerifierFs::read_slice: untyped NARG frame read reached the symbolic \ + replay — would bake proof bytes as circuit constants (proof-specific circuit). \ + Use read_scalars/read_commitments, or extend the typed layout vocabulary \ + (specs/transpiler-optimization-spec.md §4.4)." + ); + } + + fn read_scalars(&mut self) -> VerificationResult> { + let (values, nodes) = self.pop_symbolized_frame::()?; + debug_assert_eq!(values.len(), nodes.len(), "scalar frame node mismatch"); + self.layout.absorb_field_frame(&nodes); + Ok(values) + } + + fn read_commitments( + &mut self, + ) -> VerificationResult> { + let (values, nodes) = self.pop_symbolized_frame::()?; + // Each commitment must have symbolized to exactly the 13 chunk vars of + // the Dory GT re-chunking (`AstCommitment::deserialize_with_mode`). + if nodes.len() != values.len() * COMMITMENT_CHUNKS { + return Err(VerificationError); + } + let groups: Vec> = nodes + .chunks(COMMITMENT_CHUNKS) + .map(<[MleAst]>::to_vec) + .collect(); + self.layout.absorb_commitments_frame(&groups); + Ok(values) + } +} + +#[cfg(test)] +#[expect(clippy::unwrap_used)] +mod field_aligned_tests { + use super::*; + use crate::ast_evaluator::eval_root; + use crate::poseidon_model::{model_challenges, HighLevelOp}; + use ark_ff::UniformRand; + use ark_serialize::CanonicalSerialize; + use std::collections::HashMap; + use zklean_extractor::mle_ast::node_arena; + + /// THE LEVEL-2 GATE (spec §10.1): drive `FieldAlignedLayout` through a + /// mixed absorb/challenge schedule symbolically, evaluate the resulting + /// challenge AST nodes against a concrete witness, and assert they equal + /// the native sponge's challenges (oracle = `poseidon_model`, itself + /// verified against a real `ProverState`). + #[test] + fn field_aligned_layout_matches_native_sponge() { + let mut rng = ark_std::test_rng(); + let session = b"Jolt"; + let instance = [0x5Cu8; 32]; + + // Symbolic vars with known witness values. + let mut witness: HashMap = HashMap::new(); + let mut next_idx = 0u16; + let mut mk = |vals: &[Fr], witness: &mut HashMap| -> Vec { + vals.iter() + .map(|v| { + let idx = next_idx; + next_idx += 1; + witness.insert(idx, *v); + MleAst::from_var(idx) + }) + .collect() + }; + + let claims: Vec = (0..3).map(|_| Fr::rand(&mut rng)).collect(); + let frame: Vec = (0..5).map(|_| Fr::rand(&mut rng)).collect(); + // Two Dory GT commitments: 384 canonical bytes each, witness = the 13 + // byte-rule chunk values (12×31B + 1×12B, each < 2^248 < r). + let gts: Vec> = (0..2) + .map(|_| { + let gt = ark_bn254::Fq12::rand(&mut rng); + let mut b = Vec::new(); + gt.serialize_compressed(&mut b).unwrap(); + assert_eq!(b.len(), COMMITMENT_BYTES); + b + }) + .collect(); + let chunk_vals = |bytes: &[u8]| -> Vec { jolt_transcript::commitment_to_chunks(bytes) }; + + let claim_asts = mk(&claims, &mut witness); + let frame_asts = mk(&frame, &mut witness); + let gt_groups: Vec> = gts + .iter() + .map(|b| mk(&chunk_vals(b), &mut witness)) + .collect(); + + // Symbolic schedule mirroring the verifier's op kinds; the model gets + // the identical high-level ops. + let mut layout = FieldAlignedLayout::new(session, &instance); + let mut ops: Vec = Vec::new(); + let mut challenges: Vec = Vec::new(); + + // Commitments frame, then the empty advice-presence frame. + layout.absorb_commitments_frame(>_groups); + ops.push(HighLevelOp::AbsorbCommitments(gts.clone())); + layout.absorb_commitments_frame(&[]); + ops.push(HighLevelOp::AbsorbCommitments(Vec::new())); + challenges.push(layout.squeeze()); + ops.push(HighLevelOp::ChallengeFr); + + // Single-scalar absorbs (input claims / flushed claims). + for (c_ast, c_val) in claim_asts.iter().zip(&claims) { + layout.absorb_scalar_message(c_ast); + ops.push(HighLevelOp::AbsorbScalars(vec![*c_val])); + } + challenges.push(layout.squeeze()); + ops.push(HighLevelOp::ChallengeFr); + + // A read_scalars frame, then two back-to-back challenges. + layout.absorb_field_frame(&frame_asts); + ops.push(HighLevelOp::AbsorbScalars(frame.clone())); + challenges.push(layout.squeeze()); + ops.push(HighLevelOp::ChallengeFr); + challenges.push(layout.squeeze()); + ops.push(HighLevelOp::ChallengeFr); + + // A lone trusted-commitment absorb and a raw byte message. + layout.absorb_commitment_message(>_groups[0]); + ops.push(HighLevelOp::AbsorbBytes(gts[0].clone())); + layout.absorb_byte_message(b"jolt-layout-test"); + ops.push(HighLevelOp::AbsorbBytes(b"jolt-layout-test".to_vec())); + challenges.push(layout.squeeze()); + ops.push(HighLevelOp::ChallengeFr); + + // Evaluate the symbolic challenge ASTs against the witness. + let arena = node_arena().read().unwrap().clone(); + let got: Vec = challenges + .iter() + .map(|c| eval_root(&arena, c.root(), &witness)) + .collect(); + + let expected = model_challenges(session, &instance, &ops); + assert_eq!( + got, expected, + "FieldAlignedLayout challenges diverge from the native field-aligned PoseidonSponge" + ); + } + + /// Negative control: dropping the F1 frame-count unit (absorbing the two + /// per-GT groups without the leading `[Fr(2k+1), 0]` pair) must diverge. + #[test] + fn field_aligned_layout_negative_control_missing_frame_count() { + let mut rng = ark_std::test_rng(); + let session = b"Jolt"; + let instance = [0x77u8; 32]; + + let gt = ark_bn254::Fq12::rand(&mut rng); + let mut bytes = Vec::new(); + gt.serialize_compressed(&mut bytes).unwrap(); + let chunk_vals: Vec = jolt_transcript::commitment_to_chunks(&bytes); + let mut witness: HashMap = HashMap::new(); + let chunks: Vec = chunk_vals + .iter() + .enumerate() + .map(|(i, v)| { + let idx = 5000 + i as u16; + witness.insert(idx, *v); + MleAst::from_var(idx) + }) + .collect(); + + // Wrong: lone-commitment encoding where a commitments FRAME is required. + let mut layout = FieldAlignedLayout::new(session, &instance); + layout.absorb_commitment_message(&chunks); + let wrong = layout.squeeze(); + + let arena = node_arena().read().unwrap().clone(); + let wrong_val = eval_root(&arena, wrong.root(), &witness); + let expected = model_challenges( + session, + &instance, + &[ + HighLevelOp::AbsorbCommitments(vec![bytes]), + HighLevelOp::ChallengeFr, + ], + ); + assert_ne!( + wrong_val, expected[0], + "missing frame-count unit also matched — F1 binding would be vacuous" + ); + } + + /// C7 probe: `SymbolicVerifierFs::absorb_scalars` of k symbolic values must + /// reach the layout as ONE field frame of k elements — the inherited default + /// (`absorb(&values.to_vec())`) would serialize through the single-slot + /// `PENDING_APPEND` thread-local and silently drop k−1 values (or panic on + /// the untyped-symbolic guard). + #[test] + fn absorb_scalars_routes_all_symbolic_elements_through_one_frame() { + /// Records every layout call instead of hashing. + #[derive(Default)] + struct RecordingLayout { + field_frames: Vec>, + byte_messages: usize, + } + impl SymbolicSpongeLayout for RecordingLayout { + const FAITHFUL: bool = false; + fn absorb_scalar_message(&mut self, value: &MleAst) { + self.field_frames.push(vec![*value]); + } + fn absorb_field_frame(&mut self, elements: &[MleAst]) { + self.field_frames.push(elements.to_vec()); + } + fn absorb_commitments_frame(&mut self, _groups: &[Vec]) {} + fn absorb_commitment_message(&mut self, _chunks: &[MleAst]) {} + fn absorb_byte_message(&mut self, _bytes: &[u8]) { + self.byte_messages += 1; + } + fn squeeze(&mut self) -> MleAst { + MleAst::from_u64(0) + } + } + + let parsed = crate::narg_parser::parse_narg(&[], false).unwrap(); + let alloc = Rc::new(RefCell::new(VarAllocator::new())); + let mut fs = SymbolicVerifierFs::new(RecordingLayout::default(), parsed, alloc); + + let vars: Vec = (0u16..3).map(|i| MleAst::from_var(9100 + i)).collect(); + FsAbsorb::absorb_scalars(&mut fs, &vars); + + assert_eq!( + fs.layout.field_frames.len(), + 1, + "absorb_scalars must produce exactly one field frame" + ); + assert_eq!( + fs.layout.field_frames[0], vars, + "all 3 symbolic elements must reach the layout, in order" + ); + assert_eq!(fs.layout.byte_messages, 0, "no byte-rule fallback"); + } + + /// Spec guardrail §8.6: the symbolic read path must REJECT a non-canonical + /// (>= r) 32-byte element exactly like the native `FieldFrameMsg` / ark + /// `Validate::Yes` path — decode error → `VerificationError`, never a + /// silently-reduced witness value. + #[test] + fn read_scalars_rejects_non_canonical_element() { + // One canonical element, then 32 bytes of 0xFF (> r). + let mut body = Vec::new(); + Fr::from(7u64).serialize_compressed(&mut body).unwrap(); + body.extend_from_slice(&[0xFF; 32]); + let mut narg = (body.len() as u64).to_le_bytes().to_vec(); + narg.extend_from_slice(&body); + + let parsed = crate::narg_parser::parse_narg(&narg, false).unwrap(); + let alloc = Rc::new(RefCell::new(VarAllocator::new())); + let mut fs = + SymbolicVerifierFs::new(FieldAlignedLayout::new(b"t", &[0u8; 32]), parsed, alloc); + assert!( + VerifierFs::read_scalars(&mut fs).is_err(), + "non-canonical (>= r) frame element must be rejected, matching the native verifier" + ); + } +} diff --git a/transpiler/src/symbolize.rs b/transpiler/src/symbolize.rs index 3e3f47e949..fe3e272b1f 100644 --- a/transpiler/src/symbolize.rs +++ b/transpiler/src/symbolize.rs @@ -1,85 +1,35 @@ -//! Makes IO values (inputs, outputs, panic) into symbolic witness variables. +//! Makes IO values (inputs, outputs, panic) into symbolic witness variables for the +//! RAM evaluation math. //! -//! Without this module, the generated Groth16 circuit hardcodes the concrete IO -//! values as constants — meaning the circuit can only verify ONE specific execution. -//! With it, IO becomes witness inputs, so the same circuit works for any execution. +//! Under the spongefish/NARG protocol the statement (IO included) is bound into the +//! transcript via the **instance digest** (computed off-circuit, baked as a sponge-seed +//! constant — see `pipeline.rs` / TDEV-7), so there is no per-IO-byte transcript absorb +//! to intercept. The single remaining interception point is the RAM MLE: //! -//! # How it works -//! -//! Two separate interception points are set up before `verifier.verify()` runs: -//! -//! 1. **Transcript FIFO** (`PENDING_BYTES_OVERRIDES` in `io_replay.rs`): -//! `fiat_shamir_preamble` hashes IO bytes into the transcript via `append_bytes`. -//! We pre-load a FIFO with symbolic variables (one per 32-byte chunk of -//! inputs/outputs). When `PoseidonAstTranscript::raw_append_bytes` runs, it -//! pops from this FIFO instead of converting the concrete bytes. -//! Note: panic is NOT in the FIFO — it goes through `append_u64`, not `append_bytes`. -//! -//! 2. **RAM MLE override** (`PENDING_IO_MLE` in `jolt-core/zkvm/ram/mod.rs`): -//! `eval_io_mle` evaluates IO as a sparse polynomial over the RAM address space. -//! We set a thread-local with symbolic field elements (one per u64 word of IO + -//! a symbolic panic value). The early-return path in `eval_io_mle` picks these up. -//! -//! # FIFO alignment -//! -//! The FIFO must match the exact order of `raw_append_bytes` calls in -//! `fiat_shamir_preamble` (jolt-core/src/zkvm/mod.rs): -//! -//! ```text -//! append_u64(max_input_size) → raw_append_u64 (no FIFO) -//! append_u64(max_output_size) → raw_append_u64 (no FIFO) -//! append_u64(heap_size) → raw_append_u64 (no FIFO) -//! append_bytes(inputs) → raw_append_bytes → CONSUMES input chunk overrides -//! append_bytes(outputs) → raw_append_bytes → CONSUMES output chunk overrides -//! append_u64(panic) → raw_append_u64 (no FIFO) -//! append_u64(ram_K) → raw_append_u64 (no FIFO) -//! append_u64(trace_length) → raw_append_u64 (no FIFO) -//! ``` -//! -//! After preamble the FIFO must be empty. If not, a stale override would corrupt -//! the next `raw_append_bytes` call. +//! - **RAM MLE override** (`PENDING_IO_MLE` in `jolt-core/zkvm/ram/mod.rs`): +//! `eval_io_mle` evaluates IO as a sparse polynomial over the RAM address space. We +//! set a thread-local with symbolic field elements (one per u64 word of IO + a +//! symbolic panic value); the early-return path in `eval_io_mle` picks these up. use ark_bn254::Fr; -use ark_ff::PrimeField; use common::jolt_device::JoltDevice; use jolt_core::zkvm::ram::{set_pending_io_mle, PendingIoMleValues}; use zklean_extractor::mle_ast::MleAst; use crate::symbolic_proof::VarAllocator; -use crate::symbolic_traits::io_replay::push_bytes_override; -/// Allocate symbolic witness variables for all IO values and set up interception -/// points so that `verifier.verify()` uses them instead of concrete constants. +/// Allocate symbolic witness variables for all IO values and set up the RAM MLE +/// interception point so that `verifier.verify()` uses them instead of concrete +/// constants. /// /// Returns `(input_words, output_words)` at u64-word granularity — these are -/// passed to `PENDING_INITIAL_RAM` in main.rs so `eval_initial_ram_mle` can +/// passed to `PENDING_INITIAL_RAM` in the pipeline so `eval_initial_ram_mle` can /// also use symbolic inputs. pub fn symbolize_io_device( io_device: &JoltDevice, var_alloc: &mut VarAllocator, ) -> (Vec, Vec) { - // --- Transcript FIFO: symbolic overrides for fiat_shamir_preamble --- - // - // One symbolic variable per 32-byte chunk. Consumed in order by - // PoseidonAstTranscript::raw_append_bytes when fiat_shamir_preamble - // calls append_bytes(b"inputs", ...) and append_bytes(b"outputs", ...). - // - // Panic is NOT pushed into this FIFO because fiat_shamir_preamble sends - // it via append_u64 → raw_append_u64 (a different code path that doesn't - // read from the byte-chunk FIFO). Panic IS still fully symbolic in the - // circuit — it enters through two paths: - // 1. Transcript: raw_append_u64 hashes MleAst::from_u64(panic) as a - // concrete constant into the Poseidon state (correct for Fiat-Shamir). - // 2. RAM MLE: allocated as witness variable "io_panic_val" below, used - // by eval_io_mle for panic_contribution and termination checks. - - let _input_chunk_vars = - push_byte_chunk_overrides(&io_device.inputs, "io_input_chunk", var_alloc); - - let _output_chunk_vars = - push_byte_chunk_overrides(&io_device.outputs, "io_output_chunk", var_alloc); - - // --- RAM MLE override: symbolic field elements for eval_io_mle --- + // RAM MLE override: symbolic field elements for eval_io_mle. // // eval_io_mle evaluates IO as a sparse polynomial: each input/output u64 word // sits at a specific RAM address. The symbolic override makes these words @@ -102,27 +52,6 @@ pub fn symbolize_io_device( (eval_input_words, eval_output_words) } -/// Chunk bytes into 32-byte pieces, allocate one symbolic variable per chunk, -/// and push each to the transcript FIFO (`PENDING_BYTES_OVERRIDES`). -fn push_byte_chunk_overrides( - bytes: &[u8], - prefix: &str, - var_alloc: &mut VarAllocator, -) -> Vec { - bytes - .chunks(32) - .enumerate() - .map(|(i, chunk)| { - let mut padded = [0u8; 32]; - padded[..chunk.len()].copy_from_slice(chunk); - let fr_val = Fr::from_le_bytes_mod_order(&padded); - let var = var_alloc.alloc_with_value(&format!("{prefix}_{i}"), &fr_val); - push_bytes_override(var); - var - }) - .collect() -} - /// Split bytes into u64 words (little-endian) and allocate one symbolic variable per word. /// Used for the RAM MLE override where IO is addressed at u64 granularity. fn bytes_to_word_vars(bytes: &[u8], prefix: &str, var_alloc: &mut VarAllocator) -> Vec { diff --git a/transpiler/tests/symbolic_pipeline.rs b/transpiler/tests/symbolic_pipeline.rs new file mode 100644 index 0000000000..d0ffd3d309 --- /dev/null +++ b/transpiler/tests/symbolic_pipeline.rs @@ -0,0 +1,268 @@ +//! End-to-end execution of the symbolic transpilation pipeline (T5 + T1/T2/T3/T4 +//! together) on a REAL non-ZK Poseidon proof — the path the unit tests and the +//! real-transcript parity test do NOT exercise: `MleAst` flowing through all of +//! stages 1–7 over `SymbolicVerifierFs` + `AstOpeningAccumulator`, producing an +//! `AstBundle`. +//! +//! Only meaningful under `transcript-poseidon` (the symbolic sponge models +//! Poseidon) and non-ZK (the transpiler refuses ZK). Run: +//! cargo nextest run -p transpiler --features transcript-poseidon symbolic_pipeline +#![cfg(all(feature = "transcript-poseidon", not(feature = "zk")))] + +use ark_bn254::Fr; +use ark_serialize::{CanonicalDeserialize, CanonicalSerialize}; +use jolt_core::field::JoltField; +use jolt_core::transcript_msgs::{FsAbsorb, FsChallenge, VerifierFs}; +use jolt_core::zkvm::verifier::JoltVerifierPreprocessing; +use jolt_transcript::{verifier_transcript, PoseidonSponge, VerificationResult, VerifierState}; +use serial_test::serial; +use transpiler::pipeline::{bundle_needs_non_native, run_symbolic_pipeline}; + +/// Build a real non-ZK muldiv proof (Poseidon sponge under this feature set, +/// inputs `[9, 5, 3]`) plus the io device and verifier preprocessing the +/// pipeline consumes, mirroring jolt-core's `muldiv_e2e_dory` / parity test. +fn build_muldiv_proof() -> ( + jolt_core::zkvm::RV64IMACProof, + common::jolt_device::JoltDevice, + JoltVerifierPreprocessing< + Fr, + jolt_core::curve::Bn254Curve, + jolt_core::poly::commitment::dory::DoryCommitmentScheme, + >, +) { + use jolt_core::host; + use jolt_core::zkvm::program::ProgramPreprocessing; + use jolt_core::zkvm::prover::JoltProverPreprocessing; + use jolt_core::zkvm::verifier::JoltSharedPreprocessing; + use jolt_core::zkvm::RV64IMACProver; + + let mut program = host::Program::new("muldiv-guest"); + let (bytecode, init_memory_state, _, e_entry) = program.decode(); + let inputs = postcard::to_stdvec(&[9u32, 5u32, 3u32]).unwrap(); + let (_, _, _, io_device) = program.trace(&inputs, &[], &[]); + + let program_pp = + ProgramPreprocessing::preprocess(bytecode, init_memory_state, e_entry).expect("preprocess"); + let shared = JoltSharedPreprocessing::new(program_pp, io_device.memory_layout.clone(), 1 << 16); + let prover_pp = JoltProverPreprocessing::new(shared); + let elf = program.get_elf_contents().expect("elf contents"); + let prover = + RV64IMACProver::gen_from_elf(&prover_pp, &elf, &inputs, &[], &[], None, None, None); + let io_device = prover.program_io.clone(); + let (proof, _debug) = prover.prove(); + assert!(!proof.zk_mode, "test fixture must be a non-ZK proof"); + + let verifier_pp = JoltVerifierPreprocessing::from(&prover_pp); + (proof, io_device, verifier_pp) +} + +/// `VerifierFs` wrapper that delegates every call to the REAL Poseidon +/// verifier transcript and records each squeezed challenge — the native half of +/// the dispatch-layer challenge differential below. +struct RecordingFs<'a> { + inner: VerifierState<'a, PoseidonSponge>, + challenges: Vec, +} + +impl FsChallenge for RecordingFs<'_> { + fn challenge_field(&mut self) -> Fr { + let c: Fr = self.inner.challenge_field(); + self.challenges.push(c); + c + } + fn challenge_optimized(&mut self) -> ::Challenge { + let c = FsChallenge::::challenge_optimized(&mut self.inner); + self.challenges.push(c.into()); + c + } +} + +impl FsAbsorb for RecordingFs<'_> { + fn absorb(&mut self, value: &T) { + self.inner.absorb(value); + } + fn absorb_slice(&mut self, values: &[T]) { + self.inner.absorb_slice(values); + } + fn absorb_bytes(&mut self, bytes: &[u8]) { + self.inner.absorb_bytes(bytes); + } + fn absorb_scalar(&mut self, value: &T) { + self.inner.absorb_scalar(value); + } + fn absorb_scalars(&mut self, values: &[T]) { + self.inner.absorb_scalars(values); + } + fn absorb_commitment(&mut self, value: &T) { + self.inner.absorb_commitment(value); + } + fn absorb_commitment_bytes(&mut self, bytes: &[u8]) { + self.inner.absorb_commitment_bytes(bytes); + } +} + +impl VerifierFs for RecordingFs<'_> { + fn read_slice(&mut self) -> VerificationResult> { + VerifierFs::::read_slice(&mut self.inner) + } + fn read_scalars(&mut self) -> VerificationResult> { + VerifierFs::::read_scalars(&mut self.inner) + } + fn read_commitments( + &mut self, + ) -> VerificationResult> { + VerifierFs::::read_commitments(&mut self.inner) + } +} + +#[test] +#[serial] +fn symbolic_pipeline_runs_on_real_muldiv_poseidon_proof() { + // 1. Build a real muldiv proof (Poseidon sponge, under this feature set), + // mirroring jolt-core's `muldiv_e2e_dory` / parity test. + let (proof, io_device, verifier_pp) = build_muldiv_proof(); + + // 2. Run the ACTUAL symbolic pipeline (the binary's path) on the real proof. + let out = run_symbolic_pipeline(&proof, io_device.clone(), &verifier_pp, None) + .expect("symbolic pipeline must complete on a real proof"); + + // 3. Structural assertions: the full stage-1..7 symbolic replay produced a + // non-trivial circuit. (Value-level agreement with the native verifier is + // the field-aligned differential gates' job — poseidon_model + the + // FieldAlignedLayout test in verifier_fs.rs.) + assert!(out.num_frames > 0, "NARG had no frames to replay"); + assert!( + out.num_assertions > 0, + "symbolic replay recorded zero constraints — verifier asserted nothing" + ); + assert!( + !out.bundle.inputs.is_empty(), + "no witness inputs allocated during replay" + ); + assert!( + !out.bundle.constraints.is_empty(), + "bundle has no constraints" + ); + assert!( + !bundle_needs_non_native(&out.bundle), + "stages 1-7 must be native-field only (no Fq witness)" + ); + // T-O5: the pipeline uses the field-aligned sponge layout, so the bundle's + // Fiat-Shamir challenges match the native verifier (proven value-exact by the + // `poseidon_model` + `field_aligned_layout_matches_native_sponge` differential + // gates). + assert!( + out.sponge_faithful, + "pipeline must use a value-faithful sponge layout (T-O5)" + ); + + println!( + "symbolic pipeline OK: {} frames, {} assertions, {} inputs, {} constraints, {} arena nodes", + out.num_frames, + out.num_assertions, + out.bundle.inputs.len(), + out.bundle.constraints.len(), + out.bundle.nodes.len(), + ); + + // 4. THE DISPATCH-LAYER CHALLENGE DIFFERENTIAL: evaluate every Fiat-Shamir + // challenge AST the symbolic replay squeezed against the recorded witness, + // replay the SAME proof through the native `TranspilableVerifier` over the + // REAL Poseidon transcript, and assert the two challenge sequences are + // element-wise equal. The layout differential (`poseidon_model` + + // `field_aligned_layout_matches_native_sponge`) proves the sponge layout is + // value-faithful for a hand-built schedule; this closes the remaining + // compositional gap — that the symbolic replay drives the layout with the + // EXACT absorb/squeeze schedule the native verifier executes. + assert!( + !out.squeezed_challenge_roots.is_empty(), + "symbolic replay squeezed no challenges" + ); + let witness = out.var_alloc.witness_fr_map(); + // One shared memo cache across roots (challenge k's AST embeds the whole + // chain of challenges 1..k-1). + let symbolic_challenges = transpiler::ast_evaluator::eval_roots( + &out.bundle.nodes, + &out.squeezed_challenge_roots, + &witness, + ); + + let native_challenges = native_verifier_challenges(&proof, io_device, &verifier_pp); + + assert_eq!( + symbolic_challenges.len(), + native_challenges.len(), + "symbolic and native replays squeezed different challenge COUNTS — \ + absorb/squeeze schedule mismatch in the dispatch layer" + ); + assert_eq!( + symbolic_challenges, native_challenges, + "symbolic challenge ASTs evaluate differently from the native verifier's \ + Fiat-Shamir challenges on the same proof" + ); + println!( + "challenge differential OK: {} challenges match the native verifier", + native_challenges.len() + ); +} + +/// Replay `proof` through the native `TranspilableVerifier` over the real Poseidon +/// verifier transcript (built exactly like the `muldiv_transpilable_verifier_parity` +/// test in jolt-core), recording every squeezed challenge. +fn native_verifier_challenges( + proof: &jolt_core::zkvm::RV64IMACProof, + io_device: common::jolt_device::JoltDevice, + verifier_pp: &JoltVerifierPreprocessing< + Fr, + jolt_core::curve::Bn254Curve, + jolt_core::poly::commitment::dory::DoryCommitmentScheme, + >, +) -> Vec { + use jolt_core::poly::commitment::dory::DoryCommitmentScheme; + use jolt_core::poly::opening_proof::VerifierOpeningAccumulator; + use jolt_core::utils::math::Math; + use jolt_core::zkvm::fiat_shamir_instance; + use jolt_core::zkvm::transpilable_verifier::{TranspilableProofData, TranspilableVerifier}; + + // Pre-seed the accumulator with the structural opening claims, exactly as + // `JoltVerifier::new` does. + let mut accumulator = VerifierOpeningAccumulator::::new(proof.trace_length.log_2(), false); + accumulator.preseed_structural_claims(&proof.opening_claims.0); + + let proof_data = TranspilableProofData::from_proof(proof); + let mut verifier: TranspilableVerifier< + '_, + Fr, + jolt_core::curve::Bn254Curve, + DoryCommitmentScheme, + VerifierOpeningAccumulator, + > = TranspilableVerifier::new(verifier_pp, proof_data, io_device, None, accumulator) + .expect("native TranspilableVerifier construction failed"); + + // Real transcript over the proof's NARG, mirroring `verify_inner` (instance + // over the TRUNCATED program_io the verifier holds). + let preprocessing_digest = verifier_pp.shared.digest(); + let instance = fiat_shamir_instance( + &verifier.program_io, + proof.ram_K, + proof.trace_length, + verifier_pp.shared.program_meta.entry_address, + &proof.rw_config, + &proof.one_hot_config, + proof.dory_layout, + &preprocessing_digest, + ); + let mut recorder = RecordingFs { + inner: verifier_transcript(b"Jolt", instance, PoseidonSponge::default(), &proof.narg), + challenges: Vec::new(), + }; + + verifier + .verify(&mut recorder) + .expect("native TranspilableVerifier failed on the real proof"); + recorder + .inner + .check_eof() + .expect("NARG not fully consumed after stage 7"); + recorder.challenges +} diff --git a/zklean-extractor/src/ast_bundle.rs b/zklean-extractor/src/ast_bundle.rs index c236c2376c..9ee9771e47 100644 --- a/zklean-extractor/src/ast_bundle.rs +++ b/zklean-extractor/src/ast_bundle.rs @@ -11,27 +11,29 @@ use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; -use crate::mle_ast::{node_arena, set_pending_commitment_chunks, Edge, MleAst, Node, NodeId}; +use crate::mle_ast::{ + node_arena, set_pending_commitment_chunks, symbolize_read_bytes, Atom, Edge, MleAst, Node, + NodeId, Scalar, TranscriptHashData, +}; // ============================================================================= // Input and Constraint Types // ============================================================================= -/// The witness type for an input variable. -/// -/// Determines how it's treated in circuit generation: -/// - `PublicStatement`: Fixed for a given program (constant in circuit) -/// - `ProofData`: Varies per proof (variable witness in circuit) +/// The witness type for an input variable, which drives its **gnark visibility**: +/// - `PublicStatement`: emitted as a `gnark:",public"` input — the program statement +/// (IO) plus the stage-8 binding values (opening claims, commitments) the on-chain +/// PCS check must see. +/// - `ProofData`: emitted as a `gnark:",secret"` witness — proof bytes the circuit +/// re-derives Fiat-Shamir from in-circuit, so they need not be public. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum WitnessType { - /// Public statement data (constant in the circuit). - /// This includes things like: program bytecode hash, memory layout params, - /// input/output hashes, etc. These are absorbed into the transcript during - /// fiat_shamir_preamble but are fixed for a given program. + /// Public circuit input: the program IO statement and the stage-8 binding values + /// (opening claims, polynomial/advice/trusted commitments). Kept public so an + /// on-chain wrapper can bind them to the statement and to the deferred PCS check. PublicStatement, - /// Proof data (variable in the circuit). - /// This includes everything that comes from the proof: commitments, - /// sumcheck coefficients, opening claims, etc. These vary per proof. + /// Secret circuit witness: sumcheck round polynomials, uni-skip coefficients, and + /// other per-proof bytes. Self-binding via the in-circuit sponge, so not public. ProofData, } @@ -244,14 +246,6 @@ impl AstBundle { self.inputs.iter().any(|i| i.target_field == field) } - /// Count inputs for a specific target field. - pub fn count_inputs_for_field(&self, field: TargetField) -> usize { - self.inputs - .iter() - .filter(|i| i.target_field == field) - .count() - } - /// Add a constraint that asserts an expression equals zero. pub fn add_constraint_eq_zero(&mut self, name: impl Into, root: NodeId) { self.constraints.push(Constraint { @@ -294,11 +288,142 @@ impl AstBundle { self.nodes = guard.clone(); } - /// Run global CSE: identify nodes shared across ≥2 constraints and hoist them. + /// Structural hash-consing + dead-node sweep (transpiler-optimization spec §5.1/§5.3). + /// + /// A POST-HOC pass over the snapshotted arena (deliberately NOT arena-time + /// interning: replay must stay byte-identical and NodeId assignment deterministic; + /// see the spec's §5.1 rationale). Two effects, one remap: + /// + /// 1. **Hash-consing**: nodes are interned by (kind, canonicalized children), with + /// commutative canonicalization for `Add`/`Mul` (operands sorted in the intern + /// key only — the surviving node keeps its original operand order, which is + /// value-identical). Edges to `Node::Atom` nodes are normalized to inline + /// `Edge::Atom`s so the two representations of the same atom unify. Duplicates + /// map to the *minimum* NodeId representative — the arena is topological + /// (children strictly precede parents), so min-id representatives preserve that + /// invariant and a single forward scan sees final canonical children. + /// 2. **Dead-node sweep**: nodes unreachable from the constraint roots (incl. + /// `EqualNode` targets) and `extra_roots` are dropped, and surviving nodes are + /// compacted to dense NodeIds (in original order, preserving topology). /// - /// This finds `TranscriptHash` nodes (and their dependencies) that appear in - /// multiple constraint subtrees. These are computed once in a global block - /// instead of being duplicated in each constraint function. + /// All edge kinds are remapped: plain children, `TranscriptHash` data/state/rounds + /// edges, constraint roots, `EqualNode` targets, and the caller's `extra_roots` + /// (remapped in place — used by the pipeline for squeezed-challenge ASTs that live + /// outside the constraint set). Inputs are Var-indexed and unaffected. + /// + /// Must run BEFORE `run_global_cse()`/`run_cse()`: CSE bindings are NodeId lists + /// and are invalidated by the compaction (they are reset here defensively). + pub fn canonicalize_and_sweep(&mut self, extra_roots: &mut [NodeId]) -> CanonicalizeStats { + let n = self.nodes.len(); + + // Pass 1: forward hash-consing scan. Children precede parents, so canon[] is + // final for every child when its parent is visited. + let mut canon: Vec = (0..n).collect(); + let mut intern: HashMap = HashMap::with_capacity(n); + let mut duplicates_merged = 0usize; + for i in 0..n { + let remapped = map_node_edges(self.nodes[i].clone(), &mut |e| match e { + Edge::NodeRef(id) => { + debug_assert!(id < i, "arena must be topological (children < parent)"); + let c = canon[id]; + // Normalize references-to-atom-nodes to inline atoms so both + // representations of the same atom unify under one key. + match &self.nodes[c] { + Node::Atom(a) => Edge::Atom(*a), + _ => Edge::NodeRef(c), + } + } + atom => atom, + }); + self.nodes[i] = remapped.clone(); + match intern.entry(commutative_key(remapped)) { + std::collections::hash_map::Entry::Occupied(entry) => { + canon[i] = *entry.get(); + duplicates_merged += 1; + } + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(i); + } + } + } + + // Pass 2: reachability from (canonicalized) constraint roots + extra roots. + let mut reachable = vec![false; n]; + let mut stack: Vec = Vec::new(); + for constraint in &self.constraints { + stack.push(canon[constraint.root]); + if let Assertion::EqualNode(other) = &constraint.assertion { + stack.push(canon[*other]); + } + } + stack.extend(extra_roots.iter().map(|r| canon[*r])); + while let Some(id) = stack.pop() { + if reachable[id] { + continue; + } + reachable[id] = true; + // Children of a rewritten node already point at canonical ids. + stack.extend(self.node_children(id)); + } + + // Pass 3: compaction in original id order (preserves topological order). + let mut new_id: Vec = vec![usize::MAX; n]; + let mut new_nodes: Vec = Vec::with_capacity(reachable.iter().filter(|b| **b).count()); + for i in 0..n { + if !reachable[i] { + continue; + } + new_id[i] = new_nodes.len(); + let node = map_node_edges(self.nodes[i].clone(), &mut |e| match e { + Edge::NodeRef(id) => { + debug_assert_ne!( + new_id[id], + usize::MAX, + "reachable node {i} references unreachable child {id}" + ); + Edge::NodeRef(new_id[id]) + } + atom => atom, + }); + new_nodes.push(node); + } + let nodes_after = new_nodes.len(); + let dead_nodes_dropped = n - duplicates_merged - nodes_after; + self.nodes = new_nodes; + + // Pass 4: remap constraint roots, EqualNode targets, and extra roots. + for constraint in &mut self.constraints { + constraint.root = new_id[canon[constraint.root]]; + if let Assertion::EqualNode(other) = &mut constraint.assertion { + *other = new_id[canon[*other]]; + } + } + for root in extra_roots.iter_mut() { + *root = new_id[canon[*root]]; + } + + // CSE bindings are NodeId lists; any previously computed ones are now stale. + self.global_cse = GlobalCse::default(); + self.constraint_cse.clear(); + + CanonicalizeStats { + nodes_before: n, + duplicates_merged, + dead_nodes_dropped, + nodes_after, + } + } + + /// Run global CSE: identify nodes shared across ≥2 constraints and hoist them into + /// a global block computed once, instead of being duplicated in each constraint + /// function. + /// + /// EVERY non-atom node reachable from ≥2 distinct constraints is hoisted — not just + /// `TranscriptHash` nodes. Shared compound nodes (eq-poly products, challenge + /// powers, etc.) sit *above* the hash nodes in the DAG, so a hash-only filter + /// (with a downward-only dependency walk) would leave them to be re-emitted in + /// every consuming constraint. Atoms stay inline (free); only multi-constraint + /// compound nodes hoist. /// /// Call this after `snapshot_arena()` and before `run_cse()`. pub fn run_global_cse(&mut self) { @@ -318,14 +443,19 @@ impl AstBundle { } } - // Phase 2: Find TranscriptHash nodes that appear in ≥2 distinct constraints + // Phase 2: Hoist every non-atom node reachable from ≥2 distinct constraints. + // `node_to_constraints` already enumerates all such nodes, so the old downward + // dependency expansion is unnecessary (children of a hoisted node that are + // themselves multi-constraint are caught here directly). let mut global_nodes: HashSet = HashSet::new(); for (&node_id, constraints) in &node_to_constraints { - // Deduplicate constraint indices + if matches!(self.nodes[node_id], Node::Atom(_)) { + continue; + } let mut unique: Vec = constraints.clone(); unique.sort_unstable(); unique.dedup(); - if unique.len() >= 2 && matches!(self.nodes[node_id], Node::TranscriptHash(..)) { + if unique.len() >= 2 { global_nodes.insert(node_id); } } @@ -335,48 +465,28 @@ impl AstBundle { return; } - // Phase 3: Include dependencies of global nodes that are also multi-constraint. - // Walk children of each global node; if a child is in ≥2 constraints and is - // non-trivial (not an atom), include it too. This captures the full chain. - let mut expanded = global_nodes.clone(); - let mut worklist: Vec = global_nodes.into_iter().collect(); - while let Some(node_id) = worklist.pop() { - for child_id in self.node_children(node_id) { - if expanded.contains(&child_id) { - continue; - } - if matches!(self.nodes[child_id], Node::Atom(_)) { - continue; - } - // Check if child is in ≥2 distinct constraints - if let Some(constraints) = node_to_constraints.get(&child_id) { - let mut unique: Vec = constraints.clone(); - unique.sort_unstable(); - unique.dedup(); - if unique.len() >= 2 { - expanded.insert(child_id); - worklist.push(child_id); - } - } - } - } - - // Phase 4: Topological sort (post-order) of the expanded set - // We need a post-order that respects dependencies within the global set. - // Use a multi-root post-order traversal restricted to the expanded set. - let bindings = self.topological_sort_subset(&expanded); + // Phase 3: Topological sort (post-order) of the global set so each node's + // children are computed before it. + let bindings = self.topological_sort_subset(&global_nodes); self.global_cse = GlobalCse { bindings }; } /// Topological sort a subset of nodes in post-order (children before parents). + /// + /// Roots are visited in ascending `NodeId` order (not `HashSet` iteration order) so + /// the output — and therefore the `gcse[i]` index assignment derived from it — is + /// DETERMINISTIC across runs. The arena assigns NodeIds in a fixed construction + /// order, so this yields reproducible generated circuits (hence a reproducible + /// Groth16 proving/verifying key) for the same proof. fn topological_sort_subset(&self, subset: &HashSet) -> Vec { let mut result = Vec::new(); let mut visited: HashSet = HashSet::new(); let mut stack: Vec<(NodeId, bool)> = Vec::new(); - // Start from all nodes in the subset - for &node_id in subset { + let mut roots: Vec = subset.iter().copied().collect(); + roots.sort_unstable(); + for node_id in roots { if visited.contains(&node_id) { continue; } @@ -533,24 +643,20 @@ impl AstBundle { match &self.nodes[node_id] { Node::Atom(_) => vec![], Node::Neg(e) | Node::Inv(e) => edge_to_node_id(*e).into_iter().collect(), - Node::ByteReverse(e) - | Node::Truncate128Reverse(e) - | Node::Truncate128(e) - | Node::AppendU64Transform(e) => edge_to_node_id(*e).into_iter().collect(), Node::Add(l, r) | Node::Mul(l, r) | Node::Sub(l, r) | Node::Div(l, r) => { [edge_to_node_id(*l), edge_to_node_id(*r)] .into_iter() .flatten() .collect() } - Node::TranscriptHash(hash_data, state, n_rounds) => { + Node::TranscriptHash(hash_data, state, rate_unit_a) => { let mut children: Vec = hash_data .as_slice() .iter() .filter_map(|e| edge_to_node_id(*e)) .collect(); children.extend(edge_to_node_id(*state)); - children.extend(edge_to_node_id(*n_rounds)); + children.extend(edge_to_node_id(*rate_unit_a)); children } } @@ -588,22 +694,23 @@ impl AstBundle { .count() } - /// Serialize to pretty-printed JSON string (used by write_json). - fn to_json_pretty(&self) -> Result { - serde_json::to_string_pretty(self) - } - /// Deserialize from JSON string (used by read_json). fn from_json(json: &str) -> Result { serde_json::from_str(json) } /// Write to a JSON file. + /// + /// Streams compact JSON straight to a buffered file handle rather than + /// materializing a pretty-printed `String` of the whole arena first — the arena can + /// hold millions of nodes, so the intermediate `String` (several× the on-disk size) + /// was a large, avoidable peak-memory spike. pub fn write_json(&self, path: &std::path::Path) -> std::io::Result<()> { - let json = self - .to_json_pretty() + use std::io::Write; + let mut file = std::io::BufWriter::new(std::fs::File::create(path)?); + serde_json::to_writer(&mut file, self) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?; - std::fs::write(path, json) + file.flush() } /// Read from a JSON file. @@ -620,44 +727,105 @@ impl Default for AstBundle { } } +/// Counters returned by [`AstBundle::canonicalize_and_sweep`]. +#[derive(Debug, Default, Clone, Copy)] +pub struct CanonicalizeStats { + pub nodes_before: usize, + /// Structural duplicates merged into an earlier identical node. + pub duplicates_merged: usize, + /// Canonical nodes unreachable from any constraint/extra root. + pub dead_nodes_dropped: usize, + pub nodes_after: usize, +} + +/// Rebuild a node with every child edge passed through `f` (plain children, +/// `TranscriptHash` data/state/rate_unit_a edges included). Non-edge payloads +/// (atoms) are preserved. +fn map_node_edges(node: Node, f: &mut impl FnMut(Edge) -> Edge) -> Node { + match node { + Node::Atom(a) => Node::Atom(a), + Node::Neg(e) => Node::Neg(f(e)), + Node::Inv(e) => Node::Inv(f(e)), + Node::Add(a, b) => Node::Add(f(a), f(b)), + Node::Mul(a, b) => Node::Mul(f(a), f(b)), + Node::Sub(a, b) => Node::Sub(f(a), f(b)), + Node::Div(a, b) => Node::Div(f(a), f(b)), + Node::TranscriptHash(data, state, rate_unit_a) => { + let TranscriptHashData::Poseidon(e) = data; + let data = TranscriptHashData::Poseidon(f(e)); + Node::TranscriptHash(data, f(state), f(rate_unit_a)) + } + } +} + +/// Total order on edges used for commutative canonicalization of `Add`/`Mul` +/// intern keys (any fixed total order works; it only has to be deterministic). +fn edge_sort_key(e: &Edge) -> (u8, u128, Scalar) { + match e { + Edge::Atom(Atom::Scalar(s)) => (0, 0, *s), + Edge::Atom(Atom::Var(v)) => (1, *v as u128, [0; 4]), + Edge::Atom(Atom::NamedVar(v)) => (2, *v as u128, [0; 4]), + Edge::NodeRef(id) => (3, *id as u128, [0; 4]), + } +} + +/// Intern key for hash-consing: the node itself, with `Add`/`Mul` operands sorted +/// (field + and × are commutative, so `Mul(a, b)` and `Mul(b, a)` are +/// value-identical). `Sub`/`Div` are NOT commutative and keep operand order. +fn commutative_key(node: Node) -> Node { + match node { + Node::Add(a, b) if edge_sort_key(&b) < edge_sort_key(&a) => Node::Add(b, a), + Node::Mul(a, b) if edge_sort_key(&b) < edge_sort_key(&a) => Node::Mul(b, a), + other => other, + } +} + // ============================================================================= // AstCommitment // ============================================================================= /// Wrapper type for a commitment represented as MleAst chunks. /// -/// In the real verifier, commitments are `PCS::Commitment` (e.g., G1Affine for Dory). -/// When `append_serializable` is called, it serializes to bytes and calls `append_bytes` -/// which chunks into 32-byte pieces and hashes them with proper chaining. -/// -/// For symbolic execution, we represent each chunk as an MleAst variable. -/// When `AstCommitment` is serialized, it stores the chunks in the -/// `PENDING_COMMITMENT_CHUNKS` thread-local. `PoseidonAstTranscript::append_serializable` -/// then retrieves them and performs the same hash chaining operation symbolically. -/// -/// # Commitment Size +/// In the real verifier, commitments are `PCS::Commitment` (a Dory GT +/// element, 384 canonical bytes). The field-aligned Poseidon transcript +/// (specs/transpiler-optimization-spec.md §4.2) absorbs each commitment as +/// the byte rule over its serialization: [`COMMITMENT_CHUNKS`] = 13 +/// little-endian chunks of [`BYTES_PER_CHUNK`] = 31 bytes (the last chunk is +/// the 12-byte remainder; 12×31 + 12 = 384). Each chunk is < 2²⁴⁸ < r, so +/// chunk ↦ `Fr` is injective and byte-reconstructible — this re-chunking is +/// what dissolved the review-spec "GT bytes as 32-byte reductions" blocker. /// -/// The number of chunks depends on the PCS commitment type: -/// - **Dory**: 384 bytes → 12 chunks (G1Affine on BN254) -/// - **HyperKZG**: Variable size depending on configuration -/// - **Other PCS**: Determined at symbolization time from `serialized_size()` -/// -/// This type is PCS-agnostic: chunk count is derived from the concrete commitment's -/// serialized size during `symbolize_proof()`, not hardcoded here. +/// For symbolic execution, each chunk is an MleAst witness variable. When +/// `AstCommitment` is serialized, it stores the chunks in the +/// `PENDING_COMMITMENT_CHUNKS` thread-local; the transpiler's +/// `SymbolicVerifierFs::absorb_commitment` retrieves them and feeds the +/// sponge layout's commitment hook. #[derive(Clone, Debug)] pub struct AstCommitment { - /// The MleAst chunks representing this commitment (one per 32 bytes of serialized form) + /// The MleAst chunks representing this commitment (13 byte-rule chunks + /// per Dory GT: 12×31B + 1×12B). pub chunks: Vec, } -/// Number of bytes per chunk (one BN254 field element) -const BYTES_PER_CHUNK: usize = 32; +/// Bytes per byte-rule chunk (31 = the largest whole-byte count with +/// chunk < 2²⁴⁸ < r, so every chunk embeds injectively in a BN254 `Fr`). +/// Mirrors `jolt_transcript::BYTE_RULE_CHUNK`. +pub const BYTES_PER_CHUNK: usize = 31; + +/// Canonical byte length of one Dory GT commitment (Fq12, compressed == +/// uncompressed = 384 bytes) — the only commitment type the transpiler +/// handles today. +pub const COMMITMENT_BYTES: usize = 384; + +/// Byte-rule chunk count per commitment: ceil(384 / 31) = 13 +/// (12 full 31-byte chunks + one 12-byte tail). +pub const COMMITMENT_CHUNKS: usize = COMMITMENT_BYTES.div_ceil(BYTES_PER_CHUNK); impl AstCommitment { /// Create a new AstCommitment from chunks. /// - /// The number of chunks should match `ceil(serialized_size / 32)` of the - /// concrete commitment being symbolized. + /// The number of chunks should match `ceil(serialized_size / 31)` of the + /// concrete commitment being symbolized ([`COMMITMENT_CHUNKS`] for Dory). /// /// # Panics /// Panics if `chunks` is empty. @@ -668,12 +836,6 @@ impl AstCommitment { ); Self { chunks } } - - /// Returns the serialized size in bytes (chunks × 32). - #[inline] - pub fn serialized_byte_len(&self) -> usize { - self.chunks.len() * BYTES_PER_CHUNK - } } impl CanonicalSerialize for AstCommitment { @@ -682,23 +844,45 @@ impl CanonicalSerialize for AstCommitment { _writer: W, _compress: ark_serialize::Compress, ) -> Result<(), SerializationError> { - // Store chunks in thread-local for PoseidonAstTranscript::append_serializable to retrieve + // Store chunks in thread-local for SymbolicVerifierFs::absorb_commitment to retrieve set_pending_commitment_chunks(self.chunks.clone()); Ok(()) } fn serialized_size(&self, _compress: ark_serialize::Compress) -> usize { - self.serialized_byte_len() + // The 31-byte chunking is not length-invertible, so this is fixed to the + // Dory GT width the deserializer consumes. + COMMITMENT_BYTES } } +/// Reads one commitment's worth of REAL proof bytes ([`COMMITMENT_BYTES`] = +/// Dory GT — an immutable const, not a mutable thread-local, so there is no +/// set-without-restore hazard; code-review #4) and symbolizes each byte-rule +/// chunk (12×31B + 1×12B, zero-padded into the 32-byte hook buffer — LE, so +/// the witness value equals `Fr::from_le_bytes_mod_order(chunk)`) into a +/// fresh witness variable via the `set_read_symbolizer` hook. Used by the +/// transpiler's `SymbolicVerifierFs::read_commitments` on the +/// commitment/advice frames. impl CanonicalDeserialize for AstCommitment { fn deserialize_with_mode( - _reader: R, + mut reader: R, _compress: ark_serialize::Compress, _validate: ark_serialize::Validate, ) -> Result { - unimplemented!("AstCommitment deserialization not needed for transpilation") + let mut chunks = Vec::with_capacity(COMMITMENT_CHUNKS); + let mut remaining = COMMITMENT_BYTES; + while remaining > 0 { + let take = remaining.min(BYTES_PER_CHUNK); + let mut bytes = [0u8; 32]; + reader + .read_exact(&mut bytes[..take]) + .map_err(|_| SerializationError::InvalidData)?; + chunks.push(symbolize_read_bytes(&bytes).ok_or(SerializationError::InvalidData)?); + remaining -= take; + } + debug_assert_eq!(chunks.len(), COMMITMENT_CHUNKS); + Ok(Self { chunks }) } } @@ -729,3 +913,264 @@ impl PartialEq for AstCommitment { .all(|(a, b)| a.root() == b.root()) } } + +#[cfg(test)] +mod canonicalize_tests { + use super::*; + + fn var(i: u16) -> Edge { + Edge::Atom(Atom::Var(i)) + } + + fn scalar(x: u64) -> Edge { + Edge::Atom(Atom::Scalar([x, 0, 0, 0])) + } + + /// Bundle built directly on local nodes (no global arena involvement). + fn bundle_with(nodes: Vec) -> AstBundle { + AstBundle { + nodes, + ..AstBundle::new() + } + } + + #[test] + fn merges_structurally_identical_subtrees() { + // Two identical subtrees (v0 * v1) feeding one Add: exactly one Mul survives. + let mut bundle = bundle_with(vec![ + Node::Mul(var(0), var(1)), + Node::Mul(var(0), var(1)), + Node::Add(Edge::NodeRef(0), Edge::NodeRef(1)), + ]); + bundle.add_constraint_eq_zero("c0", 2); + + let stats = bundle.canonicalize_and_sweep(&mut []); + + assert_eq!(stats.duplicates_merged, 1); + assert_eq!(bundle.nodes.len(), 2); + let root = bundle.constraints[0].root; + match &bundle.nodes[root] { + Node::Add(Edge::NodeRef(a), Edge::NodeRef(b)) => { + assert_eq!(a, b, "both edges must point at the single surviving Mul"); + assert!(matches!(bundle.nodes[*a], Node::Mul(_, _))); + } + other => panic!("expected Add of two NodeRefs, got {other:?}"), + } + } + + #[test] + fn commutative_canonicalization_merges_swapped_mul_but_not_sub() { + let mut bundle = bundle_with(vec![ + Node::Mul(var(0), var(1)), + Node::Mul(var(1), var(0)), // dup of 0 under commutativity + Node::Sub(var(0), var(1)), + Node::Sub(var(1), var(0)), // NOT a dup: Sub is order-sensitive + Node::Add(Edge::NodeRef(0), Edge::NodeRef(1)), + Node::Add(Edge::NodeRef(2), Edge::NodeRef(3)), + Node::Add(Edge::NodeRef(4), Edge::NodeRef(5)), + ]); + bundle.add_constraint_eq_zero("c0", 6); + + let stats = bundle.canonicalize_and_sweep(&mut []); + + assert_eq!(stats.duplicates_merged, 1, "only the swapped Mul merges"); + let n_subs = bundle + .nodes + .iter() + .filter(|n| matches!(n, Node::Sub(_, _))) + .count(); + assert_eq!(n_subs, 2, "both Sub orientations must survive"); + let n_muls = bundle + .nodes + .iter() + .filter(|n| matches!(n, Node::Mul(_, _))) + .count(); + assert_eq!(n_muls, 1); + } + + #[test] + fn sweeps_dead_nodes_and_remaps_equal_node_targets() { + let mut bundle = bundle_with(vec![ + Node::Atom(Atom::Scalar([7, 0, 0, 0])), // dead (only ever atom-inlined) + Node::Mul(var(0), scalar(3)), // constraint root + Node::Mul(var(9), var(9)), // dead: unreachable + Node::Add(var(1), scalar(2)), // EqualNode target + ]); + bundle.add_constraint_eq_node("c0", 1, 3); + + let stats = bundle.canonicalize_and_sweep(&mut []); + + assert_eq!(stats.dead_nodes_dropped, 2); + assert_eq!(bundle.nodes.len(), 2); + let root = bundle.constraints[0].root; + assert!(matches!(bundle.nodes[root], Node::Mul(_, _))); + match &bundle.constraints[0].assertion { + Assertion::EqualNode(other) => { + assert!(matches!(bundle.nodes[*other], Node::Add(_, _))); + } + other => panic!("expected EqualNode, got {other:?}"), + } + } + + #[test] + fn remaps_transcript_hash_data_edges_through_duplicates() { + let mut bundle = bundle_with(vec![ + Node::Add(var(0), var(1)), + Node::Add(var(0), var(1)), // dup of 0 + Node::TranscriptHash( + TranscriptHashData::Poseidon(Edge::NodeRef(1)), + var(2), + scalar(1), + ), + Node::TranscriptHash( + TranscriptHashData::Poseidon(Edge::NodeRef(0)), + var(2), + scalar(1), + ), + Node::Sub(Edge::NodeRef(2), Edge::NodeRef(3)), + ]); + bundle.add_constraint_eq_zero("c0", 4); + + let stats = bundle.canonicalize_and_sweep(&mut []); + + // The Add dup merges; the two hashes then become identical and merge too. + assert_eq!(stats.duplicates_merged, 2); + let n_hashes = bundle + .nodes + .iter() + .filter(|n| matches!(n, Node::TranscriptHash(_, _, _))) + .count(); + assert_eq!(n_hashes, 1); + let root = bundle.constraints[0].root; + match &bundle.nodes[root] { + Node::Sub(Edge::NodeRef(a), Edge::NodeRef(b)) => { + assert_eq!(a, b); + match &bundle.nodes[*a] { + Node::TranscriptHash(TranscriptHashData::Poseidon(Edge::NodeRef(d)), _, _) => { + assert!(matches!(bundle.nodes[*d], Node::Add(_, _))); + } + other => panic!("expected Poseidon hash with NodeRef data edge, got {other:?}"), + } + } + other => panic!("expected Sub of two NodeRefs, got {other:?}"), + } + } + + #[test] + fn extra_roots_stay_alive_and_are_remapped() { + let mut bundle = bundle_with(vec![ + Node::Mul(var(0), var(1)), + Node::Mul(var(0), var(1)), // dup of 0; an extra root points here + Node::Add(Edge::NodeRef(0), scalar(5)), // constraint root + Node::Mul(var(7), var(8)), // extra root, NOT constraint-reachable + ]); + bundle.add_constraint_eq_zero("c0", 2); + let mut extra_roots = vec![1, 3]; + + bundle.canonicalize_and_sweep(&mut extra_roots); + + // Extra root 1 collapses onto the canonical Mul(v0, v1); extra root 3 survives + // the sweep despite being unreachable from constraints. + let r0 = extra_roots[0]; + let r1 = extra_roots[1]; + assert!(matches!(bundle.nodes[r0], Node::Mul(var0, var1) + if var0 == var(0) && var1 == var(1))); + assert!(matches!(bundle.nodes[r1], Node::Mul(var7, var8) + if var7 == var(7) && var8 == var(8))); + // And the constraint root's child edge points at the same canonical Mul. + match &bundle.nodes[bundle.constraints[0].root] { + Node::Add(Edge::NodeRef(a), _) => assert_eq!(*a, r0), + other => panic!("expected Add(NodeRef, _), got {other:?}"), + } + } + + #[test] + fn non_commutative_kinds_never_merge() { + // Div swaps, Sub swaps, and cross-kind (Add vs Mul) must all stay + // distinct — only the literal Add/Mul operand swap is canonicalized. + let mut bundle = bundle_with(vec![ + Node::Div(var(0), var(1)), + Node::Div(var(1), var(0)), // NOT a dup: Div is order-sensitive + Node::Sub(var(0), var(1)), + Node::Sub(var(1), var(0)), // NOT a dup: Sub is order-sensitive + Node::Add(var(0), var(1)), + Node::Mul(var(0), var(1)), // NOT a dup of the Add: different kind + Node::Add(Edge::NodeRef(0), Edge::NodeRef(1)), + Node::Add(Edge::NodeRef(2), Edge::NodeRef(3)), + Node::Add(Edge::NodeRef(4), Edge::NodeRef(5)), + Node::Add(Edge::NodeRef(6), Edge::NodeRef(7)), + Node::Add(Edge::NodeRef(8), Edge::NodeRef(9)), + ]); + bundle.add_constraint_eq_zero("c0", 10); + + let stats = bundle.canonicalize_and_sweep(&mut []); + + assert_eq!(stats.duplicates_merged, 0); + assert_eq!(stats.dead_nodes_dropped, 0); + assert_eq!(bundle.nodes.len(), 11); + } + + #[test] + fn transcript_hash_chains_do_not_collapse() { + // Sponge chain h1 = H(d, s0, r), h2 = H(d, h1, r): same data + rounds but the + // state edge differs by construction — h2 must NOT merge with h1. A second + // hash with the same data + state but a different round count must also stay + // distinct. Only a hash with ALL THREE of (data, state, rounds) identical + // merges (pure-function dedup). + let d = var(0); + let s0 = var(1); + let mut bundle = bundle_with(vec![ + Node::TranscriptHash(TranscriptHashData::Poseidon(d), s0, scalar(1)), // h1 + Node::TranscriptHash(TranscriptHashData::Poseidon(d), Edge::NodeRef(0), scalar(1)), // h2: state = h1 + Node::TranscriptHash(TranscriptHashData::Poseidon(d), s0, scalar(2)), // h3: rounds differ + Node::TranscriptHash(TranscriptHashData::Poseidon(d), s0, scalar(1)), // h4: true dup of h1 + Node::Add(Edge::NodeRef(1), Edge::NodeRef(2)), + Node::Add(Edge::NodeRef(4), Edge::NodeRef(3)), + ]); + bundle.add_constraint_eq_zero("c0", 5); + + let stats = bundle.canonicalize_and_sweep(&mut []); + + assert_eq!( + stats.duplicates_merged, 1, + "only the (data,state,rounds)-identical h4 merges" + ); + let n_hashes = bundle + .nodes + .iter() + .filter(|n| matches!(n, Node::TranscriptHash(_, _, _))) + .count(); + assert_eq!( + n_hashes, 3, + "h1, h2 (chained state), h3 (different rounds) all survive" + ); + // The chain edge survives intact: some hash's state edge points at another hash. + let chained = bundle.nodes.iter().any(|n| { + matches!(n, Node::TranscriptHash(_, Edge::NodeRef(s), _) + if matches!(bundle.nodes[*s], Node::TranscriptHash(_, _, _))) + }); + assert!(chained, "h2's state edge must still reference h1"); + } + + #[test] + fn atom_node_references_unify_with_inline_atoms() { + // Mul(NodeRef -> Atom(v0), v1) must merge with Mul(inline v0, v1). + let mut bundle = bundle_with(vec![ + Node::Atom(Atom::Var(0)), + Node::Mul(Edge::NodeRef(0), var(1)), + Node::Mul(var(0), var(1)), + Node::Add(Edge::NodeRef(1), Edge::NodeRef(2)), + ]); + bundle.add_constraint_eq_zero("c0", 3); + + let stats = bundle.canonicalize_and_sweep(&mut []); + + assert_eq!(stats.duplicates_merged, 1); + let n_muls = bundle + .nodes + .iter() + .filter(|n| matches!(n, Node::Mul(_, _))) + .count(); + assert_eq!(n_muls, 1); + } +} diff --git a/zklean-extractor/src/lib.rs b/zklean-extractor/src/lib.rs index aacacda0d3..b2e30cff1e 100644 --- a/zklean-extractor/src/lib.rs +++ b/zklean-extractor/src/lib.rs @@ -26,9 +26,9 @@ pub mod mle_ast; pub mod scalar_ops; // Re-export core types -pub use ast_bundle::{Assertion, AstBundle, AstCommitment, TargetField, WitnessType}; -pub use mle_ast::{ - set_pending_commitment_chunks, set_pending_point_elements, take_pending_commitment_chunks, - take_pending_point_elements, +pub use ast_bundle::{ + Assertion, AstBundle, AstCommitment, TargetField, WitnessType, BYTES_PER_CHUNK, + COMMITMENT_BYTES, COMMITMENT_CHUNKS, }; +pub use mle_ast::{set_pending_commitment_chunks, take_pending_commitment_chunks}; pub use mle_ast::{DefaultMleAst, MleAst}; diff --git a/zklean-extractor/src/mle_ast.rs b/zklean-extractor/src/mle_ast.rs index f52c0f3e71..dfde839688 100644 --- a/zklean-extractor/src/mle_ast.rs +++ b/zklean-extractor/src/mle_ast.rs @@ -136,7 +136,7 @@ fn edge_for_root(root: NodeId) -> Edge { // for ASTs, we use thread-local storage to tunnel the actual MleAst values through // these trait boundaries. // -// This will be used by PoseidonAstTranscript (in transpiler) to build symbolic +// This will be used by SymbolicVerifierFs (in transpiler) to build symbolic // AST nodes for Poseidon hash operations during verifier transpilation. // // Note: These are kept here (rather than a separate module) because they're used by @@ -145,28 +145,40 @@ fn edge_for_root(root: NodeId) -> Edge { // cleanly decoupled from MleAst without introducing circular dependencies. // ============================================================================= +/// Symbolizer hook for `MleAst::deserialize_with_mode`: maps the 32 raw bytes of one +/// field element read from a NARG frame to a fresh symbolic variable (allocated with +/// its witness value by the transpiler's `SymbolicVerifierFs`). +/// +/// This replaces the former `PENDING_CHALLENGE` tunnel: under the spongefish surface, +/// symbolic challenges flow back *directly* from the transpiler's `FsChallenge` impl +/// (its methods return `F = MleAst`), and prover-message frame reads flow through this +/// hook — `from_bytes` no longer participates in either. +pub type ReadSymbolizer = Box MleAst>; + thread_local! { - static PENDING_CHALLENGE: RefCell> = const { RefCell::new(None) }; + static READ_SYMBOLIZER: RefCell> = const { RefCell::new(None) }; } -/// Set a pending challenge that will be returned by the next MleAst::from_bytes call. -/// Called by PoseidonAstTranscript::challenge_scalar before returning. -pub fn set_pending_challenge(challenge: MleAst) { - PENDING_CHALLENGE.with(|cell| { - *cell.borrow_mut() = Some(challenge); - }); +/// Install the frame-read symbolizer (capturing the variable allocator + naming +/// context). Active until [`clear_read_symbolizer`]. +pub fn set_read_symbolizer(symbolizer: ReadSymbolizer) { + READ_SYMBOLIZER.with(|cell| *cell.borrow_mut() = Some(symbolizer)); } -/// Take the pending challenge (if any). -fn take_pending_challenge() -> Option { - PENDING_CHALLENGE.with(|cell| cell.borrow_mut().take()) +pub fn clear_read_symbolizer() { + READ_SYMBOLIZER.with(|cell| *cell.borrow_mut() = None); +} + +/// Run the installed symbolizer on one 32-byte element; `None` if not installed. +pub fn symbolize_read_bytes(bytes: &[u8; 32]) -> Option { + READ_SYMBOLIZER.with(|cell| cell.borrow_mut().as_mut().map(|f| f(bytes))) } thread_local! { static PENDING_APPEND: RefCell> = const { RefCell::new(None) }; } -/// Set a pending MleAst value that will be retrieved by PoseidonAstTranscript::append_scalar. +/// Set a pending MleAst value that will be retrieved by SymbolicVerifierFs's absorb path. /// Called by MleAst::serialize_with_mode. pub fn set_pending_append(value: MleAst) { PENDING_APPEND.with(|cell| { @@ -175,7 +187,7 @@ pub fn set_pending_append(value: MleAst) { } /// Take the pending append value (if any). -/// Called by PoseidonAstTranscript::append_scalar to get the actual MleAst. +/// Called by SymbolicVerifierFs's absorb path to get the actual MleAst. pub fn take_pending_append() -> Option { PENDING_APPEND.with(|cell| cell.borrow_mut().take()) } @@ -184,7 +196,7 @@ thread_local! { static PENDING_COMMITMENT_CHUNKS: RefCell>> = const { RefCell::new(None) }; } -/// Set pending commitment chunks for PoseidonAstTranscript::append_serializable. +/// Set pending commitment chunks for SymbolicVerifierFs::absorb_commitment. /// Called by AstCommitment::serialize_with_mode. pub fn set_pending_commitment_chunks(chunks: Vec) { PENDING_COMMITMENT_CHUNKS.with(|cell| { @@ -193,34 +205,11 @@ pub fn set_pending_commitment_chunks(chunks: Vec) { } /// Take the pending commitment chunks (if any). -/// Called by PoseidonAstTranscript::append_serializable to get the 12 MleAst chunks. +/// Called by the symbolic transcript's `absorb_commitment` to get the 13 MleAst chunks. pub fn take_pending_commitment_chunks() -> Option> { PENDING_COMMITMENT_CHUNKS.with(|cell| cell.borrow_mut().take()) } -thread_local! { - static PENDING_POINT_ELEMENTS: RefCell>> = const { RefCell::new(None) }; -} - -/// Set pending point elements for PoseidonAstTranscript::raw_append_point. -/// `elements` must have exactly 2 entries: [x_field_element, y_field_element]. -pub fn set_pending_point_elements(elements: Vec) { - assert_eq!( - elements.len(), - 2, - "Point must have exactly 2 elements (x, y)" - ); - PENDING_POINT_ELEMENTS.with(|cell| { - *cell.borrow_mut() = Some(elements); - }); -} - -/// Take the pending point elements (if any). -/// Called by PoseidonAstTranscript::raw_append_point to get the 2 MleAst elements. -pub fn take_pending_point_elements() -> Option> { - PENDING_POINT_ELEMENTS.with(|cell| cell.borrow_mut().take()) -} - // ============================================================================= // Symbolic constraint accumulation for transpilation // ============================================================================= @@ -333,26 +322,19 @@ pub enum Edge { NodeRef(NodeId), } -/// Data payload for a transcript hash node. -/// The variant determines both the hash backend and the data shape (arity). +/// Data payload for a transcript hash node. The transpiler is Poseidon-only, +/// so this carries exactly one data element (arity enforced by type). #[derive(Debug, Hash, PartialEq, Eq, Clone, Serialize, Deserialize)] pub enum TranscriptHashData { - /// Poseidon: exactly 1 data element. Arity enforced by type. + /// Poseidon: exactly 1 data element. Poseidon(Edge), - /// Blake2b: variable arity (0..N data elements in a single hash call). - Blake2b(Vec), - /// Keccak: variable arity (0..N data elements in a single hash call). - Keccak(Vec), } impl TranscriptHashData { /// View data elements as a slice (generic traversal). pub fn as_slice(&self) -> &[Edge] { - match self { - Self::Poseidon(e) => std::slice::from_ref(e), - Self::Blake2b(v) => v.as_slice(), - Self::Keccak(v) => v.as_slice(), - } + let Self::Poseidon(e) = self; + std::slice::from_ref(e) } } @@ -375,38 +357,11 @@ pub enum Node { /// The quotient between the first and second nodes (from zklean base, unused by Jolt transpiler) /// NOTE: No div-by-zero checks are performed here Div(Edge, Edge), - /// Transcript hash: hash(state, n_rounds, data). - /// The `TranscriptHashData` variant determines which hash function and arity. + /// Field-aligned Poseidon compression: `poseidon(state, rate_unit_a, data)`. + /// The second slot is the first absorbed rate UNIT (a squeeze passes 0); it + /// is NOT a round counter — the width-4 Circom permutation has a fixed round + /// schedule. TranscriptHash(TranscriptHashData, Edge, Edge), - // ------------------------------------------------------------------------- - // Byte-level transcript transforms - // ------------------------------------------------------------------------- - // Note: These are NOT used by Poseidon (which operates on field elements natively). - // They are used by Blake2b/Keccak transcripts, which use byte-level serialization - // inherited from the original Jolt transcript design (for EVM compatibility). - // ------------------------------------------------------------------------- - /// Byte-reverse a field element. - /// Transforms: serialize(x) as LE bytes -> reverse -> from_le_bytes_mod_order. - /// Used by transcript append_scalar which reverses bytes for EVM compatibility. - ByteReverse(Edge), - /// Truncate to 128 bits and byte-reverse, then shift by 2^128. - /// Used for challenge_scalar_optimized which produces F::Challenge (MontU128Challenge). - /// Transforms: take low 16 bytes (LE) -> reverse -> from_bytes -> multiply by 2^128. - /// The 2^128 shift matches MontU128Challenge internal layout. - Truncate128Reverse(Edge), - /// Truncate to 128 bits and byte-reverse WITHOUT shifting. - /// Used for challenge_scalar which produces F (raw field element). - /// Transforms: take low 16 bytes (LE) -> reverse -> from_bytes. - Truncate128(Edge), - /// Transform for transcript append_u64. - /// - /// Computes: bswap64(x) * 2^192 - /// - /// Packs u64 x into bytes 24-31 of a 32-byte array using x.to_be_bytes(), - /// then interprets the 32 bytes as a little-endian field element. - /// - /// The result is bswap64(x) * 2^192, not just x * 2^192. - AppendU64Transform(Edge), } /// An AST intended for representing an MLE computation (although it will actually work for any @@ -476,100 +431,21 @@ impl MleAst { self.root } - /// Poseidon hash with 3 inputs (state, n_rounds, data). - pub fn poseidon(state: &Self, n_rounds: &Self, data: &Self) -> Self { + /// Field-aligned Poseidon compression with 3 inputs + /// `(state, rate_unit_a, data)`. `rate_unit_a` is the first absorbed rate + /// unit (a squeeze passes 0), NOT a round counter. + pub fn poseidon(state: &Self, rate_unit_a: &Self, data: &Self) -> Self { let state_edge = edge_for_root(state.root); - let rounds_edge = edge_for_root(n_rounds.root); + let rate_unit_a_edge = edge_for_root(rate_unit_a.root); let data_edge = edge_for_root(data.root); let root = insert_node(Node::TranscriptHash( TranscriptHashData::Poseidon(data_edge), state_edge, - rounds_edge, + rate_unit_a_edge, )); Self { root, - reg_name: state.reg_name.or(n_rounds.reg_name).or(data.reg_name), - } - } - - /// Blake2b hash with variable-arity data. - pub fn blake2b(state: &Self, n_rounds: &Self, data: &[Self]) -> Self { - let data_edges: Vec = data.iter().map(|d| edge_for_root(d.root)).collect(); - let root = insert_node(Node::TranscriptHash( - TranscriptHashData::Blake2b(data_edges), - edge_for_root(state.root), - edge_for_root(n_rounds.root), - )); - Self { - root, - reg_name: state.reg_name.or(n_rounds.reg_name), - } - } - - /// Keccak hash with variable-arity data. - pub fn keccak(state: &Self, n_rounds: &Self, data: &[Self]) -> Self { - let data_edges: Vec = data.iter().map(|d| edge_for_root(d.root)).collect(); - let root = insert_node(Node::TranscriptHash( - TranscriptHashData::Keccak(data_edges), - edge_for_root(state.root), - edge_for_root(n_rounds.root), - )); - Self { - root, - reg_name: state.reg_name.or(n_rounds.reg_name), - } - } - - /// Byte-reverse a field element. - /// Transforms: serialize(x) as LE bytes -> reverse -> from_le_bytes_mod_order. - pub fn byte_reverse(input: &Self) -> Self { - let edge = edge_for_root(input.root); - let root = insert_node(Node::ByteReverse(edge)); - Self { - root, - reg_name: input.reg_name, - } - } - - /// Truncate to 128 bits and byte-reverse, then shift by 2^128. - /// Used for challenge_scalar_optimized (produces MontU128Challenge). - /// Transforms: take low 16 bytes (LE) -> reverse -> from_bytes -> multiply by 2^128. - pub fn truncate_128_reverse(input: &Self) -> Self { - let edge = edge_for_root(input.root); - let root = insert_node(Node::Truncate128Reverse(edge)); - Self { - root, - reg_name: input.reg_name, - } - } - - /// Truncate to 128 bits and byte-reverse WITHOUT shifting. - /// Used for challenge_scalar (produces raw F field element). - /// Transforms: take low 16 bytes (LE) -> reverse -> from_bytes. - pub fn truncate_128(input: &Self) -> Self { - let edge = edge_for_root(input.root); - let root = insert_node(Node::Truncate128(edge)); - Self { - root, - reg_name: input.reg_name, - } - } - - /// Transform for PoseidonTranscript::append_u64. - /// - /// Computes: bswap64(x) * 2^192 - /// - /// This matches PoseidonTranscript::raw_append_u64 which: - /// 1. Packs u64 x into bytes 24-31 of a 32-byte array using x.to_be_bytes() - /// 2. Interprets the 32 bytes as a little-endian field element - /// - /// The result is bswap64(x) * 2^192, not just x * 2^192. - pub fn append_u64_transform(input: &Self) -> Self { - let edge = edge_for_root(input.root); - let root = insert_node(Node::AppendU64Transform(edge)); - Self { - root, - reg_name: input.reg_name, + reg_name: state.reg_name.or(rate_unit_a.reg_name).or(data.reg_name), } } } @@ -596,13 +472,9 @@ fn evaluate_node(node: NodeId, env: &Environment) -> F { Node::Mul(e1, e2) => evaluate_edge(e1, env) * evaluate_edge(e2, env), Node::Sub(e1, e2) => evaluate_edge(e1, env) - evaluate_edge(e2, env), Node::Div(e1, e2) => evaluate_edge(e1, env) / evaluate_edge(e2, env), - Node::TranscriptHash(_, _, _) - | Node::ByteReverse(_) - | Node::Truncate128Reverse(_) - | Node::Truncate128(_) - | Node::AppendU64Transform(_) => { - // Hash/transform nodes are for circuit generation only, not field evaluation - unreachable!("Hash/transform nodes should not appear in zklean-extractor tests") + Node::TranscriptHash(_, _, _) => { + // Hash nodes are for circuit generation only, not field evaluation + unreachable!("Hash nodes should not appear in zklean-extractor tests") } } } @@ -694,12 +566,7 @@ fn fmt_node( fmt_edge(f, fmt_data, e2, true) } Node::TranscriptHash(ref hash_data, e1, e2) => { - let backend_name = match hash_data { - TranscriptHashData::Poseidon(_) => "Poseidon", - TranscriptHashData::Blake2b(_) => "Blake2b", - TranscriptHashData::Keccak(_) => "Keccak", - }; - write!(f, "transcript_hash({backend_name}, ")?; + write!(f, "transcript_hash(Poseidon, ")?; fmt_edge(f, fmt_data, e1, false)?; write!(f, ", ")?; fmt_edge(f, fmt_data, e2, false)?; @@ -712,26 +579,6 @@ fn fmt_node( } write!(f, "])") } - Node::ByteReverse(edge) => { - write!(f, "byte_reverse(")?; - fmt_edge(f, fmt_data, edge, false)?; - write!(f, ")") - } - Node::Truncate128Reverse(edge) => { - write!(f, "truncate_128_reverse(")?; - fmt_edge(f, fmt_data, edge, false)?; - write!(f, ")") - } - Node::Truncate128(edge) => { - write!(f, "truncate_128(")?; - fmt_edge(f, fmt_data, edge, false)?; - write!(f, ")") - } - Node::AppendU64Transform(edge) => { - write!(f, "append_u64_transform(")?; - fmt_edge(f, fmt_data, edge, false)?; - write!(f, ")") - } } } @@ -754,12 +601,7 @@ fn node_depth(node: &Node) -> usize { } match node { Node::Atom(_) => 0, - Node::Neg(e) - | Node::Inv(e) - | Node::ByteReverse(e) - | Node::Truncate128Reverse(e) - | Node::Truncate128(e) - | Node::AppendU64Transform(e) => 1 + edge_depth(*e), + Node::Neg(e) | Node::Inv(e) => 1 + edge_depth(*e), Node::Add(e1, e2) | Node::Mul(e1, e2) | Node::Sub(e1, e2) | Node::Div(e1, e2) => { 1 + max(edge_depth(*e1), edge_depth(*e2)) } @@ -857,13 +699,8 @@ pub fn common_subexpression_elimination(node: Node) -> (Vec, Node) { let cse_e2 = aux_edge(bindings, nodes, e2); register(bindings, nodes, Node::Div(cse_e1, cse_e2)) } - // Transpilation-only nodes: used by challenge derivation and Blake/Keccak transcripts. - // Lean4 extraction never encounters them. - Node::TranscriptHash(..) - | Node::ByteReverse(..) - | Node::Truncate128Reverse(..) - | Node::Truncate128(..) - | Node::AppendU64Transform(..) => { + // Transpilation-only node; Lean4 extraction never encounters it. + Node::TranscriptHash(..) => { unreachable!( "Transpilation-only node {:?} should never appear in Lean4 CSE", node @@ -1403,11 +1240,12 @@ impl JoltField for MleAst { } fn from_bytes(_bytes: &[u8]) -> Self { - // Check if there's a pending challenge from PoseidonAstTranscript - if let Some(challenge) = take_pending_challenge() { - return challenge; - } - panic!("MleAst::from_bytes called without a pending challenge — PoseidonAstTranscript must call set_pending_challenge() before from_bytes()") + // Unreachable: symbolic challenges return directly from the transpiler's + // `FsChallenge` impl (the former PENDING_CHALLENGE tunnel is gone) and NARG + // frame reads go through the read-symbolizer hook (`CanonicalDeserialize`). + // Decoding here would silently bake a CONSTANT where a symbolic node is + // required, so fail loud instead. + unimplemented!("MleAst::from_bytes has no reachable caller") } fn inverse(&self) -> Option { @@ -1486,7 +1324,7 @@ impl JoltField for MleAst { /// through the generic `Transcript` trait (which expects `CanonicalSerialize`). /// /// `serialize_with_mode` stores `self` in a thread-local via `set_pending_append`, -/// which `PoseidonAstTranscript::raw_append_scalar` retrieves via `take_pending_append`. +/// which `SymbolicVerifierFs`'s absorb path retrieves via `take_pending_append`. impl CanonicalSerialize for MleAst { fn serialize_with_mode( &self, @@ -1503,21 +1341,38 @@ impl CanonicalSerialize for MleAst { } } -/// Required by `JoltField` trait bound but not called during symbolic execution. +/// Reads exactly 32 bytes (one serialized field element) and maps them to a fresh +/// symbolic variable via the installed [`set_read_symbolizer`] hook. This is how NARG +/// frame bytes become circuit witness variables during symbolic replay: the +/// transpiler's `SymbolicVerifierFs::read_slice` installs the hook (capturing the +/// variable allocator + naming context), then drives the standard byte-cursor decode +/// loop over the frame — exactly mirroring jolt-core's `read_all`. impl CanonicalDeserialize for MleAst { fn deserialize_with_mode( - _reader: R, + mut reader: R, _compress: ark_serialize::Compress, _validate: ark_serialize::Validate, ) -> Result { - unimplemented!("MleAst deserialization not needed. We build ASTs, not read them") + let mut bytes = [0u8; 32]; + reader + .read_exact(&mut bytes) + .map_err(|_| SerializationError::InvalidData)?; + // Spec guardrail §8.6: REJECT non-canonical (>= r) elements exactly like the + // native path (`FieldFrameMsg` / ark `Validate::Yes` both refuse them), so the + // symbolic replay never accepts a frame the real verifier rejects — and the + // symbolizer's `from_le_bytes_mod_order` witness value is exact, not reduced. + if ark_bn254::Fr::deserialize_compressed(bytes.as_slice()).is_err() { + return Err(SerializationError::InvalidData); + } + // No hook installed = programmer error (deserializing MleAst outside a + // symbolic frame replay); surfaced as InvalidData through the read path. + symbolize_read_bytes(&bytes).ok_or(SerializationError::InvalidData) } } -/// Required by `CanonicalDeserialize` but not called during symbolic execution. impl Valid for MleAst { fn check(&self) -> Result<(), SerializationError> { - unimplemented!("MleAst validation not needed") + Ok(()) } } @@ -1657,19 +1512,20 @@ mod tests { // ============================================================================= #[test] - fn test_pending_challenge_round_trip() { - // Test thread-local challenge tunneling - let challenge = MleAst::from_u64(12345); + fn test_read_symbolizer_round_trip() { + // The frame-read hook maps 32-byte elements to symbolic values. + let expected = MleAst::from_u64(12345); + set_read_symbolizer(Box::new(move |_bytes| expected)); - set_pending_challenge(challenge); - let retrieved = take_pending_challenge(); + let bytes = [7u8; 32]; + let got = symbolize_read_bytes(&bytes).expect("hook installed"); + assert_eq!(got.root(), expected.root()); - assert!(retrieved.is_some()); - assert_eq!(retrieved.unwrap().root(), challenge.root()); - - // Taking again should return None - let empty = take_pending_challenge(); - assert!(empty.is_none()); + clear_read_symbolizer(); + assert!( + symbolize_read_bytes(&bytes).is_none(), + "hook must be gone after clear" + ); } #[test] @@ -1712,37 +1568,6 @@ mod tests { assert!(empty.is_none()); } - #[test] - fn test_pending_point_elements_round_trip() { - // Test thread-local point elements tunneling (must be exactly 2 elements) - let elements = vec![MleAst::from_u64(100), MleAst::from_u64(200)]; - - set_pending_point_elements(elements.clone()); - let retrieved = take_pending_point_elements(); - - assert!(retrieved.is_some()); - let retrieved_elements = retrieved.unwrap(); - assert_eq!(retrieved_elements.len(), 2); - assert_eq!(retrieved_elements[0].root(), elements[0].root()); - assert_eq!(retrieved_elements[1].root(), elements[1].root()); - - // Taking again should return None - let empty = take_pending_point_elements(); - assert!(empty.is_none()); - } - - #[test] - #[should_panic(expected = "Point must have exactly 2 elements")] - fn test_pending_point_elements_wrong_length() { - // Should panic if not exactly 2 elements - let elements = vec![ - MleAst::from_u64(1), - MleAst::from_u64(2), - MleAst::from_u64(3), - ]; - set_pending_point_elements(elements); - } - // ============================================================================= // Constraint Mode Tests // =============================================================================