From a29bff277a93520ba7fac11a0bc08da368adf975 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Tue, 21 Apr 2026 09:47:22 +0530 Subject: [PATCH 01/21] spec: port jolt-transcript to spongefish --- specs/jolt-transcript-spongefish.md | 156 ++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 specs/jolt-transcript-spongefish.md diff --git a/specs/jolt-transcript-spongefish.md b/specs/jolt-transcript-spongefish.md new file mode 100644 index 0000000000..0762a5b4b0 --- /dev/null +++ b/specs/jolt-transcript-spongefish.md @@ -0,0 +1,156 @@ +# Spec: Port jolt-transcript to spongefish + +| Field | Value | +| --------- | --------------- | +| Author(s) | @shreyas-londhe | +| Created | 2026-04-17 | +| Status | proposed | +| PR | | + +## Summary + +Jolt's current `Transcript` trait is a bespoke hash-based Fiat-Shamir construction with three backends (Blake2b, Keccak, Poseidon), each implementing custom label+length packing and challenge-extraction logic. This spec replaces that in-house construction with [spongefish](https://github.com/arkworks-rs/spongefish) — an audited duplex-sponge Fiat-Shamir library — and adopts its NARG-proof model so subprotocols (sumcheck, uni-skip, PCS) contribute to a single opaque byte string instead of directly appending typed messages onto a shared transcript struct. The result is a cleaner PCS boundary (dory contributes through a bridge into the same sponge), auditable domain-separation semantics, and alignment with the wider Arkworks spongefish ecosystem. This PR covers the Jolt side; a coordinated follow-up PR on `a16z/dory` makes dory consume `jolt-transcript` directly. + +## Intent + +### Goal + +Replace Jolt's hand-rolled Fiat-Shamir transcript with spongefish's duplex-sponge construction across all three transcript variants, defining `ProverTranscript` and `VerifierTranscript` traits implemented directly on `spongefish::ProverState` / `spongefish::VerifierState` where `Sponge` is Cargo-feature-selected (`spongefish::Blake2b512`, `spongefish::Keccak`, or a new `PoseidonSponge: DuplexSpongeInterface` impl over `light-poseidon`). The new traits adopt spongefish's positional message style — per-call string labels (e.g., `b"opening_claim"`, `b"ram_K"`) are dropped. Domain separation lives only in the one-time `DomainSeparator` string used at transcript construction. + +Key abstractions introduced: + +- **`ProverTranscript`** (trait, in `crates/jolt-transcript`) — positional methods: `public_message(&T)`, `prover_message(&T)`, `verifier_message() -> T`, plus the 128-bit-truncating challenge methods that preserve the current `challenge_*_optimized` performance profile, and `narg_string()`. +- **`VerifierTranscript`** (trait, in `crates/jolt-transcript`) — positional methods: `public_message(&T)`, `receive_prover_message() -> T`, `verifier_message() -> T`, the same 128-bit-truncating challenge methods, and `check_eof()`. +- **`PoseidonSponge`** (type, in `crates/jolt-transcript`) — new `DuplexSpongeInterface` impl wrapping `light-poseidon`, so Poseidon plugs into spongefish like any other sponge. +- Trait impls live directly on `spongefish::ProverState` / `VerifierState` (orphan rule allows it); no wrapper structs. + +### Invariants + +1. **Prover/verifier sponge symmetry.** For every Jolt protocol path (standard and ZK), the prover and verifier absorb the same sequence of values into their sponges in the same order (positional — per-call labels no longer exist). This is the inductive property — challenge streams match on both sides, and protocol correctness and soundness carry over unchanged from the pre-port implementation. + +2. **Functional equivalence for the Rust `JoltVerifier`.** Every Jolt proof produced by the off-chain prover continues to verify against the in-tree Rust `JoltVerifier`, across all three sponges (Blake2b / Keccak / Poseidon), in both `--features host` and `--features host,zk` modes. + +### Non-Goals + +1. **Consolidating dory onto `jolt-transcript` in this PR.** The end state is dory depending on `jolt-transcript` and sharing the same `ProverTranscript` / `VerifierTranscript` traits — but that change lives in a coordinated follow-up PR on `a16z/dory`. In the interim, the `JoltToDoryTranscript` bridge adapter (`jolt-core/src/poly/commitment/dory/wrappers.rs:336`) is updated to wrap the new spongefish-backed traits while dory keeps its current `DoryTranscript` trait. The bridge is removed once the follow-up lands. + +2. **Updating `transpilable_verifier.rs`'s Solidity emission to match spongefish's Keccak absorb layout.** The Rust side migrates; the Solidity verifier update is a coordinated downstream follow-up. + +3. **Updating `transpiler/` (gnark) and `zklean-extractor/` (Lean) to match spongefish's Poseidon absorb layout.** The Rust side migrates; the gnark and Lean verifier updates are coordinated downstream follow-ups. + +4. **Cryptographic redesign of what gets absorbed.** This is a mechanical port of protocol semantics: the same values bind to the transcript in the same order. The only structural API change is that per-call string labels are dropped — in a deterministic protocol flow, positional order already provides domain separation, and production spongefish consumers (WhiR, sigma-rs) use purely positional calls. Labels survive only in the one-time `DomainSeparator` string used at transcript construction. No reconsideration of which values bind to the transcript or in what order. + +5. **Guaranteeing stable spongefish NARG byte format across future spongefish releases.** Off-chain proofs are regenerated per release. + +6. **Performance improvement beyond no-regression.** Bar: `muldiv` e2e no slower than current Blake2b. + +## Evaluation + +### Acceptance Criteria + +- [ ] `crates/jolt-transcript` exposes `ProverTranscript` / `VerifierTranscript` traits implemented directly on `spongefish::ProverState` / `spongefish::VerifierState` (no wrapper structs). +- [ ] Three sponge backends selectable via Cargo feature: `transcript-blake2b` → `spongefish::Blake2b512`; `transcript-keccak` → `spongefish::Keccak`; `transcript-poseidon` → new `PoseidonSponge: DuplexSpongeInterface` over `light-poseidon`. +- [ ] `jolt-core/src/transcripts/` is deleted; `jolt-core` depends on `jolt-transcript`. +- [ ] `JoltToDoryTranscript` bridge (`jolt-core/src/poly/commitment/dory/wrappers.rs:336`) is updated to wrap the new traits and continues to satisfy dory's `DoryTranscript`. +- [ ] `muldiv` e2e passes under `--features host` for each of the three sponges. +- [ ] `muldiv` e2e passes under `--features host,zk` for each of the three sponges. +- [ ] `crates/jolt-transcript/tests/` and `crates/jolt-transcript/fuzz/` are deleted alongside the custom transcript implementation they were written to test. These tests exercised generic Fiat-Shamir properties (determinism, domain separation, challenge uniqueness, order sensitivity, clone independence) that are now owned by spongefish's own test suite; duplicating them in Jolt would be maintenance burden with no Jolt-specific correctness benefit. + +- [ ] `crates/jolt-transcript/benches/transcript_ops.rs` is adapted to the new positional API and retained. Its role shifts from "micro-benchmarking our transcript implementation" to "regression gauge for spongefish operations under Jolt's usage pattern." Serves the Performance section's regression check. + +- [ ] All `jolt-core` unit and integration tests remain green after mechanical API renames only: `append_bytes(&b)` → `public_message(&b)` / `prover_message(&b)`, `challenge_*` → `verifier_message::()`, `Transcript::new(label)` → `DomainSeparator::new(...).instance(...).std_prover()`. No `jolt-core` test is deleted or `#[ignore]`d. + +- [ ] `jolt-transcript` depends on the latest published `spongefish` release on crates.io at implementation time; the resolved version is captured in `Cargo.lock`. + +### Testing Strategy + +**Existing tests must keep passing:** + +- `muldiv` e2e under `--features host` and `--features host,zk`, across all three Cargo feature sponges (`transcript-blake2b`, `transcript-keccak`, `transcript-poseidon`). +- All `jolt-core` unit and integration tests across the same matrix (with mechanical API renames as described in Acceptance Criteria). + +**Tests removed alongside the code they test:** + +- `crates/jolt-transcript/tests/` (all four files and the `common/` shared macro) — tested the custom transcript implementation's generic Fiat-Shamir properties; spongefish owns these tests for its own sponges upstream. +- `crates/jolt-transcript/fuzz/` — fuzzed the custom transcript's no-panic guarantee; spongefish owns this upstream. + +**New tests:** None required. Correctness of the new code (the `PoseidonSponge: DuplexSpongeInterface` impl and the 128-bit-truncating decoder) is exercised by the `muldiv` e2e matrix — if either is wrong, e2e fails for the affected sponge or challenge path. + +**Open question for maintainers:** + +> How should CI treat `transpiler/go/e2e_test.go` and any Solidity integration tests that pin the current Poseidon / Keccak absorb byte layout? These will fail as a direct consequence of this port, since spongefish's domain separator and absorb semantics differ from the current hand-rolled layout. Options considered: (a) mark as `#[ignore]` until the downstream gnark / Solidity follow-ups land; (b) leave them failing, gating merge on coordinated downstream PR readiness; (c) maintainer's preference. + +### Performance + +No observable regression beyond benchmark noise on the `transcript_ops` micro-benchmark (`crates/jolt-transcript/benches/transcript_ops.rs`) and on the `muldiv` e2e wall-clock under `--features host` with `transcript-blake2b` (default). + +Rationale: transcript operations are fundamentally hash calls (absorb, squeeze). The underlying hash implementation per sponge is unchanged — Blake2b is still Blake2b, Keccak is still Keccak, Poseidon is still Poseidon. Spongefish's construction adds a thin domain-separation framework over the sponge; it does not change the dominant cost. + +## Design + +### Architecture + +**`crates/jolt-transcript`** (currently parked; this spec activates it): + +- Adds `spongefish` and `light-poseidon` dependencies. +- Defines `ProverTranscript` and `VerifierTranscript` traits with positional method signatures matching spongefish-native shape: `public_message(&T)` on both, `prover_message(&T)` on prover / `receive_prover_message() -> T` on verifier, `verifier_message() -> T` for challenges on both. Challenge decoders implement `spongefish::Decoding<[H::U]>`; a custom 128-bit-truncating decoder preserves the performance profile of the current `challenge_*_optimized` family (63 hot-path callsites). `narg_string()` on prover, `check_eof()` on verifier. +- Implements these traits directly on `spongefish::ProverState` / `spongefish::VerifierState` — no wrapper structs. +- Provides a new `PoseidonSponge: DuplexSpongeInterface` impl wrapping `light-poseidon` so Poseidon plugs into spongefish. +- Retains the existing Cargo feature names `transcript-blake2b` / `transcript-keccak` / `transcript-poseidon`, each selecting the corresponding sponge type. `transcript-poseidon` continues to force-enable `challenge-254-bit`. +- Deletes the current `crates/jolt-transcript/src/{blake2b,keccak,poseidon,transcript}.rs` legacy implementations. + +**`jolt-core`:** + +- Deletes `src/transcripts/` (blake2b.rs, keccak.rs, poseidon.rs, transcript.rs, mod.rs). +- Adds `jolt-transcript.workspace = true` to `Cargo.toml`; forwards the three `transcript-*` features through. +- Updates ~148 transcript callsites. Generic bounds change from `` to `` or `` as appropriate, AND each `append_*(label, &val)` / `challenge_*` call drops its label argument, becoming positional: `prover_message(&val)` / `public_message(&val)` / `verifier_message::()`. Hot sites: `subprotocols/sumcheck.rs` (44 refs), `zkvm/prover.rs` (33), `zkvm/verifier.rs` (21), `zkvm/transpilable_verifier.rs` (30), `poly/commitment/hyperkzg.rs` (28), `subprotocols/univariate_skip.rs` (15), `subprotocols/blindfold/protocol.rs` (15), `poly/commitment/dory/commitment_scheme.rs` (12). +- `JoltToDoryTranscript` bridge (`poly/commitment/dory/wrappers.rs:336`) updated to wrap the new traits; dory interface unchanged. +- The shared preprocessing-binding code (currently a generic function at `zkvm/mod.rs:204-234` that binds preprocessing digest / memory layout / I/O) is split into two symmetric calls — one in `JoltProver::new()`, one in `JoltVerifier::new()`. Spongefish's `public_message` semantics mean both sides independently absorb the same values and their sponge states stay synchronized; duplicating the ~30 lines of binding code is cleaner than abstracting into a super-trait and keeps the symmetry eyeball-verifiable for Fiat-Shamir auditability. + +**`JoltProof` structure** collapses to essentially: + +```rust +pub struct JoltProof { + narg: Vec, // spongefish NARG byte string + // plus any public inputs the verifier doesn't already know +} +``` + +Today's cfg-gated fields (`opening_claims: Claims` in standard, `blindfold_proof: BlindFoldProof` in ZK) disappear from the struct — they become different prover-message sequences inside the NARG. The prover-side cfg gates remain (different code paths call different `prover_message` sequences), but the wire format unifies. Proof mode (standard vs ZK) is encoded in the spongefish domain separator at construction, not stored as a runtime field on the proof. + +**`transpiler/`, `zklean-extractor/`:** + +These depend on `jolt-core` features; they keep compiling because the Cargo feature names stay the same. Their emitted byte layouts change as a direct consequence of this port. Coordinating their downstream verifier updates is out of scope (Non-Goals 2 and 3). + +### Alternatives Considered + +1. **Keep Poseidon on the old `Transcript` trait (dual trait systems).** Rejected: spongefish's pluggable `DuplexSpongeInterface` means Poseidon can be a sponge like any other. No reason to maintain two parallel transcript worlds. + +2. **`legacy-transcript-compat` feature flag that keeps old hand-rolled backends alive.** Rejected: contradicts "spongefish everywhere," adds permanent maintenance burden, defers downstream coordination indefinitely. + +3. **Keep Keccak as a non-spongefish holdout for the EVM verifier.** Rejected: creates a forever-special-case in `transpilable_verifier.rs`. Better to coordinate the Solidity byte-layout update as a downstream follow-up (Non-Goal 2). + +4. **Wrapper structs around `spongefish::ProverState` / `VerifierState`.** Rejected: the orphan rule lets us implement our local traits directly on spongefish's types. Wrappers would add ceremony without carrying extra state. + +5. **Super-trait `TranscriptCommon` for shared prover/verifier code.** Rejected: spongefish's `public_message` semantics already handle symmetric absorption — both sides independently call `public_message(&value)` with identical inputs, and sponge states move in lockstep. The small amount of shared binding code (~30 lines, called once per proof) is cleaner duplicated at both callsites than abstracted; Fiat-Shamir symmetry is more eyeball-auditable when the two sides' code is visible side by side. + +6. **Port in place in `jolt-core/src/transcripts/` without activating `crates/jolt-transcript`.** Rejected: the extracted crate exists per jolt#1365 and is the canonical future home once dory consumes it. Migrating code twice (in-place now, to the crate later) is strictly worse than doing it once. + +7. **Keep per-call string labels by absorbing them as extra `public_message` calls on both sides.** Rejected: WhiR (`WizardOfMenlo/whir` — PCS used in production) and sigma-rs (`sigma-rs/sigma-proofs` — Sigma-protocols library) both use spongefish with purely positional calls; domain separation lives in the one-time protocol/session/instance string passed to `DomainSeparator`. In Jolt's deterministic protocol flow, positional order already provides the domain separation that per-call labels would redundantly provide, and absorbing labels adds ~one extra sponge permutation per transcript call on both prover and verifier for no soundness benefit. + +## Documentation + +No `book/` changes required. Existing conceptual descriptions of Fiat-Shamir (`book/src/how/blindfold.md:30-31,63,149`, `book/src/how/architecture/opening-proof.md:32`) are implementation-agnostic and remain accurate post-port. `CLAUDE.md`'s `## Architecture → transcripts/` subsection needs updating to reflect the new `crates/jolt-transcript` crate structure and spongefish-based implementation; that update lands with the implementation PR. + +## References + +- [arkworks-rs/spongefish](https://github.com/arkworks-rs/spongefish) — Fiat-Shamir duplex-sponge library. +- Closed dory PR #17 on `a16z/dory` — original spongefish integration attempt, redirected by the maintainer to `jolt-transcript`. +- `.claude/2026-04-17-jolt-transcript-spongefish-handoff.md` — handoff notes that triggered this spec. +- Related jolt crate-extraction work: + - jolt#1362 — workspace scaffolding (merged). + - jolt#1363 — `jolt-field` crate (merged). + - jolt#1365 — `jolt-transcript` crate extraction (merged; this spec activates it). + - jolt#1368 — `jolt-crypto` crate (merged). + - jolt#1369 — `jolt-trace` crate (merged). +- jolt#1322 — original Poseidon transcript + gnark transpiler pipeline. From 848ebb16a7a72497b7c1c7ad44b8a1d4b36f00af Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Wed, 22 Apr 2026 11:13:52 +0530 Subject: [PATCH 02/21] spec: narrow scope to crates/jolt-transcript internal port --- specs/jolt-transcript-spongefish.md | 148 +++++++++++++--------------- 1 file changed, 71 insertions(+), 77 deletions(-) diff --git a/specs/jolt-transcript-spongefish.md b/specs/jolt-transcript-spongefish.md index 0762a5b4b0..c9959a2279 100644 --- a/specs/jolt-transcript-spongefish.md +++ b/specs/jolt-transcript-spongefish.md @@ -5,152 +5,146 @@ | Author(s) | @shreyas-londhe | | Created | 2026-04-17 | | Status | proposed | -| PR | | +| PR | 1455 | ## Summary -Jolt's current `Transcript` trait is a bespoke hash-based Fiat-Shamir construction with three backends (Blake2b, Keccak, Poseidon), each implementing custom label+length packing and challenge-extraction logic. This spec replaces that in-house construction with [spongefish](https://github.com/arkworks-rs/spongefish) — an audited duplex-sponge Fiat-Shamir library — and adopts its NARG-proof model so subprotocols (sumcheck, uni-skip, PCS) contribute to a single opaque byte string instead of directly appending typed messages onto a shared transcript struct. The result is a cleaner PCS boundary (dory contributes through a bridge into the same sponge), auditable domain-separation semantics, and alignment with the wider Arkworks spongefish ecosystem. This PR covers the Jolt side; a coordinated follow-up PR on `a16z/dory` makes dory consume `jolt-transcript` directly. +Port the internal implementation of the `crates/jolt-transcript` crate to use [spongefish](https://github.com/arkworks-rs/spongefish)'s duplex-sponge Fiat-Shamir construction. The crate currently holds a hand-rolled digest-based implementation; this PR replaces it with spongefish-backed `ProverTranscript` / `VerifierTranscript` traits and a new `PoseidonSponge` adapter over `light-poseidon`. The crate stays workspace-local and unused by `jolt-core`, dory, or the transpiler in this PR — integration into the jolt-core transcript call-path, the dory PCS bridge, and the gnark transpiler are explicitly deferred to follow-up PRs per maintainer guidance on #1455. This PR is step 1 of a staged rollout; scope is intentionally narrow so the trait surface and spongefish wiring can be reviewed in isolation. One downstream consumer is touched in-scope: `jolt-eval` gains a dependency on `jolt-transcript` so a new `transcript_prover_verifier_consistency` invariant can mechanically verify sponge symmetry across all three sponges. ## Intent ### Goal -Replace Jolt's hand-rolled Fiat-Shamir transcript with spongefish's duplex-sponge construction across all three transcript variants, defining `ProverTranscript` and `VerifierTranscript` traits implemented directly on `spongefish::ProverState` / `spongefish::VerifierState` where `Sponge` is Cargo-feature-selected (`spongefish::Blake2b512`, `spongefish::Keccak`, or a new `PoseidonSponge: DuplexSpongeInterface` impl over `light-poseidon`). The new traits adopt spongefish's positional message style — per-call string labels (e.g., `b"opening_claim"`, `b"ram_K"`) are dropped. Domain separation lives only in the one-time `DomainSeparator` string used at transcript construction. +Replace `crates/jolt-transcript`'s hand-rolled digest-based `Transcript` trait with spongefish-native `ProverTranscript` and `VerifierTranscript` traits, implemented directly on `spongefish::ProverState` / `spongefish::VerifierState`. Methods are positional, matching spongefish-native shape. Three sponges are feature-selectable within the crate: `spongefish::Blake2b512`, `spongefish::Keccak`, and a new `PoseidonSponge` adapter over `light-poseidon`. `jolt-eval` gains a new invariant that exercises the new trait surface across all three sponges. Crates outside `crates/jolt-transcript/` and `jolt-eval/` are not modified in this PR. -Key abstractions introduced: +Key abstractions: -- **`ProverTranscript`** (trait, in `crates/jolt-transcript`) — positional methods: `public_message(&T)`, `prover_message(&T)`, `verifier_message() -> T`, plus the 128-bit-truncating challenge methods that preserve the current `challenge_*_optimized` performance profile, and `narg_string()`. -- **`VerifierTranscript`** (trait, in `crates/jolt-transcript`) — positional methods: `public_message(&T)`, `receive_prover_message() -> T`, `verifier_message() -> T`, the same 128-bit-truncating challenge methods, and `check_eof()`. -- **`PoseidonSponge`** (type, in `crates/jolt-transcript`) — new `DuplexSpongeInterface` impl wrapping `light-poseidon`, so Poseidon plugs into spongefish like any other sponge. -- Trait impls live directly on `spongefish::ProverState` / `VerifierState` (orphan rule allows it); no wrapper structs. +- **`ProverTranscript`** (new trait, replacing the current symmetric `Transcript` trait) — positional methods: `public_message(&T)`, `prover_message(&T)`, `verifier_message() -> T`, `narg_string()`. +- **`VerifierTranscript`** (new trait) — positional methods: `public_message(&T)`, `receive_prover_message() -> T`, `verifier_message() -> T`, `check_eof()`. +- Per-sponge challenge-width contract: Blake2b and Keccak expose both a 128-bit-truncating optimized decoder and a full-field decoder. Poseidon exposes only a full-field decoder (254-bit — the sponge's native field-element width). The 128-bit decoder is simply not defined on `PoseidonSponge`, so calling a `_optimized` method against it is a compile error. +- **`PoseidonSponge`** (new type) — adapter making `light-poseidon` usable as a spongefish sponge via `spongefish::DuplexSpongeInterface`. Circom-compatible BN254 parameters, matching today's `PoseidonTranscript` configuration. +- Trait impls live directly on `spongefish::ProverState` / `spongefish::VerifierState` (orphan rule allows it); no wrapper structs. ### Invariants -1. **Prover/verifier sponge symmetry.** For every Jolt protocol path (standard and ZK), the prover and verifier absorb the same sequence of values into their sponges in the same order (positional — per-call labels no longer exist). This is the inductive property — challenge streams match on both sides, and protocol correctness and soundness carry over unchanged from the pre-port implementation. +1. **Prover/verifier sponge symmetry (within the new crate).** Two `spongefish::ProverState` / `spongefish::VerifierState` instances constructed with identical `DomainSeparator` strings and absorbing the same sequence of values via `public_message` produce identical challenge streams via `verifier_message`. Holds for each of the three sponges. This invariant is encoded mechanically in `jolt-eval` (see Acceptance Criteria and Testing Strategy). -2. **Functional equivalence for the Rust `JoltVerifier`.** Every Jolt proof produced by the off-chain prover continues to verify against the in-tree Rust `JoltVerifier`, across all three sponges (Blake2b / Keccak / Poseidon), in both `--features host` and `--features host,zk` modes. +2. **No external behavior change, apart from a controlled jolt-eval addition.** `jolt-core/src/transcripts/`, the `JoltToDoryTranscript` bridge (`jolt-core/src/poly/commitment/dory/wrappers.rs:336`), the `transpiler/` and `zklean-extractor/` crates, and `JoltProof` are not modified. All their existing tests continue to pass unchanged. No downstream artifact (Solidity verifier, gnark verifier, Lean extraction) needs updating as a consequence of this PR. The only crate modified outside `crates/jolt-transcript/` is `jolt-eval`, which gains a new invariant module and a dependency on `jolt-transcript`. ### Non-Goals -1. **Consolidating dory onto `jolt-transcript` in this PR.** The end state is dory depending on `jolt-transcript` and sharing the same `ProverTranscript` / `VerifierTranscript` traits — but that change lives in a coordinated follow-up PR on `a16z/dory`. In the interim, the `JoltToDoryTranscript` bridge adapter (`jolt-core/src/poly/commitment/dory/wrappers.rs:336`) is updated to wrap the new spongefish-backed traits while dory keeps its current `DoryTranscript` trait. The bridge is removed once the follow-up lands. +1. **Integrating the new crate into `jolt-core`, dory, or the transpiler.** jolt-core's ~148 transcript callsites keep using `jolt-core/src/transcripts/`; the `JoltToDoryTranscript` bridge is untouched; `transpiler/` and `zklean-extractor/` are untouched. Three separate follow-up PRs will land these: (a) a16z/dory PR replacing dory's own `DoryTranscript` with a `crates/jolt-transcript` dependency; (b) a jolt-core migration PR replacing `jolt-core/src/transcripts/` with `jolt-transcript` imports and updating the 148 callsites; (c) a transpiler update PR regenerating the gnark verifier against spongefish's Poseidon absorb layout. -2. **Updating `transpilable_verifier.rs`'s Solidity emission to match spongefish's Keccak absorb layout.** The Rust side migrates; the Solidity verifier update is a coordinated downstream follow-up. +2. **Cryptographic redesign of what gets absorbed, at any layer.** Per-sponge challenge widths preserve today's semantics: Blake2b/Keccak's new traits expose both a 128-bit-optimized decoder and a full-field decoder; Poseidon's new traits expose only a full-field decoder. jolt-core's `challenge-254-bit` feature (gating `JoltField::Challenge` at `jolt-core/src/field/ark.rs:2`) is not modified and continues to behave exactly as today — since this PR does not connect jolt-core to the new crate, there is no cross-crate coupling on that feature. -3. **Updating `transpiler/` (gnark) and `zklean-extractor/` (Lean) to match spongefish's Poseidon absorb layout.** The Rust side migrates; the gnark and Lean verifier updates are coordinated downstream follow-ups. +3. **Guaranteeing stable spongefish NARG byte format across future spongefish releases.** `crates/jolt-transcript` pins the latest published `spongefish` at implementation time; future releases are picked up as normal dep updates. -4. **Cryptographic redesign of what gets absorbed.** This is a mechanical port of protocol semantics: the same values bind to the transcript in the same order. The only structural API change is that per-call string labels are dropped — in a deterministic protocol flow, positional order already provides domain separation, and production spongefish consumers (WhiR, sigma-rs) use purely positional calls. Labels survive only in the one-time `DomainSeparator` string used at transcript construction. No reconsideration of which values bind to the transcript or in what order. - -5. **Guaranteeing stable spongefish NARG byte format across future spongefish releases.** Off-chain proofs are regenerated per release. - -6. **Performance improvement beyond no-regression.** Bar: `muldiv` e2e no slower than current Blake2b. +4. **Performance improvement beyond no-regression.** Bar: `transcript_ops` micro-benchmark within 5% of the `main_run` CI baseline per `BenchmarkId`. ## Evaluation ### Acceptance Criteria -- [ ] `crates/jolt-transcript` exposes `ProverTranscript` / `VerifierTranscript` traits implemented directly on `spongefish::ProverState` / `spongefish::VerifierState` (no wrapper structs). -- [ ] Three sponge backends selectable via Cargo feature: `transcript-blake2b` → `spongefish::Blake2b512`; `transcript-keccak` → `spongefish::Keccak`; `transcript-poseidon` → new `PoseidonSponge: DuplexSpongeInterface` over `light-poseidon`. -- [ ] `jolt-core/src/transcripts/` is deleted; `jolt-core` depends on `jolt-transcript`. -- [ ] `JoltToDoryTranscript` bridge (`jolt-core/src/poly/commitment/dory/wrappers.rs:336`) is updated to wrap the new traits and continues to satisfy dory's `DoryTranscript`. -- [ ] `muldiv` e2e passes under `--features host` for each of the three sponges. -- [ ] `muldiv` e2e passes under `--features host,zk` for each of the three sponges. -- [ ] `crates/jolt-transcript/tests/` and `crates/jolt-transcript/fuzz/` are deleted alongside the custom transcript implementation they were written to test. These tests exercised generic Fiat-Shamir properties (determinism, domain separation, challenge uniqueness, order sensitivity, clone independence) that are now owned by spongefish's own test suite; duplicating them in Jolt would be maintenance burden with no Jolt-specific correctness benefit. - -- [ ] `crates/jolt-transcript/benches/transcript_ops.rs` is adapted to the new positional API and retained. Its role shifts from "micro-benchmarking our transcript implementation" to "regression gauge for spongefish operations under Jolt's usage pattern." Serves the Performance section's regression check. - -- [ ] All `jolt-core` unit and integration tests remain green after mechanical API renames only: `append_bytes(&b)` → `public_message(&b)` / `prover_message(&b)`, `challenge_*` → `verifier_message::()`, `Transcript::new(label)` → `DomainSeparator::new(...).instance(...).std_prover()`. No `jolt-core` test is deleted or `#[ignore]`d. - -- [ ] `jolt-transcript` depends on the latest published `spongefish` release on crates.io at implementation time; the resolved version is captured in `Cargo.lock`. +- [ ] `crates/jolt-transcript` replaces its current hand-rolled impl with spongefish-backed `ProverTranscript` / `VerifierTranscript` traits, implemented directly on `spongefish::ProverState` / `spongefish::VerifierState` (no wrapper structs). +- [ ] Three new Cargo features added to `crates/jolt-transcript/Cargo.toml` — `transcript-blake2b`, `transcript-keccak`, `transcript-poseidon` — selecting `spongefish::Blake2b512`, `spongefish::Keccak`, and the new `PoseidonSponge` adapter respectively. These features are new to the crate; the identically-named features in `jolt-core/Cargo.toml:45-48`, `jolt-sdk/Cargo.toml:44-46`, and `transpiler/Cargo.toml:26-28` are not modified and continue to drive `jolt-core/src/transcripts/` unchanged. +- [ ] Per-sponge challenge-width contract: Blake2b/Keccak expose both 128-bit-optimized and full-field decoders; Poseidon exposes full-field only (the 128-bit API is not defined on `PoseidonSponge`, so using it is a compile error). +- [ ] `jolt-core/src/transcripts/` is **not** modified; `jolt-core` does **not** depend on `jolt-transcript`; `JoltToDoryTranscript` is **not** modified; `transpiler/` and `zklean-extractor/` are **not** modified; `JoltProof` is **not** modified. +- [ ] `crates/jolt-transcript/tests/` and `crates/jolt-transcript/fuzz/` are deleted. Rationale in Testing Strategy. +- [ ] `crates/jolt-transcript/benches/transcript_ops.rs` is adapted to the new positional API and retained. +- [ ] A new jolt-eval invariant `transcript_prover_verifier_consistency` is added at `jolt-eval/src/invariant/transcript_symmetry.rs`, with one instantiation per sponge. The invariant covers the full trait surface — `public_message`, `prover_message`/`receive_prover_message`, and `verifier_message` — via an `Input` enum with variants `PublicBytes`, `PublicScalar`, `ProverBytes`, `ProverScalar`, `Challenge`. The `check` method runs the sequence on a `ProverTranscript` to build the NARG, constructs a `VerifierTranscript` against it, replays the sequence, and asserts both `receive_prover_message` round-trip equality and `verifier_message` challenge equality. The `#[invariant(Test, Fuzz, RedTeam)]` macro generates `#[test] fn seed_corpus()` and `#[test] fn random_inputs()` per instantiation; the generated fuzz targets compile; the seed corpus covers the empty sequence, single-message (each variant), 10-message mixed, and 1000-message mixed cases. +- [ ] `jolt-eval/Cargo.toml` gains a dependency on `jolt-transcript`. `jolt-eval/src/invariant/mod.rs` registers the new module. `jolt-eval/sync_targets.sh` is run so the fuzz target Cargo.toml entries are synchronized (per `jolt-eval/README.md` lines 229-239). +- [ ] `cargo nextest run -p jolt-eval` passes, exercising the new invariant's generated tests for all three sponge instantiations. +- [ ] `cargo nextest run -p jolt-core muldiv --features host` and `--features host,zk` continue to pass unchanged (workspace-build sanity check; jolt-core does not exercise the new code in this PR, so a per-sponge matrix is not meaningful here). +- [ ] `jolt-transcript` depends on the latest published `spongefish` release on crates.io at implementation time; version captured in `Cargo.lock`. ### Testing Strategy -**Existing tests must keep passing:** +**Existing tests that must keep passing, unchanged:** -- `muldiv` e2e under `--features host` and `--features host,zk`, across all three Cargo feature sponges (`transcript-blake2b`, `transcript-keccak`, `transcript-poseidon`). -- All `jolt-core` unit and integration tests across the same matrix (with mechanical API renames as described in Acceptance Criteria). +- All `jolt-core` unit, integration, and e2e tests — no modifications, since jolt-core's transcript path is untouched. `muldiv` under `--features host` and `--features host,zk` included. +- All `transpiler/` and `zklean-extractor/` tests — no modifications, same reason. +- All dory-related tests via the existing `JoltToDoryTranscript` bridge — no modifications. **Tests removed alongside the code they test:** -- `crates/jolt-transcript/tests/` (all four files and the `common/` shared macro) — tested the custom transcript implementation's generic Fiat-Shamir properties; spongefish owns these tests for its own sponges upstream. -- `crates/jolt-transcript/fuzz/` — fuzzed the custom transcript's no-panic guarantee; spongefish owns this upstream. +- `crates/jolt-transcript/tests/` (all four files and the shared `common/` macro) — tested generic Fiat-Shamir properties (determinism, domain separation, order sensitivity, clone behavior, prover/verifier consistency). These hold for any correct generic deterministic sponge regardless of parameter correctness — wrong width/rate/chunking in our adapter would produce "wrong but deterministic" output, which property tests still pass. They measure spongefish's properties (which spongefish tests upstream), not our adapter's parametrization. +- `crates/jolt-transcript/fuzz/` — fuzzed the no-panic guarantee, same reasoning. -**New tests:** None required. Correctness of the new code (the `PoseidonSponge: DuplexSpongeInterface` impl and the 128-bit-truncating decoder) is exercised by the `muldiv` e2e matrix — if either is wrong, e2e fails for the affected sponge or challenge path. +**New tests added in this PR:** -**Open question for maintainers:** - -> How should CI treat `transpiler/go/e2e_test.go` and any Solidity integration tests that pin the current Poseidon / Keccak absorb byte layout? These will fail as a direct consequence of this port, since spongefish's domain separator and absorb semantics differ from the current hand-rolled layout. Options considered: (a) mark as `#[ignore]` until the downstream gnark / Solidity follow-ups land; (b) leave them failing, gating merge on coordinated downstream PR readiness; (c) maintainer's preference. +- `jolt-eval`'s `transcript_prover_verifier_consistency` invariant, instantiated per sponge. The `#[invariant(Test, Fuzz, RedTeam)]` macro auto-generates two `#[test]` entries per instantiation (`seed_corpus`, `random_inputs`), plus a `libfuzzer_sys` fuzz target. Test entries run under `cargo nextest run -p jolt-eval`. The fuzz target builds in CI but is invoked ad-hoc via `cargo fuzz run -p jolt-eval `. +- This invariant is the only in-tree correctness gauge for the new crate's spongefish wiring. Its differential-comparison shape (two instances absorbing the same messages must produce the same challenges) directly mechanizes Invariant #1. If an adapter-layer bug corrupts state but deterministically (e.g., wrong rate on `PoseidonSponge`), the invariant still passes — ultimate validation of absorb correctness happens in the follow-up jolt-core migration PR's `muldiv` e2e, which exercises the full protocol flow. ### Performance -No observable regression beyond benchmark noise on the `transcript_ops` micro-benchmark (`crates/jolt-transcript/benches/transcript_ops.rs`) and on the `muldiv` e2e wall-clock under `--features host` with `transcript-blake2b` (default). +**Regression budget: ≤5% wall-clock regression per `BenchmarkId` on `transcript_ops`**, measured against the `main_run` baseline artifact `criterion-baseline` stored by `.github/workflows/bench-crates.yml` on main pushes. The PR CI job at the same workflow downloads the baseline, runs the PR benchmarks with `--save-baseline pr_run`, and invokes `critcmp main_run pr_run --threshold 5` in the "Compare benchmarks" step to enforce the budget per individual `BenchmarkId` (e.g., `transcript_ops/absorb_scalar/Blake2b`), not against an aggregate. -Rationale: transcript operations are fundamentally hash calls (absorb, squeeze). The underlying hash implementation per sponge is unchanged — Blake2b is still Blake2b, Keccak is still Keccak, Poseidon is still Poseidon. Spongefish's construction adds a thin domain-separation framework over the sponge; it does not change the dominant cost. +Rationale: transcript operations are fundamentally hash calls (absorb, squeeze). The underlying hash primitive per sponge is unchanged — Blake2b, Keccak, and Poseidon all compute the same way they did pre-port. Spongefish's construction adds a thin domain-separation framework; it does not change the dominant cost. The 5% budget accommodates small differences in per-call sponge ratcheting and domain-separator overhead without requiring micro-optimization. ## Design ### Architecture -**`crates/jolt-transcript`** (currently parked; this spec activates it): - -- Adds `spongefish` and `light-poseidon` dependencies. -- Defines `ProverTranscript` and `VerifierTranscript` traits with positional method signatures matching spongefish-native shape: `public_message(&T)` on both, `prover_message(&T)` on prover / `receive_prover_message() -> T` on verifier, `verifier_message() -> T` for challenges on both. Challenge decoders implement `spongefish::Decoding<[H::U]>`; a custom 128-bit-truncating decoder preserves the performance profile of the current `challenge_*_optimized` family (63 hot-path callsites). `narg_string()` on prover, `check_eof()` on verifier. -- Implements these traits directly on `spongefish::ProverState` / `spongefish::VerifierState` — no wrapper structs. -- Provides a new `PoseidonSponge: DuplexSpongeInterface` impl wrapping `light-poseidon` so Poseidon plugs into spongefish. -- Retains the existing Cargo feature names `transcript-blake2b` / `transcript-keccak` / `transcript-poseidon`, each selecting the corresponding sponge type. `transcript-poseidon` continues to force-enable `challenge-254-bit`. -- Deletes the current `crates/jolt-transcript/src/{blake2b,keccak,poseidon,transcript}.rs` legacy implementations. - -**`jolt-core`:** - -- Deletes `src/transcripts/` (blake2b.rs, keccak.rs, poseidon.rs, transcript.rs, mod.rs). -- Adds `jolt-transcript.workspace = true` to `Cargo.toml`; forwards the three `transcript-*` features through. -- Updates ~148 transcript callsites. Generic bounds change from `` to `` or `` as appropriate, AND each `append_*(label, &val)` / `challenge_*` call drops its label argument, becoming positional: `prover_message(&val)` / `public_message(&val)` / `verifier_message::()`. Hot sites: `subprotocols/sumcheck.rs` (44 refs), `zkvm/prover.rs` (33), `zkvm/verifier.rs` (21), `zkvm/transpilable_verifier.rs` (30), `poly/commitment/hyperkzg.rs` (28), `subprotocols/univariate_skip.rs` (15), `subprotocols/blindfold/protocol.rs` (15), `poly/commitment/dory/commitment_scheme.rs` (12). -- `JoltToDoryTranscript` bridge (`poly/commitment/dory/wrappers.rs:336`) updated to wrap the new traits; dory interface unchanged. -- The shared preprocessing-binding code (currently a generic function at `zkvm/mod.rs:204-234` that binds preprocessing digest / memory layout / I/O) is split into two symmetric calls — one in `JoltProver::new()`, one in `JoltVerifier::new()`. Spongefish's `public_message` semantics mean both sides independently absorb the same values and their sponge states stay synchronized; duplicating the ~30 lines of binding code is cleaner than abstracting into a super-trait and keeps the symmetry eyeball-verifiable for Fiat-Shamir auditability. +**`crates/jolt-transcript`** (currently contains a hand-rolled Fiat-Shamir implementation — `src/{lib,transcript,blake2b,keccak,poseidon,blanket,digest}.rs` plus populated `tests/`, `fuzz/`, `benches/` — that this PR replaces in-place): -**`JoltProof` structure** collapses to essentially: +- Adds `spongefish` and `light-poseidon` workspace dependencies. +- Defines `ProverTranscript` and `VerifierTranscript` traits with positional method signatures matching spongefish-native shape. Domain separation lives in the one-time `DomainSeparator` string used at transcript construction. Production spongefish consumers (WhiR, sigma-rs) use this same positional style. Note: the current `crates/jolt-transcript/src/transcript.rs` is already positional per-call; the positional-API choice is structural preparation for the follow-up jolt-core migration, where jolt-core's labeled per-call style (`append_scalar(b"opening_claim", &x)` at ~148 callsites in `jolt-core/src/transcripts/`) will transform into positional `prover_message(&x)` calls. Within this PR's scope, no callsite transformation is observable. +- Challenge decoders implement `spongefish::Decoding<[H::U]>`. Per-sponge contract: + - **Blake2b** and **Keccak**: both a 128-bit-truncating optimized decoder and a full-field decoder. + - **Poseidon**: full-field (254-bit) decoder only. The 128-bit optimized API is not defined on `spongefish::ProverState` — attempting to use it is a compile error. +- Implements these traits directly on `spongefish::ProverState` / `spongefish::VerifierState` via the orphan rule — no wrapper structs around the library types. +- Provides a new `PoseidonSponge` adapter making `light-poseidon` usable as a spongefish sponge (via `DuplexSpongeInterface`, or equivalently via a `Permutation` impl consumed through spongefish's built-in `DuplexSponge` — implementer's choice). Circom-compatible BN254 parameters, matching today's `PoseidonTranscript`. Both paths produce absorb layouts that the follow-up gnark transpiler PR will validate against the emitted verifier; within this PR's scope there is no observable downstream effect from the choice, so the implementer should pick the path that most cleanly matches `light-poseidon`'s native API surface. +- Introduces three new Cargo features: `transcript-blake2b`, `transcript-keccak`, `transcript-poseidon`. These are new to the crate; jolt-core's and downstream crates' identically-named features remain in place and continue to drive `jolt-core/src/transcripts/` unchanged. +- Deletes the current `crates/jolt-transcript/src/{transcript,blake2b,keccak,poseidon,blanket,digest}.rs` legacy implementations. `lib.rs` is retained and re-exports the new trait surface. +- Deletes `crates/jolt-transcript/tests/` and `crates/jolt-transcript/fuzz/` per Testing Strategy. -```rust -pub struct JoltProof { - narg: Vec, // spongefish NARG byte string - // plus any public inputs the verifier doesn't already know -} -``` +**`jolt-eval`:** -Today's cfg-gated fields (`opening_claims: Claims` in standard, `blindfold_proof: BlindFoldProof` in ZK) disappear from the struct — they become different prover-message sequences inside the NARG. The prover-side cfg gates remain (different code paths call different `prover_message` sequences), but the wire format unifies. Proof mode (standard vs ZK) is encoded in the spongefish domain separator at construction, not stored as a runtime field on the proof. +- Adds `jolt-transcript` as a dependency in `jolt-eval/Cargo.toml`. +- Adds `jolt-eval/src/invariant/transcript_symmetry.rs` defining `TranscriptProverVerifierConsistency` with `#[invariant(Test, Fuzz, RedTeam)]` targets, one instantiation per sponge. The `Input` type is an enum covering the full trait surface: `PublicBytes(Vec)`, `PublicScalar(F)`, `ProverBytes(Vec)`, `ProverScalar(F)`, and `Challenge`. `check` runs the sequence on a `ProverTranscript` to produce a NARG byte string, constructs a `VerifierTranscript` against that NARG, replays the same sequence on the verifier, and asserts: (a) at each `ProverBytes`/`ProverScalar` op, the verifier's `receive_prover_message` returns the original value; (b) at each `Challenge` op, both sides' `verifier_message` outputs are identical. +- Registers the new module in `jolt-eval/src/invariant/mod.rs`. +- Runs `jolt-eval/sync_targets.sh` to synchronize the fuzz target Cargo.toml entries (per `jolt-eval/README.md:229-239`). -**`transpiler/`, `zklean-extractor/`:** +**Out of scope this PR (preserved exactly as today):** -These depend on `jolt-core` features; they keep compiling because the Cargo feature names stay the same. Their emitted byte layouts change as a direct consequence of this port. Coordinating their downstream verifier updates is out of scope (Non-Goals 2 and 3). +- `jolt-core/src/transcripts/` — the hand-rolled `Transcript` trait and its three backends used by ~148 jolt-core callsites, the `JoltToDoryTranscript` bridge, the transpilable verifier, BlindFold, sumcheck, univariate skip, HyperKZG, etc. +- `JoltProof` — wire format unchanged. +- dory — keeps its own `DoryTranscript` trait; the `JoltToDoryTranscript` bridge wrapping jolt-core's transcript stays as-is. +- `transpiler/` and `zklean-extractor/` — their emitted verifier byte layouts are unaffected since they depend on jolt-core's transcript, which is untouched. ### Alternatives Considered -1. **Keep Poseidon on the old `Transcript` trait (dual trait systems).** Rejected: spongefish's pluggable `DuplexSpongeInterface` means Poseidon can be a sponge like any other. No reason to maintain two parallel transcript worlds. +1. **Port `jolt-core/src/transcripts/` and `crates/jolt-transcript` together in one PR.** Rejected: maintainer's comment on #1455 (@moodlezoup, 2026-04-21) asked for a staged rollout — crate-only port first, jolt-core integration later — to keep each PR narrow-scope and reviewable. The earlier draft of this spec had the full migration; this revision narrows to just the crate. -2. **`legacy-transcript-compat` feature flag that keeps old hand-rolled backends alive.** Rejected: contradicts "spongefish everywhere," adds permanent maintenance burden, defers downstream coordination indefinitely. +2. **Keep Poseidon on the old `Transcript` trait (dual trait systems inside the same crate).** Rejected: spongefish's pluggable `DuplexSpongeInterface` means Poseidon can be a sponge like any other, and maintaining parallel transcript worlds in one crate would be a permanent maintenance burden. -3. **Keep Keccak as a non-spongefish holdout for the EVM verifier.** Rejected: creates a forever-special-case in `transpilable_verifier.rs`. Better to coordinate the Solidity byte-layout update as a downstream follow-up (Non-Goal 2). +3. **Keep per-call string labels by absorbing them as extra `public_message` calls on both sides.** Rejected: WhiR and sigma-rs (production spongefish consumers) both use purely positional calls; in a deterministic protocol flow, positional order already provides the domain separation that per-call labels would redundantly provide, and absorbing labels adds ~one extra sponge permutation per call for no soundness benefit. -4. **Wrapper structs around `spongefish::ProverState` / `VerifierState`.** Rejected: the orphan rule lets us implement our local traits directly on spongefish's types. Wrappers would add ceremony without carrying extra state. +4. **Wrapper structs around `spongefish::ProverState` / `VerifierState`.** Rejected: the orphan rule lets us impl our local traits directly on spongefish's types. Wrappers would add ceremony without carrying extra state. -5. **Super-trait `TranscriptCommon` for shared prover/verifier code.** Rejected: spongefish's `public_message` semantics already handle symmetric absorption — both sides independently call `public_message(&value)` with identical inputs, and sponge states move in lockstep. The small amount of shared binding code (~30 lines, called once per proof) is cleaner duplicated at both callsites than abstracted; Fiat-Shamir symmetry is more eyeball-auditable when the two sides' code is visible side by side. +5. **Super-trait `TranscriptCommon` for shared prover/verifier code.** Rejected: spongefish's `public_message` semantics already handle symmetric absorption — both sides independently call `public_message(&value)` with identical inputs, and sponge states move in lockstep. Shared binding code is cleaner duplicated at both callsites than abstracted; Fiat-Shamir symmetry is more eyeball-auditable when both sides' code is visible side by side. -6. **Port in place in `jolt-core/src/transcripts/` without activating `crates/jolt-transcript`.** Rejected: the extracted crate exists per jolt#1365 and is the canonical future home once dory consumes it. Migrating code twice (in-place now, to the crate later) is strictly worse than doing it once. +6. **Keep adapted versions of the existing property tests (determinism, domain separation, order-sensitivity, etc.) against `PoseidonSponge` to test the adapter.** Rejected: these tests verify properties that hold for any correct generic deterministic sponge, regardless of parameter correctness. Wrong width/rate/chunking in our `DuplexSpongeInterface` impl would produce "wrong but deterministic" output — property tests would still pass. They don't catch realistic adapter failure modes, so running them on our new code provides false confidence. The meaningful signal comes from the `jolt-eval` invariant (which tests the differential prover-verifier property the protocol relies on) plus the follow-up integration PR's `muldiv` e2e. -7. **Keep per-call string labels by absorbing them as extra `public_message` calls on both sides.** Rejected: WhiR (`WizardOfMenlo/whir` — PCS used in production) and sigma-rs (`sigma-rs/sigma-proofs` — Sigma-protocols library) both use spongefish with purely positional calls; domain separation lives in the one-time protocol/session/instance string passed to `DomainSeparator`. In Jolt's deterministic protocol flow, positional order already provides the domain separation that per-call labels would redundantly provide, and absorbing labels adds ~one extra sponge permutation per transcript call on both prover and verifier for no soundness benefit. +7. **No in-tree correctness gauge at all; defer all verification to the follow-up jolt-core migration's `muldiv` e2e.** Rejected (after reviewer feedback): `jolt-eval` is specifically designed to provide mechanically checkable invariants for exactly this class of property, and the cost of registering one invariant (one file + one Cargo.toml dep) is small compared to the value of closing the staging gap. ## Documentation -No `book/` changes required. Existing conceptual descriptions of Fiat-Shamir (`book/src/how/blindfold.md:30-31,63,149`, `book/src/how/architecture/opening-proof.md:32`) are implementation-agnostic and remain accurate post-port. `CLAUDE.md`'s `## Architecture → transcripts/` subsection needs updating to reflect the new `crates/jolt-transcript` crate structure and spongefish-based implementation; that update lands with the implementation PR. +No `book/` changes required. `CLAUDE.md`'s `## Architecture → transcripts/` subsection is not updated in this PR — it describes jolt-core's transcript code, which is unchanged. It gets updated in the follow-up jolt-core migration PR. ## References - [arkworks-rs/spongefish](https://github.com/arkworks-rs/spongefish) — Fiat-Shamir duplex-sponge library. +- [WizardOfMenlo/whir](https://github.com/WizardOfMenlo/whir) and [sigma-rs/sigma-proofs](https://github.com/sigma-rs/sigma-proofs) — production spongefish consumers using positional (label-less) calls. - Closed dory PR #17 on `a16z/dory` — original spongefish integration attempt, redirected by the maintainer to `jolt-transcript`. - `.claude/2026-04-17-jolt-transcript-spongefish-handoff.md` — handoff notes that triggered this spec. +- Maintainer's scope-shrink comment on #1455 by @moodlezoup (2026-04-21) — requested staged rollout, deferred jolt-core integration and transpiler updates to follow-up PRs. +- `jolt-eval/README.md` — invariant/objective framework and the `#[invariant(Test, Fuzz, RedTeam)]` macro used by the new `transcript_prover_verifier_consistency` module. +- `.github/workflows/bench-crates.yml` — Criterion baseline storage convention (`--save-baseline main_run` artifact + PR `--baseline main_run` comparison). - Related jolt crate-extraction work: - jolt#1362 — workspace scaffolding (merged). - jolt#1363 — `jolt-field` crate (merged). - - jolt#1365 — `jolt-transcript` crate extraction (merged; this spec activates it). + - jolt#1365 — `jolt-transcript` crate extraction (merged; this spec ports its internal implementation). - jolt#1368 — `jolt-crypto` crate (merged). - jolt#1369 — `jolt-trace` crate (merged). - jolt#1322 — original Poseidon transcript + gnark transpiler pipeline. From d7cf298517548722ca644d17d888527368048704 Mon Sep 17 00:00:00 2001 From: vishal Date: Tue, 5 May 2026 12:04:23 +0530 Subject: [PATCH 03/21] spec: narrow jolt-transcript port to crate-only with compat layer --- specs/jolt-transcript-spongefish.md | 75 +++++++++++++++++------------ 1 file changed, 44 insertions(+), 31 deletions(-) diff --git a/specs/jolt-transcript-spongefish.md b/specs/jolt-transcript-spongefish.md index c9959a2279..f027abcfe7 100644 --- a/specs/jolt-transcript-spongefish.md +++ b/specs/jolt-transcript-spongefish.md @@ -9,35 +9,36 @@ ## Summary -Port the internal implementation of the `crates/jolt-transcript` crate to use [spongefish](https://github.com/arkworks-rs/spongefish)'s duplex-sponge Fiat-Shamir construction. The crate currently holds a hand-rolled digest-based implementation; this PR replaces it with spongefish-backed `ProverTranscript` / `VerifierTranscript` traits and a new `PoseidonSponge` adapter over `light-poseidon`. The crate stays workspace-local and unused by `jolt-core`, dory, or the transpiler in this PR — integration into the jolt-core transcript call-path, the dory PCS bridge, and the gnark transpiler are explicitly deferred to follow-up PRs per maintainer guidance on #1455. This PR is step 1 of a staged rollout; scope is intentionally narrow so the trait surface and spongefish wiring can be reviewed in isolation. One downstream consumer is touched in-scope: `jolt-eval` gains a dependency on `jolt-transcript` so a new `transcript_prover_verifier_consistency` invariant can mechanically verify sponge symmetry across all three sponges. +Port the internal implementation of the `crates/jolt-transcript` crate to use [spongefish](https://github.com/arkworks-rs/spongefish)'s duplex-sponge Fiat-Shamir construction. The crate currently holds a hand-rolled digest-based implementation; this PR adds spongefish-backed `ProverTranscript` / `VerifierTranscript` traits, keeps the existing `Transcript` / `AppendToTranscript` facade source-compatible for current modular-crate consumers, and adds a new `PoseidonSponge` adapter over `light-poseidon`. Per maintainer guidance on #1455, the staged rollout still leaves `jolt-core`, dory, the transpiler, `zklean-extractor`, and `JoltProof` untouched. However, `jolt-transcript` is already used by `jolt-sumcheck`, `jolt-openings`, and `jolt-crypto`; this PR must either preserve their existing API surface or migrate those direct consumers in the same PR. Default decision: preserve compatibility in this PR and validate those consumers explicitly. `jolt-eval` also gains a dependency on `jolt-transcript` so a new `transcript_prover_verifier_consistency` invariant can mechanically verify sponge symmetry across all three sponges. ## Intent ### Goal -Replace `crates/jolt-transcript`'s hand-rolled digest-based `Transcript` trait with spongefish-native `ProverTranscript` and `VerifierTranscript` traits, implemented directly on `spongefish::ProverState` / `spongefish::VerifierState`. Methods are positional, matching spongefish-native shape. Three sponges are feature-selectable within the crate: `spongefish::Blake2b512`, `spongefish::Keccak`, and a new `PoseidonSponge` adapter over `light-poseidon`. `jolt-eval` gains a new invariant that exercises the new trait surface across all three sponges. Crates outside `crates/jolt-transcript/` and `jolt-eval/` are not modified in this PR. +Replace `crates/jolt-transcript`'s hand-rolled digest-based internals with spongefish-backed implementations. Add spongefish-native `ProverTranscript` and `VerifierTranscript` traits implemented directly on `spongefish::ProverState` / `spongefish::VerifierState<'_, Sponge>`, while retaining the current symmetric `Transcript` / `AppendToTranscript` facade for `jolt-sumcheck`, `jolt-openings`, and `jolt-crypto`. Methods are positional, matching spongefish-native shape. Three sponges are feature-selectable within the crate: `spongefish::instantiations::Blake2b512`, `spongefish::instantiations::Keccak`, and a new `PoseidonSponge` adapter over `light-poseidon`. `jolt-eval` gains a new invariant that exercises the new trait surface across all three sponges. `jolt-core`, dory, the transpiler, `zklean-extractor`, and `JoltProof` are not modified in this PR. Key abstractions: -- **`ProverTranscript`** (new trait, replacing the current symmetric `Transcript` trait) — positional methods: `public_message(&T)`, `prover_message(&T)`, `verifier_message() -> T`, `narg_string()`. -- **`VerifierTranscript`** (new trait) — positional methods: `public_message(&T)`, `receive_prover_message() -> T`, `verifier_message() -> T`, `check_eof()`. +- **`ProverTranscript`** (new trait, additive to the current symmetric `Transcript` facade) — positional methods: `public_message + ?Sized>(&mut self, &T)`, `prover_message + spongefish::NargSerialize + ?Sized>(&mut self, &T)`, `verifier_message>(&mut self) -> T`, and `narg_string(&self) -> &[u8]`. +- **`VerifierTranscript`** (new trait) — positional methods: `public_message + ?Sized>(&mut self, &T)`, `prover_message + spongefish::NargDeserialize>(&mut self) -> spongefish::VerificationResult`, `verifier_message>(&mut self) -> T`, and `check_eof(self) -> spongefish::VerificationResult<()>`. - Per-sponge challenge-width contract: Blake2b and Keccak expose both a 128-bit-truncating optimized decoder and a full-field decoder. Poseidon exposes only a full-field decoder (254-bit — the sponge's native field-element width). The 128-bit decoder is simply not defined on `PoseidonSponge`, so calling a `_optimized` method against it is a compile error. -- **`PoseidonSponge`** (new type) — adapter making `light-poseidon` usable as a spongefish sponge via `spongefish::DuplexSpongeInterface`. Circom-compatible BN254 parameters, matching today's `PoseidonTranscript` configuration. -- Trait impls live directly on `spongefish::ProverState` / `spongefish::VerifierState` (orphan rule allows it); no wrapper structs. +- **`PoseidonSponge`** (new type) — adapter making `light-poseidon` usable as a spongefish sponge via `spongefish::DuplexSpongeInterface`. Circom-compatible BN254 parameters, matching Jolt's intended Poseidon transcript configuration. +- Trait impls live directly on `spongefish::ProverState` / `spongefish::VerifierState<'_, Sponge>` (orphan rule allows it); no wrapper structs. +- **Compatibility facade** — the existing `Transcript`, `AppendToTranscript`, `Blake2bTranscript`, `KeccakTranscript`, `PoseidonTranscript`, `Label`, `LabelWithCount`, and `U64Word` public names remain available for current direct consumers. Their internals may wrap spongefish state, but external source compatibility is preserved in this PR. ### Invariants -1. **Prover/verifier sponge symmetry (within the new crate).** Two `spongefish::ProverState` / `spongefish::VerifierState` instances constructed with identical `DomainSeparator` strings and absorbing the same sequence of values via `public_message` produce identical challenge streams via `verifier_message`. Holds for each of the three sponges. This invariant is encoded mechanically in `jolt-eval` (see Acceptance Criteria and Testing Strategy). +1. **Prover/verifier sponge symmetry (within the new crate).** A `spongefish::ProverState` / `spongefish::VerifierState<'_, Sponge>` pair constructed with identical Spongefish domain setup — protocol identifier, session choice, and prefix-free instance encoding — and replaying the same ordered sequence of public messages, prover messages, and challenges produces identical challenge streams via `verifier_message` and identical verifier-side `prover_message` round trips. Holds for each of the three sponges. This invariant is encoded mechanically in `jolt-eval` (see Acceptance Criteria and Testing Strategy). -2. **No external behavior change, apart from a controlled jolt-eval addition.** `jolt-core/src/transcripts/`, the `JoltToDoryTranscript` bridge (`jolt-core/src/poly/commitment/dory/wrappers.rs:336`), the `transpiler/` and `zklean-extractor/` crates, and `JoltProof` are not modified. All their existing tests continue to pass unchanged. No downstream artifact (Solidity verifier, gnark verifier, Lean extraction) needs updating as a consequence of this PR. The only crate modified outside `crates/jolt-transcript/` is `jolt-eval`, which gains a new invariant module and a dependency on `jolt-transcript`. +2. **No external behavior change for the staged-out systems.** `jolt-core/src/transcripts/`, the `JoltToDoryTranscript` bridge (`jolt-core/src/poly/commitment/dory/wrappers.rs:336`), the `transpiler/` and `zklean-extractor/` crates, and `JoltProof` are not modified. All their existing tests continue to pass unchanged. No downstream artifact (Solidity verifier, gnark verifier, Lean extraction) needs updating as a consequence of this PR. Direct modular consumers of `jolt-transcript` (`jolt-sumcheck`, `jolt-openings`, `jolt-crypto`) continue to compile and pass against the compatibility facade. ### Non-Goals 1. **Integrating the new crate into `jolt-core`, dory, or the transpiler.** jolt-core's ~148 transcript callsites keep using `jolt-core/src/transcripts/`; the `JoltToDoryTranscript` bridge is untouched; `transpiler/` and `zklean-extractor/` are untouched. Three separate follow-up PRs will land these: (a) a16z/dory PR replacing dory's own `DoryTranscript` with a `crates/jolt-transcript` dependency; (b) a jolt-core migration PR replacing `jolt-core/src/transcripts/` with `jolt-transcript` imports and updating the 148 callsites; (c) a transpiler update PR regenerating the gnark verifier against spongefish's Poseidon absorb layout. -2. **Cryptographic redesign of what gets absorbed, at any layer.** Per-sponge challenge widths preserve today's semantics: Blake2b/Keccak's new traits expose both a 128-bit-optimized decoder and a full-field decoder; Poseidon's new traits expose only a full-field decoder. jolt-core's `challenge-254-bit` feature (gating `JoltField::Challenge` at `jolt-core/src/field/ark.rs:2`) is not modified and continues to behave exactly as today — since this PR does not connect jolt-core to the new crate, there is no cross-crate coupling on that feature. +2. **Cryptographic redesign of what gets absorbed, at any layer.** Per-sponge challenge-width APIs are explicit and staged: Blake2b/Keccak's new split traits expose both a 128-bit-optimized decoder and a full-field decoder; Poseidon's new split traits expose only a full-field decoder; the compatibility facade preserves the source-level modular-crate API. jolt-core's `challenge-254-bit` feature (gating `JoltField::Challenge` at `jolt-core/src/field/ark.rs:2`) is not modified and continues to behave exactly as today — since this PR does not connect jolt-core to the new crate, there is no cross-crate coupling on that feature. -3. **Guaranteeing stable spongefish NARG byte format across future spongefish releases.** `crates/jolt-transcript` pins the latest published `spongefish` at implementation time; future releases are picked up as normal dep updates. +3. **Guaranteeing stable spongefish NARG byte format across future spongefish releases.** `crates/jolt-transcript` targets the current published `spongefish` `0.7.x` API at implementation time; future spongefish version bumps must revalidate API signatures, NARG semantics, and compatibility-facade byte encodings. 4. **Performance improvement beyond no-regression.** Bar: `transcript_ops` micro-benchmark within 5% of the `main_run` CI baseline per `BenchmarkId`. @@ -45,17 +46,18 @@ Key abstractions: ### Acceptance Criteria -- [ ] `crates/jolt-transcript` replaces its current hand-rolled impl with spongefish-backed `ProverTranscript` / `VerifierTranscript` traits, implemented directly on `spongefish::ProverState` / `spongefish::VerifierState` (no wrapper structs). -- [ ] Three new Cargo features added to `crates/jolt-transcript/Cargo.toml` — `transcript-blake2b`, `transcript-keccak`, `transcript-poseidon` — selecting `spongefish::Blake2b512`, `spongefish::Keccak`, and the new `PoseidonSponge` adapter respectively. These features are new to the crate; the identically-named features in `jolt-core/Cargo.toml:45-48`, `jolt-sdk/Cargo.toml:44-46`, and `transpiler/Cargo.toml:26-28` are not modified and continue to drive `jolt-core/src/transcripts/` unchanged. +- [ ] `crates/jolt-transcript` replaces its current hand-rolled internals with spongefish-backed `ProverTranscript` / `VerifierTranscript` traits, implemented directly on `spongefish::ProverState` / `spongefish::VerifierState<'_, Sponge>` (no wrapper structs for the new split traits), while preserving the existing `Transcript` / `AppendToTranscript` facade for direct modular consumers. +- [ ] Three new Cargo features added to `crates/jolt-transcript/Cargo.toml` — `transcript-blake2b`, `transcript-keccak`, `transcript-poseidon` — selecting `spongefish::instantiations::Blake2b512`, `spongefish::instantiations::Keccak`, and the new `PoseidonSponge` adapter respectively. These features are new to the crate; the identically-named features in `jolt-core/Cargo.toml:45-48`, `jolt-sdk/Cargo.toml:44-46`, and `transpiler/Cargo.toml:26-28` are not modified and continue to drive `jolt-core/src/transcripts/` unchanged. - [ ] Per-sponge challenge-width contract: Blake2b/Keccak expose both 128-bit-optimized and full-field decoders; Poseidon exposes full-field only (the 128-bit API is not defined on `PoseidonSponge`, so using it is a compile error). -- [ ] `jolt-core/src/transcripts/` is **not** modified; `jolt-core` does **not** depend on `jolt-transcript`; `JoltToDoryTranscript` is **not** modified; `transpiler/` and `zklean-extractor/` are **not** modified; `JoltProof` is **not** modified. -- [ ] `crates/jolt-transcript/tests/` and `crates/jolt-transcript/fuzz/` are deleted. Rationale in Testing Strategy. +- [ ] `jolt-core/src/transcripts/` is **not** modified; `jolt-core` does **not** depend on `jolt-transcript`; `JoltToDoryTranscript` is **not** modified; `transpiler/` and `zklean-extractor/` are **not** modified; `JoltProof` is **not** modified. `jolt-sumcheck`, `jolt-openings`, and `jolt-crypto` are either left source-compatible via the facade or migrated in this PR; the default path is source-compatible preservation. +- [ ] `crates/jolt-transcript/tests/` and `crates/jolt-transcript/fuzz/` are not blindly deleted. Generic tests that only duplicate spongefish's own determinism/order-sensitivity coverage may be removed, but local coverage remains or is added for the compatibility facade, local codecs, Poseidon adapter, NARG EOF rejection, and challenge-width behavior. - [ ] `crates/jolt-transcript/benches/transcript_ops.rs` is adapted to the new positional API and retained. -- [ ] A new jolt-eval invariant `transcript_prover_verifier_consistency` is added at `jolt-eval/src/invariant/transcript_symmetry.rs`, with one instantiation per sponge. The invariant covers the full trait surface — `public_message`, `prover_message`/`receive_prover_message`, and `verifier_message` — via an `Input` enum with variants `PublicBytes`, `PublicScalar`, `ProverBytes`, `ProverScalar`, `Challenge`. The `check` method runs the sequence on a `ProverTranscript` to build the NARG, constructs a `VerifierTranscript` against it, replays the sequence, and asserts both `receive_prover_message` round-trip equality and `verifier_message` challenge equality. The `#[invariant(Test, Fuzz, RedTeam)]` macro generates `#[test] fn seed_corpus()` and `#[test] fn random_inputs()` per instantiation; the generated fuzz targets compile; the seed corpus covers the empty sequence, single-message (each variant), 10-message mixed, and 1000-message mixed cases. +- [ ] A new jolt-eval invariant `transcript_prover_verifier_consistency` is added at `jolt-eval/src/invariant/transcript_symmetry.rs`, with one instantiation per sponge and `type Setup = ()`. The invariant covers the full trait surface — `public_message`, prover-side and verifier-side `prover_message`, and `verifier_message` — via an `Input` enum with variants `PublicBytes`, `PublicScalar`, `ProverBytes`, `ProverScalar`, `Challenge`. The `check` method derives a valid Spongefish `DomainSeparator` from the exact operation sequence, runs the sequence on a `ProverTranscript` to build the NARG, constructs a `VerifierTranscript` against it, replays the sequence, and asserts both verifier-side `prover_message` round-trip equality and `verifier_message` challenge equality. A sequence may begin with `Challenge`; validity is determined by the generated `DomainSeparator`, not by a hard-coded "must absorb first" rule. The `#[invariant(Test, Fuzz, RedTeam)]` macro generates `#[test] fn seed_corpus()` and `#[test] fn random_inputs()` per instantiation; the generated fuzz targets compile; the seed corpus covers the empty sequence, single-message (each variant), 10-message mixed, and 1000-message mixed cases. - [ ] `jolt-eval/Cargo.toml` gains a dependency on `jolt-transcript`. `jolt-eval/src/invariant/mod.rs` registers the new module. `jolt-eval/sync_targets.sh` is run so the fuzz target Cargo.toml entries are synchronized (per `jolt-eval/README.md` lines 229-239). - [ ] `cargo nextest run -p jolt-eval` passes, exercising the new invariant's generated tests for all three sponge instantiations. -- [ ] `cargo nextest run -p jolt-core muldiv --features host` and `--features host,zk` continue to pass unchanged (workspace-build sanity check; jolt-core does not exercise the new code in this PR, so a per-sponge matrix is not meaningful here). -- [ ] `jolt-transcript` depends on the latest published `spongefish` release on crates.io at implementation time; version captured in `Cargo.lock`. +- [ ] `cargo nextest run -p jolt-sumcheck -p jolt-openings -p jolt-crypto` passes, proving all current direct `jolt-transcript` consumers remain compatible. +- [ ] `cargo nextest run -p jolt-core muldiv --cargo-quiet --features host` and `cargo nextest run -p jolt-core muldiv --cargo-quiet --features host,zk` continue to pass unchanged (workspace-build sanity check; jolt-core does not exercise the new code in this PR, so a per-sponge matrix is not meaningful here). +- [ ] `jolt-transcript` depends on the current published `spongefish` `0.7.x` release on crates.io at implementation time; version captured in `Cargo.lock`. Do not enable Spongefish's arkworks codec features unless compatibility with Jolt's patched `ark-ff`/`ark-serialize` versions is verified. ### Testing Strategy @@ -64,22 +66,24 @@ Key abstractions: - All `jolt-core` unit, integration, and e2e tests — no modifications, since jolt-core's transcript path is untouched. `muldiv` under `--features host` and `--features host,zk` included. - All `transpiler/` and `zklean-extractor/` tests — no modifications, same reason. - All dory-related tests via the existing `JoltToDoryTranscript` bridge — no modifications. +- All current direct modular consumers of `jolt-transcript`: `cargo nextest run -p jolt-sumcheck -p jolt-openings -p jolt-crypto`. +- Primary jolt-core sanity checks: `cargo nextest run -p jolt-core muldiv --cargo-quiet --features host` and `cargo nextest run -p jolt-core muldiv --cargo-quiet --features host,zk`. -**Tests removed alongside the code they test:** +**Tests removed or rewritten alongside the code they test:** -- `crates/jolt-transcript/tests/` (all four files and the shared `common/` macro) — tested generic Fiat-Shamir properties (determinism, domain separation, order sensitivity, clone behavior, prover/verifier consistency). These hold for any correct generic deterministic sponge regardless of parameter correctness — wrong width/rate/chunking in our adapter would produce "wrong but deterministic" output, which property tests still pass. They measure spongefish's properties (which spongefish tests upstream), not our adapter's parametrization. -- `crates/jolt-transcript/fuzz/` — fuzzed the no-panic guarantee, same reasoning. +- Generic property tests under `crates/jolt-transcript/tests/` may be removed when they only duplicate spongefish's own properties (determinism, order sensitivity, clone behavior). Tests that protect Jolt-local behavior — compatibility facade byte encodings, scalar endianness, label/count packing, Poseidon adapter semantics, and NARG EOF rejection — must be retained or replaced. +- `crates/jolt-transcript/fuzz/` may be reduced if it only fuzzes spongefish's no-panic behavior. Keep or add fuzz/invariant coverage for local codecs and the compatibility facade. **New tests added in this PR:** - `jolt-eval`'s `transcript_prover_verifier_consistency` invariant, instantiated per sponge. The `#[invariant(Test, Fuzz, RedTeam)]` macro auto-generates two `#[test]` entries per instantiation (`seed_corpus`, `random_inputs`), plus a `libfuzzer_sys` fuzz target. Test entries run under `cargo nextest run -p jolt-eval`. The fuzz target builds in CI but is invoked ad-hoc via `cargo fuzz run -p jolt-eval `. -- This invariant is the only in-tree correctness gauge for the new crate's spongefish wiring. Its differential-comparison shape (two instances absorbing the same messages must produce the same challenges) directly mechanizes Invariant #1. If an adapter-layer bug corrupts state but deterministically (e.g., wrong rate on `PoseidonSponge`), the invariant still passes — ultimate validation of absorb correctness happens in the follow-up jolt-core migration PR's `muldiv` e2e, which exercises the full protocol flow. +- This invariant is the only in-tree correctness gauge for the new crate's spongefish wiring. Its differential-comparison shape (a prover/verifier pair replaying the same operation sequence must round-trip prover messages and produce the same challenges) directly mechanizes Invariant #1. If an adapter-layer bug corrupts state but deterministically (e.g., wrong rate on `PoseidonSponge`), the invariant still passes — ultimate validation of absorb correctness happens in the follow-up jolt-core migration PR's `muldiv` e2e, which exercises the full protocol flow. ### Performance **Regression budget: ≤5% wall-clock regression per `BenchmarkId` on `transcript_ops`**, measured against the `main_run` baseline artifact `criterion-baseline` stored by `.github/workflows/bench-crates.yml` on main pushes. The PR CI job at the same workflow downloads the baseline, runs the PR benchmarks with `--save-baseline pr_run`, and invokes `critcmp main_run pr_run --threshold 5` in the "Compare benchmarks" step to enforce the budget per individual `BenchmarkId` (e.g., `transcript_ops/absorb_scalar/Blake2b`), not against an aggregate. -Rationale: transcript operations are fundamentally hash calls (absorb, squeeze). The underlying hash primitive per sponge is unchanged — Blake2b, Keccak, and Poseidon all compute the same way they did pre-port. Spongefish's construction adds a thin domain-separation framework; it does not change the dominant cost. The 5% budget accommodates small differences in per-call sponge ratcheting and domain-separator overhead without requiring micro-optimization. +Rationale: transcript operations are fundamentally hash calls (absorb, squeeze). The selected cryptographic primitives remain Blake2b, Keccak, and Circom-compatible BN254 Poseidon; spongefish adds a domain-separation and NARG framework around those primitives. The 5% budget accommodates small differences in per-call sponge ratcheting, adapter code, and domain-separator overhead without requiring micro-optimization. ## Design @@ -87,24 +91,32 @@ Rationale: transcript operations are fundamentally hash calls (absorb, squeeze). **`crates/jolt-transcript`** (currently contains a hand-rolled Fiat-Shamir implementation — `src/{lib,transcript,blake2b,keccak,poseidon,blanket,digest}.rs` plus populated `tests/`, `fuzz/`, `benches/` — that this PR replaces in-place): -- Adds `spongefish` and `light-poseidon` workspace dependencies. -- Defines `ProverTranscript` and `VerifierTranscript` traits with positional method signatures matching spongefish-native shape. Domain separation lives in the one-time `DomainSeparator` string used at transcript construction. Production spongefish consumers (WhiR, sigma-rs) use this same positional style. Note: the current `crates/jolt-transcript/src/transcript.rs` is already positional per-call; the positional-API choice is structural preparation for the follow-up jolt-core migration, where jolt-core's labeled per-call style (`append_scalar(b"opening_claim", &x)` at ~148 callsites in `jolt-core/src/transcripts/`) will transform into positional `prover_message(&x)` calls. Within this PR's scope, no callsite transformation is observable. +- Adds `spongefish` `0.7.x` and `light-poseidon` workspace dependencies. Enable only the spongefish hash features required for the selected backends; do not enable spongefish arkworks codec features unless they are proven compatible with Jolt's patched `ark-ff` / `ark-serialize` dependency graph. +- Defines `ProverTranscript` and `VerifierTranscript` traits with positional method signatures matching spongefish-native shape. Domain separation lives in the one-time `DomainSeparator` used at transcript construction. Production spongefish consumers (WhiR, sigma-rs) use this same positional style. Note: the current `crates/jolt-transcript/src/transcript.rs` is already positional per-call; the positional-API choice is structural preparation for the follow-up jolt-core migration, where jolt-core's labeled per-call style (`append_scalar(b"opening_claim", &x)` at ~148 callsites in `jolt-core/src/transcripts/`) will transform into positional `prover_message(&x)` calls. Within this PR's scope, no jolt-core callsite transformation is observable. +- Uses a concrete Spongefish domain setup before constructing prover/verifier states: `DomainSeparator::new(JOLT_TRANSCRIPT_PROTOCOL_ID)`, where the 64-byte protocol id is ASCII `a16z/jolt-transcript/spongefish/v1` followed by zero padding; an explicit session decision (`session(...)` or `without_session()`); and a prefix-free instance encoding. The instance encoding is a local codec struct containing at least a version tag, sponge/backend id, challenge-width mode, and length-prefixed caller instance bytes. For the compatibility facade, `Transcript::new(label)` maps `label` into the session context with length-prefixing; the native split API requires callers to choose either an explicit session value or `without_session()` and to bind an instance value before calling `to_prover` / `to_verifier`. +- Provides local codec/newtype implementations for Jolt field/scalar/byte/message types instead of relying on spongefish's optional arkworks codecs. Jolt-local encodings must remain injective and prefix-free; the compatibility facade must preserve today's scalar endianness and label/count packing semantics for existing modular consumers. - Challenge decoders implement `spongefish::Decoding<[H::U]>`. Per-sponge contract: - **Blake2b** and **Keccak**: both a 128-bit-truncating optimized decoder and a full-field decoder. - **Poseidon**: full-field (254-bit) decoder only. The 128-bit optimized API is not defined on `spongefish::ProverState` — attempting to use it is a compile error. -- Implements these traits directly on `spongefish::ProverState` / `spongefish::VerifierState` via the orphan rule — no wrapper structs around the library types. -- Provides a new `PoseidonSponge` adapter making `light-poseidon` usable as a spongefish sponge (via `DuplexSpongeInterface`, or equivalently via a `Permutation` impl consumed through spongefish's built-in `DuplexSponge` — implementer's choice). Circom-compatible BN254 parameters, matching today's `PoseidonTranscript`. Both paths produce absorb layouts that the follow-up gnark transpiler PR will validate against the emitted verifier; within this PR's scope there is no observable downstream effect from the choice, so the implementer should pick the path that most cleanly matches `light-poseidon`'s native API surface. +- Implements these traits directly on `spongefish::ProverState` / `spongefish::VerifierState<'_, Sponge>` via the orphan rule — no wrapper structs around the library types. +- Provides a new `PoseidonSponge` adapter making `light-poseidon` usable as a spongefish sponge (via `DuplexSpongeInterface`, or equivalently via a `Permutation` impl consumed through spongefish's built-in `DuplexSponge` — implementer's choice). Use Circom-compatible BN254 parameters matching Jolt's intended Poseidon transcript configuration; preserve the compatibility facade for existing modular consumers. The follow-up gnark transpiler PR validates the exact absorb layout against the emitted verifier; within this PR's scope there is no observable downstream effect from the adapter-shape choice, so the implementer should pick the path that most cleanly matches `light-poseidon`'s native API surface. - Introduces three new Cargo features: `transcript-blake2b`, `transcript-keccak`, `transcript-poseidon`. These are new to the crate; jolt-core's and downstream crates' identically-named features remain in place and continue to drive `jolt-core/src/transcripts/` unchanged. -- Deletes the current `crates/jolt-transcript/src/{transcript,blake2b,keccak,poseidon,blanket,digest}.rs` legacy implementations. `lib.rs` is retained and re-exports the new trait surface. -- Deletes `crates/jolt-transcript/tests/` and `crates/jolt-transcript/fuzz/` per Testing Strategy. +- Rewrites the current `crates/jolt-transcript/src/{transcript,blake2b,keccak,poseidon,blanket,digest}.rs` legacy implementations in place. `lib.rs` is retained and re-exports both the new split trait surface and the existing compatibility facade. +- Removes only the tests/fuzz targets that no longer test Jolt-owned behavior; retains or replaces tests for local codec correctness, the compatibility facade, Poseidon adapter behavior, and NARG EOF rejection per Testing Strategy. **`jolt-eval`:** - Adds `jolt-transcript` as a dependency in `jolt-eval/Cargo.toml`. -- Adds `jolt-eval/src/invariant/transcript_symmetry.rs` defining `TranscriptProverVerifierConsistency` with `#[invariant(Test, Fuzz, RedTeam)]` targets, one instantiation per sponge. The `Input` type is an enum covering the full trait surface: `PublicBytes(Vec)`, `PublicScalar(F)`, `ProverBytes(Vec)`, `ProverScalar(F)`, and `Challenge`. `check` runs the sequence on a `ProverTranscript` to produce a NARG byte string, constructs a `VerifierTranscript` against that NARG, replays the same sequence on the verifier, and asserts: (a) at each `ProverBytes`/`ProverScalar` op, the verifier's `receive_prover_message` returns the original value; (b) at each `Challenge` op, both sides' `verifier_message` outputs are identical. +- Adds `jolt-eval/src/invariant/transcript_symmetry.rs` defining `TranscriptProverVerifierConsistency` with `#[invariant(Test, Fuzz, RedTeam)]` targets, `type Setup = ()`, and one instantiation per sponge. The `Input` type is an enum covering the full trait surface: `PublicBytes(Vec)`, `PublicScalar(F)`, `ProverBytes(Vec)`, `ProverScalar(F)`, and `Challenge`. `check` derives a valid `DomainSeparator` from the exact generated operation sequence, runs that sequence on a `ProverTranscript` to produce a NARG byte string, constructs a `VerifierTranscript` against that NARG, replays the same sequence on the verifier, calls `check_eof()`, and asserts: (a) at each `ProverBytes`/`ProverScalar` op, the verifier-side `prover_message` returns the original value; (b) at each `Challenge` op, both sides' `verifier_message` outputs are identical. - Registers the new module in `jolt-eval/src/invariant/mod.rs`. - Runs `jolt-eval/sync_targets.sh` to synchronize the fuzz target Cargo.toml entries (per `jolt-eval/README.md:229-239`). +**Direct modular consumers of `jolt-transcript`:** + +- `jolt-sumcheck`, `jolt-openings`, and `jolt-crypto` currently depend on `jolt-transcript` directly, so this PR validates them explicitly. +- Default implementation path: preserve their existing imports, trait bounds, and callsites by keeping the `Transcript` / `AppendToTranscript` compatibility facade. If implementation proves this facade impractical, the PR must migrate these three crates in-scope and update this spec's test commands accordingly before merging. +- The compatibility facade is a source-compatibility layer only. It is not the future jolt-core migration API; future jolt-core work should use the split `ProverTranscript` / `VerifierTranscript` API and Spongefish NARG flow directly. + **Out of scope this PR (preserved exactly as today):** - `jolt-core/src/transcripts/` — the hand-rolled `Transcript` trait and its three backends used by ~148 jolt-core callsites, the `JoltToDoryTranscript` bridge, the transpilable verifier, BlindFold, sumcheck, univariate skip, HyperKZG, etc. @@ -124,7 +136,7 @@ Rationale: transcript operations are fundamentally hash calls (absorb, squeeze). 5. **Super-trait `TranscriptCommon` for shared prover/verifier code.** Rejected: spongefish's `public_message` semantics already handle symmetric absorption — both sides independently call `public_message(&value)` with identical inputs, and sponge states move in lockstep. Shared binding code is cleaner duplicated at both callsites than abstracted; Fiat-Shamir symmetry is more eyeball-auditable when both sides' code is visible side by side. -6. **Keep adapted versions of the existing property tests (determinism, domain separation, order-sensitivity, etc.) against `PoseidonSponge` to test the adapter.** Rejected: these tests verify properties that hold for any correct generic deterministic sponge, regardless of parameter correctness. Wrong width/rate/chunking in our `DuplexSpongeInterface` impl would produce "wrong but deterministic" output — property tests would still pass. They don't catch realistic adapter failure modes, so running them on our new code provides false confidence. The meaningful signal comes from the `jolt-eval` invariant (which tests the differential prover-verifier property the protocol relies on) plus the follow-up integration PR's `muldiv` e2e. +6. **Keep all existing transcript property tests unchanged.** Rejected: tests that only duplicate spongefish's generic properties (determinism, order-sensitivity, no-panic behavior) provide little signal after the port. However, wholesale deletion is also rejected because this crate now owns compatibility encodings, local codecs, Poseidon adapter wiring, and NARG EOF checks. Retain or rewrite tests for those Jolt-owned behaviors. 7. **No in-tree correctness gauge at all; defer all verification to the follow-up jolt-core migration's `muldiv` e2e.** Rejected (after reviewer feedback): `jolt-eval` is specifically designed to provide mechanically checkable invariants for exactly this class of property, and the cost of registering one invariant (one file + one Cargo.toml dep) is small compared to the value of closing the staging gap. @@ -134,7 +146,8 @@ No `book/` changes required. `CLAUDE.md`'s `## Architecture → transcripts/` su ## References -- [arkworks-rs/spongefish](https://github.com/arkworks-rs/spongefish) — Fiat-Shamir duplex-sponge library. +- [arkworks-rs/spongefish](https://github.com/arkworks-rs/spongefish) and [spongefish crate docs](https://docs.rs/spongefish/latest/spongefish/) — Fiat-Shamir duplex-sponge library. This spec targets the current `0.7.x` API (`Encoding`, `NargSerialize`, `NargDeserialize`, `Decoding`, `VerificationResult`, `narg_string() -> &[u8]`, and `check_eof() -> VerificationResult<()>`). +- [spongefish `ProverState`](https://docs.rs/spongefish/latest/spongefish/struct.ProverState.html), [spongefish `VerifierState`](https://docs.rs/spongefish/latest/spongefish/struct.VerifierState.html), and [spongefish `DuplexSpongeInterface`](https://docs.rs/spongefish/latest/spongefish/trait.DuplexSpongeInterface.html) — API contracts used by this spec. - [WizardOfMenlo/whir](https://github.com/WizardOfMenlo/whir) and [sigma-rs/sigma-proofs](https://github.com/sigma-rs/sigma-proofs) — production spongefish consumers using positional (label-less) calls. - Closed dory PR #17 on `a16z/dory` — original spongefish integration attempt, redirected by the maintainer to `jolt-transcript`. - `.claude/2026-04-17-jolt-transcript-spongefish-handoff.md` — handoff notes that triggered this spec. From d90cda57ea254d2ebd303cf0f1087dafe6a67ff2 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Tue, 5 May 2026 12:20:20 +0530 Subject: [PATCH 04/21] spec: resolve implementation ambiguities found in analyze-spec review Concrete field type (ark_bn254::Fr) named for all decoders. Compat facade trait bound requirements (Default + Clone + Sync + Send + 'static) made explicit. Test command gains feature flags for all three sponges. Invariant struct shape clarified as three concrete structs (not generic) matching the JoltInvariants dispatch pattern. JoltInvariants enum update added to jolt-eval acceptance criteria. --- specs/jolt-transcript-spongefish.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/specs/jolt-transcript-spongefish.md b/specs/jolt-transcript-spongefish.md index f027abcfe7..60f4d97a05 100644 --- a/specs/jolt-transcript-spongefish.md +++ b/specs/jolt-transcript-spongefish.md @@ -21,10 +21,10 @@ Key abstractions: - **`ProverTranscript`** (new trait, additive to the current symmetric `Transcript` facade) — positional methods: `public_message + ?Sized>(&mut self, &T)`, `prover_message + spongefish::NargSerialize + ?Sized>(&mut self, &T)`, `verifier_message>(&mut self) -> T`, and `narg_string(&self) -> &[u8]`. - **`VerifierTranscript`** (new trait) — positional methods: `public_message + ?Sized>(&mut self, &T)`, `prover_message + spongefish::NargDeserialize>(&mut self) -> spongefish::VerificationResult`, `verifier_message>(&mut self) -> T`, and `check_eof(self) -> spongefish::VerificationResult<()>`. -- Per-sponge challenge-width contract: Blake2b and Keccak expose both a 128-bit-truncating optimized decoder and a full-field decoder. Poseidon exposes only a full-field decoder (254-bit — the sponge's native field-element width). The 128-bit decoder is simply not defined on `PoseidonSponge`, so calling a `_optimized` method against it is a compile error. +- Per-sponge challenge-width contract: Blake2b and Keccak expose both a 128-bit-truncating optimized decoder and a full-field decoder (decoding to `ark_bn254::Fr`). Poseidon exposes only a full-field decoder (decoding to `ark_bn254::Fr`; 254-bit — the sponge's native field-element width). The 128-bit decoder is simply not defined on `PoseidonSponge`, so calling a `_optimized` method against it is a compile error. - **`PoseidonSponge`** (new type) — adapter making `light-poseidon` usable as a spongefish sponge via `spongefish::DuplexSpongeInterface`. Circom-compatible BN254 parameters, matching Jolt's intended Poseidon transcript configuration. - Trait impls live directly on `spongefish::ProverState` / `spongefish::VerifierState<'_, Sponge>` (orphan rule allows it); no wrapper structs. -- **Compatibility facade** — the existing `Transcript`, `AppendToTranscript`, `Blake2bTranscript`, `KeccakTranscript`, `PoseidonTranscript`, `Label`, `LabelWithCount`, and `U64Word` public names remain available for current direct consumers. Their internals may wrap spongefish state, but external source compatibility is preserved in this PR. +- **Compatibility facade** — the existing `Transcript`, `AppendToTranscript`, `Blake2bTranscript`, `KeccakTranscript`, `PoseidonTranscript`, `Label`, `LabelWithCount`, and `U64Word` public names remain available for current direct consumers. Their internals may wrap spongefish state, but external source compatibility is preserved in this PR. The compat types must continue to satisfy `Default + Clone + Sync + Send + 'static`, since those bounds are baked into the `Transcript` supertrait that `jolt-sumcheck`, `jolt-openings`, and `jolt-crypto` use in their generic bounds. ### Invariants @@ -54,7 +54,7 @@ Key abstractions: - [ ] `crates/jolt-transcript/benches/transcript_ops.rs` is adapted to the new positional API and retained. - [ ] A new jolt-eval invariant `transcript_prover_verifier_consistency` is added at `jolt-eval/src/invariant/transcript_symmetry.rs`, with one instantiation per sponge and `type Setup = ()`. The invariant covers the full trait surface — `public_message`, prover-side and verifier-side `prover_message`, and `verifier_message` — via an `Input` enum with variants `PublicBytes`, `PublicScalar`, `ProverBytes`, `ProverScalar`, `Challenge`. The `check` method derives a valid Spongefish `DomainSeparator` from the exact operation sequence, runs the sequence on a `ProverTranscript` to build the NARG, constructs a `VerifierTranscript` against it, replays the sequence, and asserts both verifier-side `prover_message` round-trip equality and `verifier_message` challenge equality. A sequence may begin with `Challenge`; validity is determined by the generated `DomainSeparator`, not by a hard-coded "must absorb first" rule. The `#[invariant(Test, Fuzz, RedTeam)]` macro generates `#[test] fn seed_corpus()` and `#[test] fn random_inputs()` per instantiation; the generated fuzz targets compile; the seed corpus covers the empty sequence, single-message (each variant), 10-message mixed, and 1000-message mixed cases. - [ ] `jolt-eval/Cargo.toml` gains a dependency on `jolt-transcript`. `jolt-eval/src/invariant/mod.rs` registers the new module. `jolt-eval/sync_targets.sh` is run so the fuzz target Cargo.toml entries are synchronized (per `jolt-eval/README.md` lines 229-239). -- [ ] `cargo nextest run -p jolt-eval` passes, exercising the new invariant's generated tests for all three sponge instantiations. +- [ ] `cargo nextest run -p jolt-eval --features jolt-transcript/transcript-blake2b,jolt-transcript/transcript-keccak,jolt-transcript/transcript-poseidon` passes, exercising the new invariant's generated tests for all three sponge instantiations. - [ ] `cargo nextest run -p jolt-sumcheck -p jolt-openings -p jolt-crypto` passes, proving all current direct `jolt-transcript` consumers remain compatible. - [ ] `cargo nextest run -p jolt-core muldiv --cargo-quiet --features host` and `cargo nextest run -p jolt-core muldiv --cargo-quiet --features host,zk` continue to pass unchanged (workspace-build sanity check; jolt-core does not exercise the new code in this PR, so a per-sponge matrix is not meaningful here). - [ ] `jolt-transcript` depends on the current published `spongefish` `0.7.x` release on crates.io at implementation time; version captured in `Cargo.lock`. Do not enable Spongefish's arkworks codec features unless compatibility with Jolt's patched `ark-ff`/`ark-serialize` versions is verified. @@ -97,7 +97,7 @@ Rationale: transcript operations are fundamentally hash calls (absorb, squeeze). - Provides local codec/newtype implementations for Jolt field/scalar/byte/message types instead of relying on spongefish's optional arkworks codecs. Jolt-local encodings must remain injective and prefix-free; the compatibility facade must preserve today's scalar endianness and label/count packing semantics for existing modular consumers. - Challenge decoders implement `spongefish::Decoding<[H::U]>`. Per-sponge contract: - **Blake2b** and **Keccak**: both a 128-bit-truncating optimized decoder and a full-field decoder. - - **Poseidon**: full-field (254-bit) decoder only. The 128-bit optimized API is not defined on `spongefish::ProverState` — attempting to use it is a compile error. + - **Poseidon**: full-field decoder only, decoding to `ark_bn254::Fr` (254-bit). The 128-bit optimized API is not defined on `spongefish::ProverState` — attempting to use it is a compile error. - Implements these traits directly on `spongefish::ProverState` / `spongefish::VerifierState<'_, Sponge>` via the orphan rule — no wrapper structs around the library types. - Provides a new `PoseidonSponge` adapter making `light-poseidon` usable as a spongefish sponge (via `DuplexSpongeInterface`, or equivalently via a `Permutation` impl consumed through spongefish's built-in `DuplexSponge` — implementer's choice). Use Circom-compatible BN254 parameters matching Jolt's intended Poseidon transcript configuration; preserve the compatibility facade for existing modular consumers. The follow-up gnark transpiler PR validates the exact absorb layout against the emitted verifier; within this PR's scope there is no observable downstream effect from the adapter-shape choice, so the implementer should pick the path that most cleanly matches `light-poseidon`'s native API surface. - Introduces three new Cargo features: `transcript-blake2b`, `transcript-keccak`, `transcript-poseidon`. These are new to the crate; jolt-core's and downstream crates' identically-named features remain in place and continue to drive `jolt-core/src/transcripts/` unchanged. @@ -107,8 +107,8 @@ Rationale: transcript operations are fundamentally hash calls (absorb, squeeze). **`jolt-eval`:** - Adds `jolt-transcript` as a dependency in `jolt-eval/Cargo.toml`. -- Adds `jolt-eval/src/invariant/transcript_symmetry.rs` defining `TranscriptProverVerifierConsistency` with `#[invariant(Test, Fuzz, RedTeam)]` targets, `type Setup = ()`, and one instantiation per sponge. The `Input` type is an enum covering the full trait surface: `PublicBytes(Vec)`, `PublicScalar(F)`, `ProverBytes(Vec)`, `ProverScalar(F)`, and `Challenge`. `check` derives a valid `DomainSeparator` from the exact generated operation sequence, runs that sequence on a `ProverTranscript` to produce a NARG byte string, constructs a `VerifierTranscript` against that NARG, replays the same sequence on the verifier, calls `check_eof()`, and asserts: (a) at each `ProverBytes`/`ProverScalar` op, the verifier-side `prover_message` returns the original value; (b) at each `Challenge` op, both sides' `verifier_message` outputs are identical. -- Registers the new module in `jolt-eval/src/invariant/mod.rs`. +- Adds `jolt-eval/src/invariant/transcript_symmetry.rs` defining three concrete named structs — `TranscriptConsistencyBlake2b`, `TranscriptConsistencyKeccak`, `TranscriptConsistencyPoseidon` — each with `#[invariant(Test, Fuzz, RedTeam)]`, `type Setup = ()`, and a shared `Input` enum. The three-concrete-struct pattern matches `SplitEqBindLowHighInvariant` / `SplitEqBindHighLowInvariant`; the `#[invariant]` macro is not designed for generic structs. The `Input` type is an enum covering the full trait surface: `PublicBytes(Vec)`, `PublicScalar(ark_bn254::Fr)`, `ProverBytes(Vec)`, `ProverScalar(ark_bn254::Fr)`, and `Challenge`. The scalar field is hardcoded to `ark_bn254::Fr`, matching the convention in `split_eq_bind.rs`. `check` derives a valid `DomainSeparator` from the exact generated operation sequence, runs that sequence on a `ProverTranscript` to produce a NARG byte string, constructs a `VerifierTranscript` against that NARG, replays the same sequence on the verifier, calls `check_eof()`, and asserts: (a) at each `ProverBytes`/`ProverScalar` op, the verifier-side `prover_message` returns the original value; (b) at each `Challenge` op, both sides' `verifier_message` outputs are identical. +- Registers the new module in `jolt-eval/src/invariant/mod.rs` and adds one `JoltInvariants` variant per sponge instantiation to the `JoltInvariants` dispatch enum in the same file (following the pattern of `SplitEqBindLowHigh`, `SplitEqBindHighLow`, etc.). - Runs `jolt-eval/sync_targets.sh` to synchronize the fuzz target Cargo.toml entries (per `jolt-eval/README.md:229-239`). **Direct modular consumers of `jolt-transcript`:** From 4654db8dea36a706b43a72edcaee524bad6f99c6 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Tue, 12 May 2026 12:56:12 +0530 Subject: [PATCH 05/21] feat(jolt-transcript): port internals to spongefish 0.7 Reasons: - Establish spongefish-native ProverTranscript/VerifierTranscript split surface on top of spongefish::ProverState / VerifierState so future jolt-core, dory, and gnark transpiler migrations land on a single Fiat-Shamir construction owned by this crate. - Replace the hand-rolled digest-based transcript with a duplex sponge to get NARG byte strings, DomainSeparator-based protocol-id/session/ instance binding, and check_eof rejection of trailing proof bytes. - Add a Circom-compatible BN254 Poseidon sponge (rate-2 capacity-1 over light-poseidon::Poseidon::new_circom(3), byte-driven via 31-byte LE chunks) so the gnark verifier follow-up can recompute the same challenge stream. Layout: flat crates/jolt-transcript/src/ (lib, domain, codec, prover, verifier, poseidon, compat). The compat module preserves the legacy Transcript / AppendToTranscript / Label / LabelWithCount / U64Word surface for jolt-sumcheck, jolt-openings, and jolt-crypto and routes Transcript::new(label) through the same DomainSeparator builder as the split traits. Per-sponge challenge-width contract: Blake2b and Keccak expose both a 128-bit-truncating OptimizedChallenge::challenge_128 and a full-field FieldEl decoder; PoseidonSponge has no OptimizedChallenge impl, so the 128-bit method on a Poseidon-backed state is a compile error. Known-vector tests pin the absolute Fr challenge value per backend for new(b"Jolt") + append_bytes(12345u64.to_be_bytes()) so any accidental encoding change (PROTOCOL_ID, session encoding, append_bytes layout, challenge decoder) flips the pinned bytes. --- Cargo.lock | 227 +++++++- Cargo.toml | 3 +- crates/jolt-transcript/Cargo.toml | 14 +- crates/jolt-transcript/src/blake2b.rs | 8 - crates/jolt-transcript/src/blanket.rs | 15 - crates/jolt-transcript/src/codec.rs | 188 +++++++ crates/jolt-transcript/src/compat.rs | 217 ++++++++ crates/jolt-transcript/src/digest.rs | 165 ------ crates/jolt-transcript/src/domain.rs | 162 ++---- crates/jolt-transcript/src/keccak.rs | 8 - crates/jolt-transcript/src/lib.rs | 104 ++-- crates/jolt-transcript/src/poseidon.rs | 488 ++++++------------ crates/jolt-transcript/src/prover.rs | 81 +++ crates/jolt-transcript/src/transcript.rs | 81 --- crates/jolt-transcript/src/verifier.rs | 59 +++ crates/jolt-transcript/tests/blake2b_tests.rs | 30 +- crates/jolt-transcript/tests/common/mod.rs | 203 +++----- crates/jolt-transcript/tests/keccak_tests.rs | 28 +- .../jolt-transcript/tests/poseidon_tests.rs | 19 + 19 files changed, 1143 insertions(+), 957 deletions(-) delete mode 100644 crates/jolt-transcript/src/blake2b.rs delete mode 100644 crates/jolt-transcript/src/blanket.rs create mode 100644 crates/jolt-transcript/src/codec.rs create mode 100644 crates/jolt-transcript/src/compat.rs delete mode 100644 crates/jolt-transcript/src/digest.rs delete mode 100644 crates/jolt-transcript/src/keccak.rs create mode 100644 crates/jolt-transcript/src/prover.rs delete mode 100644 crates/jolt-transcript/src/transcript.rs create mode 100644 crates/jolt-transcript/src/verifier.rs diff --git a/Cargo.lock b/Cargo.lock index e7cf2eacc2..eb3dac7ae3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2729,7 +2729,7 @@ dependencies = [ "rayon", "serde", "serial_test", - "sha3 0.11.0", + "sha3 0.11.0-rc.9", "strum 0.28.0", "strum_macros 0.28.0", "sysinfo", @@ -2779,12 +2779,14 @@ dependencies = [ "jolt-field", "jolt-inlines-secp256k1", "jolt-inlines-sha2", + "jolt-transcript", "postcard", "rand 0.8.5", "rust-code-analysis", "schemars 1.2.1", "serde", "serde_json", + "spongefish", "tempfile", "tracer", "tracing", @@ -2866,7 +2868,7 @@ version = "0.1.0" dependencies = [ "hex-literal", "jolt-inlines-sdk", - "sha3 0.11.0", + "sha3 0.11.0-rc.9", "tracer", ] @@ -3093,14 +3095,12 @@ version = "0.1.0" dependencies = [ "ark-bn254", "ark-ff 0.5.0", - "ark-serialize 0.5.0", - "blake2 0.11.0-rc.6", "criterion", - "digest 0.11.2", "jolt-field", "light-poseidon", "num-traits", - "sha3 0.11.0", + "rand 0.8.5", + "spongefish", ] [[package]] @@ -3757,6 +3757,171 @@ dependencies = [ "jolt-sdk", ] +[[package]] +name = "p3-challenger" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0b490c745a7d2adeeafff06411814c8078c432740162332b3cd71be0158a76" +dependencies = [ + "p3-field", + "p3-maybe-rayon", + "p3-monty-31", + "p3-symmetric", + "p3-util", + "tracing", +] + +[[package]] +name = "p3-dft" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55301e91544440254977108b85c32c09d7ea05f2f0dd61092a2825339906a4a7" +dependencies = [ + "itertools 0.14.0", + "p3-field", + "p3-matrix", + "p3-maybe-rayon", + "p3-util", + "spin 0.10.0", + "tracing", +] + +[[package]] +name = "p3-field" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85affca7fc983889f260655c4cf74163eebb94605f702e4b6809ead707cba54f" +dependencies = [ + "itertools 0.14.0", + "num-bigint", + "p3-maybe-rayon", + "p3-util", + "paste", + "rand 0.10.1", + "serde", + "tracing", +] + +[[package]] +name = "p3-koala-bear" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7369a8eb2a27b314338f6b4b77e6a701ad31f1deff25b75bd302fc08c924c9b3" +dependencies = [ + "p3-challenger", + "p3-field", + "p3-mds", + "p3-monty-31", + "p3-poseidon1", + "p3-poseidon2", + "p3-symmetric", + "rand 0.10.1", +] + +[[package]] +name = "p3-matrix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53428126b009071563d1d07305a9de8be0d21de00b57d2475289ee32ffca6577" +dependencies = [ + "itertools 0.14.0", + "p3-field", + "p3-maybe-rayon", + "p3-util", + "rand 0.10.1", + "serde", + "tracing", +] + +[[package]] +name = "p3-maybe-rayon" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "082bf467011c06c768c579ec6eb9accb5e1e62108891634cc770396e917f978a" + +[[package]] +name = "p3-mds" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35209e6214102ea6ec6b8cb1b9c15a9b8e597a39f9173597c957f123bced81b3" +dependencies = [ + "p3-dft", + "p3-field", + "p3-symmetric", + "p3-util", + "rand 0.10.1", +] + +[[package]] +name = "p3-monty-31" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffa8c99ec50c035020bbf5457c6a729ba6a975719c1a8dd3f16421081e4f650c" +dependencies = [ + "itertools 0.14.0", + "num-bigint", + "p3-dft", + "p3-field", + "p3-matrix", + "p3-maybe-rayon", + "p3-mds", + "p3-poseidon1", + "p3-poseidon2", + "p3-symmetric", + "p3-util", + "paste", + "rand 0.10.1", + "serde", + "spin 0.10.0", + "tracing", +] + +[[package]] +name = "p3-poseidon1" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a018b618e3fa0aec8be933b1d8e404edd23f46991f6bf3f5c2f3f95e9413fe9" +dependencies = [ + "p3-field", + "p3-symmetric", + "rand 0.10.1", +] + +[[package]] +name = "p3-poseidon2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "256a668a9ba916f8767552f13d0ba50d18968bc74a623bfdafa41e2970c944d0" +dependencies = [ + "p3-field", + "p3-mds", + "p3-symmetric", + "p3-util", + "rand 0.10.1", +] + +[[package]] +name = "p3-symmetric" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c60a71a1507c13611b0f2b0b6e83669fd5b76f8e3115bcbced5ccfdf3ca7807" +dependencies = [ + "itertools 0.14.0", + "p3-field", + "p3-util", + "serde", +] + +[[package]] +name = "p3-util" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8b766b9e9254bf3fa98d76e42cf8a5b30628c182dfd5272d270076ee12f0fc0" +dependencies = [ + "serde", + "transpose", +] + [[package]] name = "page_size" version = "0.6.0" @@ -4275,6 +4440,15 @@ dependencies = [ "serde", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4314,6 +4488,12 @@ dependencies = [ "serde", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -5190,9 +5370,9 @@ dependencies = [ [[package]] name = "sha3" -version = "0.11.0" +version = "0.11.0-rc.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +checksum = "0b233a7d59d7bfc027208506a33ffc9532b2acb24ddc61fe7e758dc2250db431" dependencies = [ "digest 0.11.2", "keccak 0.2.0", @@ -5375,6 +5555,21 @@ dependencies = [ "der", ] +[[package]] +name = "spongefish" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a2b3b9771b4059c025d11039a51294f7ccd9430d594e79aada5fca1e7cb4e3" +dependencies = [ + "blake2 0.11.0-rc.6", + "digest 0.11.2", + "keccak 0.1.6", + "p3-koala-bear", + "rand 0.8.5", + "sha2 0.11.0", + "sha3 0.11.0-rc.9", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5411,6 +5606,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9091b6114800a5f2141aee1d1b9d6ca3592ac062dc5decb3764ec5895a47b4eb" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "strsim" version = "0.11.1" @@ -5822,6 +6023,16 @@ dependencies = [ "zklean-extractor", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "tree-sitter" version = "0.19.3" diff --git a/Cargo.toml b/Cargo.toml index 3e1cdfe36b..e8afe7e624 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -248,11 +248,12 @@ ark-secp256r1 = { git = "https://github.com/a16z/arkworks-algebra", branch = "de ark-serialize-derive = { version = "0.5.0", default-features = false } ark-std = { version = "0.5.0", default-features = false } sha2 = "0.11" -sha3 = "0.11" +sha3 = "=0.11.0-rc.9" blake2 = "0.11.0-rc.6" blake3 = { version = "1.5.0" } light-poseidon = "0.4" digest = "0.11" +spongefish = { version = "0.7", default-features = false, features = ["sha3"] } jolt-optimizations = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" } dory = { package = "dory-pcs", version = "0.3.0", features = [ "backends", diff --git a/crates/jolt-transcript/Cargo.toml b/crates/jolt-transcript/Cargo.toml index cde38673be..baa4837348 100644 --- a/crates/jolt-transcript/Cargo.toml +++ b/crates/jolt-transcript/Cargo.toml @@ -11,15 +11,19 @@ categories = ["cryptography", "no-std"] [lints] workspace = true +[features] +default = ["transcript-blake2b", "transcript-keccak", "transcript-poseidon"] +transcript-blake2b = ["spongefish/blake2"] +transcript-keccak = ["spongefish/keccak"] +transcript-poseidon = ["dep:light-poseidon"] + [dependencies] ark-bn254.workspace = true ark-ff.workspace = true -ark-serialize.workspace = true -blake2.workspace = true -digest.workspace = true -light-poseidon.workspace = true -sha3.workspace = true +light-poseidon = { workspace = true, optional = true } +spongefish = { workspace = true } jolt-field.workspace = true +rand.workspace = true [dev-dependencies] num-traits = { workspace = true } diff --git a/crates/jolt-transcript/src/blake2b.rs b/crates/jolt-transcript/src/blake2b.rs deleted file mode 100644 index 4b47d4a28b..0000000000 --- a/crates/jolt-transcript/src/blake2b.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Blake2b-256 based Fiat-Shamir transcript. - -use blake2::{digest::consts::U32, Blake2b}; - -use crate::digest::DigestTranscript; - -/// Fiat-Shamir transcript backed by Blake2b-256. -pub type Blake2bTranscript = DigestTranscript, F>; diff --git a/crates/jolt-transcript/src/blanket.rs b/crates/jolt-transcript/src/blanket.rs deleted file mode 100644 index 423f487df1..0000000000 --- a/crates/jolt-transcript/src/blanket.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! Blanket implementation of [`AppendToTranscript`] for field elements. - -use jolt_field::Field; - -use crate::transcript::{AppendToTranscript, Transcript}; - -/// Absorbs any field element as big-endian bytes (reversed from the canonical -/// LE layout) for EVM compatibility. -impl AppendToTranscript for F { - fn append_to_transcript(&self, transcript: &mut T) { - let mut buf = self.to_bytes(); - buf.reverse(); - transcript.append_bytes(&buf); - } -} diff --git a/crates/jolt-transcript/src/codec.rs b/crates/jolt-transcript/src/codec.rs new file mode 100644 index 0000000000..6e520b60c8 --- /dev/null +++ b/crates/jolt-transcript/src/codec.rs @@ -0,0 +1,188 @@ +//! Local codecs for absorbing / decoding Jolt-native messages over a +//! byte-oriented spongefish sponge. +//! +//! Spongefish ships optional arkworks codec features; we don't enable them +//! because Jolt patches `ark-ff` / `ark-serialize` to a fork. These local +//! codecs are injective and prefix-free. + +use ark_bn254::Fr; +use ark_ff::{BigInteger, PrimeField}; +use spongefish::{Decoding, Encoding, NargDeserialize, VerificationError, VerificationResult}; + +const FR_LE_BYTES: usize = 32; +const FR_TRUNCATED_BYTES: usize = 16; +/// Bytes drawn per full-field challenge. 64 bytes mod the BN254 modulus +/// is within `2^{-130}` statistical distance of uniform. +const FR_UNIFORM_BYTES: usize = 64; + +fn fr_to_le_bytes(f: &Fr) -> [u8; FR_LE_BYTES] { + let bytes = f.into_bigint().to_bytes_le(); + debug_assert_eq!( + bytes.len(), + FR_LE_BYTES, + "BN254 Fr LE serialization is fixed-width 32 bytes" + ); + let mut out = [0u8; FR_LE_BYTES]; + out.copy_from_slice(&bytes); + out +} + +/// Wraps a BN254 `Fr` for absorption / decoding as 32 little-endian bytes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FieldEl(pub Fr); + +impl From for FieldEl { + fn from(f: Fr) -> Self { + Self(f) + } +} + +impl Encoding<[u8]> for FieldEl { + fn encode(&self) -> impl AsRef<[u8]> { + fr_to_le_bytes(&self.0) + } +} + +/// 64-byte squeeze buffer used as the [`Decoding::Repr`] for full-field +/// challenges. See `FR_UNIFORM_BYTES`. +#[derive(Clone, Copy)] +pub struct UniformFrBytes(pub [u8; FR_UNIFORM_BYTES]); + +impl Default for UniformFrBytes { + fn default() -> Self { + Self([0u8; FR_UNIFORM_BYTES]) + } +} + +impl AsMut<[u8]> for UniformFrBytes { + fn as_mut(&mut self) -> &mut [u8] { + &mut self.0 + } +} + +impl Decoding<[u8]> for FieldEl { + type Repr = UniformFrBytes; + fn decode(buf: Self::Repr) -> Self { + FieldEl(Fr::from_le_bytes_mod_order(&buf.0)) + } +} + +impl NargDeserialize for FieldEl { + fn deserialize_from_narg(buf: &mut &[u8]) -> VerificationResult { + if buf.len() < FR_LE_BYTES { + return Err(VerificationError); + } + let (head, tail) = buf.split_at(FR_LE_BYTES); + *buf = tail; + Ok(FieldEl(Fr::from_le_bytes_mod_order(head))) + } +} + +/// 128-bit-truncating challenge wrapper. Decodes 16 squeezed bytes via +/// `Fr::from(u128)`. Used only as a verifier message; the `Encoding` impl +/// is the same 32-byte LE form as [`FieldEl`] so that absorbing one of +/// these symmetrically with the other type stays a code error rather than +/// a wire-format hazard. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FieldElOptimized(pub Fr); + +impl Encoding<[u8]> for FieldElOptimized { + fn encode(&self) -> impl AsRef<[u8]> { + fr_to_le_bytes(&self.0) + } +} + +impl Decoding<[u8]> for FieldElOptimized { + type Repr = [u8; FR_TRUNCATED_BYTES]; + fn decode(buf: Self::Repr) -> Self { + FieldElOptimized(Fr::from(u128::from_le_bytes(buf))) + } +} + +/// Length-prefixed byte string. 8-byte LE length keeps `BytesMsg(a) ; BytesMsg(b)` +/// distinguishable from `BytesMsg(a||b)`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BytesMsg(pub Vec); + +impl BytesMsg { + /// Returns the inner bytes. + pub fn as_slice(&self) -> &[u8] { + &self.0 + } +} + +impl From> for BytesMsg { + fn from(v: Vec) -> Self { + Self(v) + } +} + +impl Encoding<[u8]> for BytesMsg { + fn encode(&self) -> impl AsRef<[u8]> { + let mut out = Vec::with_capacity(8 + self.0.len()); + out.extend_from_slice(&(self.0.len() as u64).to_le_bytes()); + out.extend_from_slice(&self.0); + out + } +} + +impl NargDeserialize for BytesMsg { + fn deserialize_from_narg(buf: &mut &[u8]) -> VerificationResult { + if buf.len() < 8 { + return Err(VerificationError); + } + let mut len_bytes = [0u8; 8]; + len_bytes.copy_from_slice(&buf[..8]); + let len = u64::from_le_bytes(len_bytes) as usize; + if buf.len() < 8 + len { + return Err(VerificationError); + } + let body = buf[8..8 + len].to_vec(); + *buf = &buf[8 + len..]; + Ok(BytesMsg(body)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fr_le_bytes_round_trip() { + for i in 0u64..32 { + let f = Fr::from(i.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + let bytes = fr_to_le_bytes(&f); + assert_eq!(Fr::from_le_bytes_mod_order(&bytes), f); + } + } + + #[test] + fn bytes_msg_is_length_prefixed() { + let m = BytesMsg(vec![1, 2, 3, 4]); + let enc = m.encode(); + let bytes = enc.as_ref(); + assert_eq!(bytes.len(), 8 + 4); + assert_eq!(&bytes[..8], &4u64.to_le_bytes()); + assert_eq!(&bytes[8..], &[1, 2, 3, 4]); + } + + #[test] + fn bytes_msg_narg_rejects_truncation() { + let m = BytesMsg(vec![9, 8, 7]); + let mut narg: Vec = Vec::new(); + narg.extend_from_slice(m.encode().as_ref()); + let _ = narg.pop(); + let mut cursor: &[u8] = &narg; + let before = cursor.len(); + let result = BytesMsg::deserialize_from_narg(&mut cursor); + assert!(result.is_err()); + assert_eq!(cursor.len(), before, "cursor must not advance on error"); + } + + #[test] + fn field_el_optimized_decodes_u128() { + let buf = 12345u128.to_le_bytes(); + let FieldElOptimized(f) = FieldElOptimized::decode(buf); + assert_eq!(f, Fr::from(12345u128)); + } +} diff --git a/crates/jolt-transcript/src/compat.rs b/crates/jolt-transcript/src/compat.rs new file mode 100644 index 0000000000..14d32f88b3 --- /dev/null +++ b/crates/jolt-transcript/src/compat.rs @@ -0,0 +1,217 @@ +//! Source-compatible facade for `jolt-sumcheck`, `jolt-openings`, and +//! `jolt-crypto`. +//! +//! Wraps a spongefish `ProverState` over each of the three sponges 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::Field; +use spongefish::{DuplexSpongeInterface, Encoding}; + +use crate::codec::BytesMsg; +use crate::domain::{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 + Clone + Sync + Send + 'static { + /// The challenge type produced by this transcript. + type Challenge: Copy + Default + PartialEq + Eq + std::fmt::Debug + std::hash::Hash; + + /// 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); + } + + /// Squeezes a challenge. + #[must_use] + fn challenge(&mut self) -> Self::Challenge; + + /// Squeezes `len` challenges. + #[must_use] + fn challenge_vector(&mut self, len: usize) -> Vec { + (0..len).map(|_| self.challenge()).collect() + } +} + +/// 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); +} + +/// 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 = self.to_bytes(); + 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 compat facade does not produce or consume a NARG byte string — +/// existing modular consumers (jolt-sumcheck, jolt-openings, jolt-crypto) +/// only call `append_bytes` / `challenge`. 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: Field, +{ + sponge: H, + _field: PhantomData, +} + +impl Default for SpongeTranscript +where + H: DuplexSpongeInterface + Clone + Default + Send + Sync + 'static, + F: Field, +{ + fn default() -> Self { + Self::new(b"") + } +} + +impl Clone for SpongeTranscript +where + H: DuplexSpongeInterface + Clone + Default + Send + Sync + 'static, + F: Field, +{ + fn clone(&self) -> Self { + Self { + sponge: self.sponge.clone(), + _field: PhantomData, + } + } +} + +fn absorb_encoded(sponge: &mut H, value: &T) +where + H: DuplexSpongeInterface, + T: Encoding<[u8]> + ?Sized, +{ + let _ = sponge.absorb(value.encode().as_ref()); +} + +impl Transcript for SpongeTranscript +where + H: DuplexSpongeInterface + Clone + Default + Send + Sync + 'static, + F: Field, +{ + 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 compat-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 compat 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 { + let mut buf = [0u8; 16]; + let _ = self.sponge.squeeze(&mut buf); + F::from_u128(u128::from_le_bytes(buf)) + } +} diff --git a/crates/jolt-transcript/src/digest.rs b/crates/jolt-transcript/src/digest.rs deleted file mode 100644 index e7923c5e24..0000000000 --- a/crates/jolt-transcript/src/digest.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Generic digest-based Fiat-Shamir transcript. -//! -//! Provides [`DigestTranscript`], a Fiat-Shamir transcript backed by any -//! 256-bit hash function implementing [`Digest`]. Concrete instantiations -//! (Blake2b, Keccak) are type aliases in their respective modules. - -use digest::{consts::U32, Digest}; - -use crate::transcript::{Transcript, MAX_LABEL_LEN}; - -#[cfg(test)] -#[derive(Clone, Default)] -struct TestState { - state_history: Vec<[u8; 32]>, - expected_state_history: Option>, -} - -/// Fiat-Shamir transcript backed by a 256-bit digest. -/// -/// Generic over the hash function `D` and field type `F`. Challenges are -/// produced as field elements directly via `F::from_u128()`. -pub struct DigestTranscript< - D: Digest + 'static, - F: jolt_field::Field = jolt_field::Fr, -> { - state: [u8; 32], - n_rounds: u32, - #[cfg(test)] - test_state: TestState, - _marker: std::marker::PhantomData<(fn() -> D, F)>, -} - -impl, F: jolt_field::Field> Clone for DigestTranscript { - fn clone(&self) -> Self { - Self { - state: self.state, - n_rounds: self.n_rounds, - #[cfg(test)] - test_state: self.test_state.clone(), - _marker: std::marker::PhantomData, - } - } -} - -impl, F: jolt_field::Field> Default for DigestTranscript { - fn default() -> Self { - Self::new(b"") - } -} - -impl, F: jolt_field::Field> std::fmt::Debug for DigestTranscript { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("DigestTranscript") - .field("state", &format_args!("{:02x?}", self.state)) - .field("n_rounds", &self.n_rounds) - .finish_non_exhaustive() - } -} - -impl, F: jolt_field::Field> DigestTranscript { - #[inline] - fn hasher(&self) -> D { - let mut round_bytes = [0u8; 32]; - round_bytes[28..].copy_from_slice(&self.n_rounds.to_be_bytes()); - D::new().chain_update(self.state).chain_update(round_bytes) - } - - fn challenge_bytes(&mut self, out: &mut [u8]) { - let mut remaining = out.len(); - let mut offset = 0; - - while remaining > 32 { - let mut chunk = [0u8; 32]; - self.challenge_bytes32(&mut chunk); - out[offset..offset + 32].copy_from_slice(&chunk); - offset += 32; - remaining -= 32; - } - - let mut final_chunk = [0u8; 32]; - self.challenge_bytes32(&mut final_chunk); - out[offset..offset + remaining].copy_from_slice(&final_chunk[..remaining]); - } - - #[inline] - fn challenge_bytes32(&mut self, out: &mut [u8; 32]) { - let hash: [u8; 32] = self - .hasher() - .chain_update([0x01]) // squeeze domain tag - .finalize() - .into(); - out.copy_from_slice(&hash); - self.update_state(hash); - } - - fn update_state(&mut self, new_state: [u8; 32]) { - self.state = new_state; - self.n_rounds += 1; - - #[cfg(test)] - { - if let Some(ref expected) = self.test_state.expected_state_history { - assert_eq!( - new_state, expected[self.n_rounds as usize], - "Fiat-Shamir transcript mismatch at round {}", - self.n_rounds - ); - } - self.test_state.state_history.push(new_state); - } - } -} - -impl, F: jolt_field::Field> Transcript for DigestTranscript { - 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 padded = [0u8; MAX_LABEL_LEN]; - padded[..label.len()].copy_from_slice(label); - - let hash: [u8; 32] = D::new().chain_update(padded).finalize().into(); - - Self { - state: hash, - n_rounds: 0, - #[cfg(test)] - test_state: TestState { - state_history: vec![hash], - expected_state_history: None, - }, - _marker: std::marker::PhantomData, - } - } - - fn append_bytes(&mut self, bytes: &[u8]) { - let hash: [u8; 32] = self - .hasher() - .chain_update([0x00]) // absorb domain tag - .chain_update(bytes) - .finalize() - .into(); - self.update_state(hash); - } - - fn challenge(&mut self) -> F { - let mut buf = [0u8; 16]; - self.challenge_bytes(&mut buf); - F::from_u128(u128::from_le_bytes(buf)) - } - - #[inline] - fn state(&self) -> &[u8; 32] { - &self.state - } - - #[cfg(test)] - fn compare_to(&mut self, other: &Self) { - self.test_state.expected_state_history = Some(other.test_state.state_history.clone()); - } -} diff --git a/crates/jolt-transcript/src/domain.rs b/crates/jolt-transcript/src/domain.rs index 5be57e8800..e5b707e9e1 100644 --- a/crates/jolt-transcript/src/domain.rs +++ b/crates/jolt-transcript/src/domain.rs @@ -1,119 +1,65 @@ -//! Domain-separation helpers matching jolt-core's labeled transcript encoding. +//! Spongefish domain-separator wiring used by both the new split-trait +//! surface and the source-compatible facade. //! -//! These types implement [`AppendToTranscript`] and reproduce the exact byte -//! patterns that jolt-core absorbs before payload data: -//! -//! - [`Label`] — 32-byte zero-padded label word (matches `raw_append_label`) -//! - [`LabelWithCount`] — 24-byte label + 8-byte BE count (matches `raw_append_label_with_len`) -//! - [`U64Word`] — 24 zero bytes + 8-byte BE u64 (matches `raw_append_u64`) - -use crate::transcript::{AppendToTranscript, Transcript}; - -/// 32-byte zero-padded label word. -/// -/// Matches jolt-core's `raw_append_label(label)`: the label bytes are placed -/// at the start of a 32-byte buffer, with the remainder zero-filled. -/// -/// # Panics -/// -/// Panics if the label exceeds 32 bytes. Silent truncation would allow two -/// distinct labels sharing a 32-byte prefix to collide in Fiat-Shamir. -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); +//! All three sponges in this crate use byte units (`H::U = u8`); the protocol +//! identifier is fixed crate-wide. Per-construction disambiguation is carried +//! by the spongefish session value (e.g. the legacy `Transcript::new(label)` +//! is mapped to `session(label)`). + +use rand::rngs::StdRng; +use spongefish::{DomainSeparator, DuplexSpongeInterface, Encoding, ProverState, VerifierState}; + +use crate::codec::BytesMsg; + +/// Crate-wide spongefish protocol identifier (ASCII left, zero-padded to +/// 64 bytes). +pub const PROTOCOL_ID: [u8; 64] = pad_id(b"a16z/jolt-transcript/spongefish/v1"); + +const fn pad_id(src: &[u8]) -> [u8; 64] { + assert!(src.len() <= 64, "protocol id exceeds 64 bytes"); + let mut buf = [0u8; 64]; + let mut i = 0; + while i < src.len() { + buf[i] = src[i]; + i += 1; } + buf } -/// Packed label (24 bytes) + count (8 bytes BE) in one 32-byte word. -/// -/// Matches jolt-core's `raw_append_label_with_len(label, count)`: the label -/// occupies bytes `[0..24)` and the count is big-endian in `[24..32)`. -/// -/// # Panics -/// -/// Panics if the label exceeds 24 bytes. Silent truncation would allow two -/// distinct labels sharing a 24-byte prefix to collide in Fiat-Shamir. -pub struct LabelWithCount(pub &'static [u8], pub u64); +/// Empty `instance` value so the spongefish builder reaches the `to_prover` +/// stage. Encodes to zero bytes. +#[derive(Clone, Copy, Debug, Default)] +pub struct EmptyInstance; -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); +impl Encoding<[u8]> for EmptyInstance { + fn encode(&self) -> impl AsRef<[u8]> { + [0u8; 0] } } -/// EVM-compatible left-padded u64: 24 zero bytes + 8-byte BE value. -/// -/// Matches jolt-core's `raw_append_u64(x)`. -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); - } +/// Builds a fresh `ProverState` over `sponge` for the given session bytes. +pub fn to_prover(sponge: H, session: &[u8]) -> ProverState +where + H: DuplexSpongeInterface, +{ + DomainSeparator::new(PROTOCOL_ID) + .session(BytesMsg(session.to_vec())) + .instance(EmptyInstance) + .to_prover(sponge) } -#[cfg(test)] -mod tests { - use super::*; - use crate::Blake2bTranscript; - use jolt_field::Fr; - - #[test] - fn label_pads_to_32_bytes() { - let mut t1 = Blake2bTranscript::::new(b"test"); - t1.append(&Label(b"hello")); - - let mut t2 = Blake2bTranscript::::new(b"test"); - let mut buf = [0u8; 32]; - buf[..5].copy_from_slice(b"hello"); - t2.append_bytes(&buf); - - assert_eq!(t1.state(), t2.state()); - } - - #[test] - fn label_with_count_packs_correctly() { - let mut t1 = Blake2bTranscript::::new(b"test"); - t1.append(&LabelWithCount(b"sumcheck_poly", 5)); - - let mut t2 = Blake2bTranscript::::new(b"test"); - let mut buf = [0u8; 32]; - buf[..13].copy_from_slice(b"sumcheck_poly"); - buf[24..32].copy_from_slice(&5u64.to_be_bytes()); - t2.append_bytes(&buf); - - assert_eq!(t1.state(), t2.state()); - } - - #[test] - fn u64_word_left_pads() { - let mut t1 = Blake2bTranscript::::new(b"test"); - t1.append(&U64Word(42)); - - let mut t2 = Blake2bTranscript::::new(b"test"); - let mut buf = [0u8; 32]; - buf[24..].copy_from_slice(&42u64.to_be_bytes()); - t2.append_bytes(&buf); - - assert_eq!(t1.state(), t2.state()); - } +/// Builds a fresh `VerifierState` over `sponge` for the given session bytes +/// and NARG. +pub fn to_verifier<'a, H>( + sponge: H, + session: &[u8], + narg: &'a [u8], +) -> VerifierState<'a, H> +where + H: DuplexSpongeInterface, +{ + DomainSeparator::new(PROTOCOL_ID) + .session(BytesMsg(session.to_vec())) + .instance(EmptyInstance) + .to_verifier(sponge, narg) } diff --git a/crates/jolt-transcript/src/keccak.rs b/crates/jolt-transcript/src/keccak.rs deleted file mode 100644 index 9449baedc2..0000000000 --- a/crates/jolt-transcript/src/keccak.rs +++ /dev/null @@ -1,8 +0,0 @@ -//! Keccak-256 based Fiat-Shamir transcript (Ethereum/EVM compatible). - -use sha3::Keccak256; - -use crate::digest::DigestTranscript; - -/// Fiat-Shamir transcript backed by Keccak-256. -pub type KeccakTranscript = DigestTranscript; diff --git a/crates/jolt-transcript/src/lib.rs b/crates/jolt-transcript/src/lib.rs index 1f33ab316b..d384e18e0f 100644 --- a/crates/jolt-transcript/src/lib.rs +++ b/crates/jolt-transcript/src/lib.rs @@ -1,63 +1,51 @@ -//! Fiat-Shamir transcript implementations for [Jolt](https://github.com/a16z/jolt). -//! -//! This crate provides hash-based Fiat-Shamir transcripts that convert -//! interactive proof protocols into non-interactive ones. The transcript -//! maintains a 256-bit running state, absorbs prover messages via hashing, -//! and squeezes deterministic challenges for the verifier. -//! -//! # Traits -//! -//! - [`Transcript`]: Main transcript trait — `new(label)`, `append_bytes(bytes)`, -//! `append(value)`, `challenge()`, `challenge_vector(len)`, `state()`. -//! - [`AppendToTranscript`]: For types that can be absorbed into a transcript. -//! -//! # Implementations -//! -//! Three hash backends are provided. All produce 128-bit challenges (drawn -//! from `u128`) and use a `state || round_counter` domain separation scheme. -//! -//! - [`Blake2bTranscript`]: Uses Blake2b-256. Default choice for Jolt proofs. -//! - [`KeccakTranscript`]: Uses Keccak-256. EVM-compatible for on-chain verification. -//! - [`PoseidonTranscript`]: Uses Poseidon over BN254. SNARK-friendly for recursive verification. -//! -//! # Dependency position -//! -//! Depends on `jolt-field` (for the blanket [`AppendToTranscript`] impl on -//! [`Field`](jolt_field::Field) types). Used by `jolt-crypto`, `jolt-sumcheck`, -//! `jolt-openings`, `jolt-dory`, `jolt-blindfold`, and `jolt-zkvm`. -//! -//! # Example -//! -//! ``` -//! use jolt_transcript::{Transcript, Blake2bTranscript}; -//! use jolt_field::{Field, Fr}; -//! -//! let mut transcript = Blake2bTranscript::::new(b"my_protocol"); -//! -//! // Absorb field elements using append (AppendToTranscript) -//! let value = Fr::from_u64(42); -//! transcript.append(&value); -//! -//! // Absorb raw bytes directly -//! transcript.append_bytes(b"raw bytes"); -//! -//! // Squeeze a challenge — returns Fr directly -//! let challenge: Fr = transcript.challenge(); -//! ``` +//! 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. +//! +//! Three sponges feature-gated: `transcript-blake2b` (spongefish +//! `Blake2b512`), `transcript-keccak` (spongefish `Keccak`), +//! `transcript-poseidon` (local Circom-compatible BN254 [`PoseidonSponge`]). #![deny(missing_docs)] -mod blake2b; -mod blanket; -mod digest; -pub mod domain; -mod keccak; +mod codec; +mod compat; +mod domain; +#[cfg(feature = "transcript-poseidon")] mod poseidon; -mod transcript; +mod prover; +mod verifier; + +pub use codec::{BytesMsg, FieldEl, FieldElOptimized}; +pub use compat::{ + AppendToTranscript, Label, LabelWithCount, SpongeTranscript, Transcript, U64Word, + MAX_LABEL_LEN, +}; +pub use domain::{to_prover, to_verifier, EmptyInstance, PROTOCOL_ID}; +#[cfg(feature = "transcript-poseidon")] +pub use poseidon::PoseidonSponge; +pub use prover::{OptimizedChallenge, ProverTranscript}; +pub use verifier::VerifierTranscript; + +/// Fiat-Shamir transcript backed by Blake2b-512 (spongefish duplex sponge). +#[cfg(feature = "transcript-blake2b")] +pub type Blake2bTranscript = + SpongeTranscript; + +/// Fiat-Shamir transcript backed by Keccak-f1600 (spongefish duplex sponge). +#[cfg(feature = "transcript-keccak")] +pub type KeccakTranscript = + SpongeTranscript; -pub use blake2b::Blake2bTranscript; -pub use digest::DigestTranscript; -pub use domain::{Label, LabelWithCount, U64Word}; -pub use keccak::KeccakTranscript; -pub use poseidon::PoseidonTranscript; -pub use transcript::{AppendToTranscript, Transcript}; +/// Fiat-Shamir transcript backed by Circom-compatible BN254 Poseidon. +#[cfg(feature = "transcript-poseidon")] +pub type PoseidonTranscript = SpongeTranscript; diff --git a/crates/jolt-transcript/src/poseidon.rs b/crates/jolt-transcript/src/poseidon.rs index 48205fa984..71339c160c 100644 --- a/crates/jolt-transcript/src/poseidon.rs +++ b/crates/jolt-transcript/src/poseidon.rs @@ -1,376 +1,210 @@ -//! Poseidon-based Fiat-Shamir transcript for SNARK-friendly verification. +//! `PoseidonSponge` — Circom-compatible BN254 Poseidon adapter exposed +//! through `spongefish::DuplexSpongeInterface`. //! -//! Uses 3-input Poseidon (width-4 permutation: 3 inputs + 1 capacity element) -//! over BN254 Fr with circom-compatible parameters via [`light_poseidon`]. -//! Each hash operation: `state = poseidon(state, n_rounds, data)`. +//! 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. //! -//! # Why Poseidon? +//! 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. //! -//! Poseidon is ~600x cheaper in-circuit than Keccak (~250 constraints vs -//! ~150,000). When the Jolt verifier runs inside a Groth16/gnark circuit, -//! all Fiat-Shamir challenges must be recomputed — using a SNARK-friendly -//! hash makes this feasible. -//! -//! # Parameters -//! -//! - **Inputs**: 3 field elements per hash (state, round counter, data) -//! - **Permutation width**: 4 (3 inputs + 1 capacity) -//! - **Curve**: BN254 scalar field (Fr) -//! - **Constants**: circom-compatible (`light_poseidon::new_circom`) -//! - **Rounds**: 8 full + 56 partial, x^5 S-box -//! -//! # Domain separation -//! -//! Each `append_bytes` call includes an `n_rounds` counter in the hash input -//! for domain separation. Multi-chunk appends chain: first chunk includes -//! `n_rounds`, remaining chunks chain as `poseidon(prev, i+1, chunk_i)`. -//! A final length-mixing step `poseidon(prev, 0, byte_len)` disambiguates -//! inputs that differ only in trailing zero bytes. +//! Round constants are built once per `PoseidonSponge` construction; the +//! same `Poseidon` is reused for every `permute` call in this sponge's +//! lifetime (`light_poseidon::Poseidon::hash` clears its scratch state on +//! exit, so reuse is safe). use ark_bn254::Fr; -use ark_ff::{PrimeField, Zero}; -use ark_serialize::CanonicalSerialize; +use ark_ff::{BigInteger, PrimeField, Zero}; use light_poseidon::{Poseidon, PoseidonHasher}; +use spongefish::DuplexSpongeInterface; -use crate::transcript::Transcript; - -/// Number of Poseidon inputs per hash call. -const NR_INPUTS: usize = 3; +const SQUEEZE_BYTES: usize = 32; +const ABSORB_CHUNK_BYTES: usize = 31; -/// Bytes per BN254 Fr field element. -const BYTES_PER_CHUNK: usize = 32; +#[expect( + clippy::expect_used, + reason = "width 4 (NR_INPUTS=3) is supported by light-poseidon's Circom params" +)] +fn fresh_hasher() -> Poseidon { + Poseidon::::new_circom(3).expect("light-poseidon: width-4 init") +} -/// Fiat-Shamir transcript using Poseidon hash over BN254. -/// -/// Generic over the field type `F`. Challenges are produced as field -/// elements directly via `F::from_u128()`. -pub struct PoseidonTranscript { - /// 256-bit running state (canonical LE serialization of Fr). - state: [u8; 32], - /// Round counter for domain separation. - n_rounds: u32, - /// Cached Poseidon instance — round constants allocated once. - poseidon: Poseidon, - /// Test-only state history for transcript comparison. - #[cfg(test)] - state_history: Vec<[u8; 32]>, - #[cfg(test)] - expected_state_history: Option>, - _field: std::marker::PhantomData, +/// Width-4 Poseidon duplex sponge over BN254 `Fr`, byte-driven. +pub struct PoseidonSponge { + hasher: Poseidon, + state: Fr, + pending_squeeze: [u8; SQUEEZE_BYTES], + squeeze_offset: usize, } -impl Clone for PoseidonTranscript { - #[expect(clippy::expect_used)] - fn clone(&self) -> Self { +impl PoseidonSponge { + /// Construct a fresh sponge with zero state. + pub fn new() -> Self { Self { - state: self.state, - n_rounds: self.n_rounds, - poseidon: Poseidon::::new_circom(NR_INPUTS).expect("Poseidon init failed"), - #[cfg(test)] - state_history: self.state_history.clone(), - #[cfg(test)] - expected_state_history: self.expected_state_history.clone(), - _field: std::marker::PhantomData, + hasher: fresh_hasher(), + state: Fr::zero(), + pending_squeeze: [0u8; SQUEEZE_BYTES], + squeeze_offset: SQUEEZE_BYTES, } } -} -impl Default for PoseidonTranscript { - fn default() -> Self { - Self::new(b"") + /// One Poseidon application over the current state plus two `Fr` rate + /// inputs. + fn permute(&mut self, a: Fr, b: Fr) { + #[expect( + clippy::expect_used, + reason = "input length matches hasher width-1; failure is unreachable" + )] + let next = self + .hasher + .hash(&[self.state, a, b]) + .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 std::fmt::Debug for PoseidonTranscript { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("PoseidonTranscript") - .field("state", &format_args!("{:02x?}", self.state)) - .field("n_rounds", &self.n_rounds) - .finish_non_exhaustive() +impl Default for PoseidonSponge { + fn default() -> Self { + Self::new() } } -impl PoseidonTranscript { - /// Squeeze exactly 32 challenge bytes: `poseidon(state, n_rounds, 0)`. - #[expect(clippy::expect_used)] - fn challenge_bytes32(&mut self, out: &mut [u8; 32]) { - let state_f = Fr::from_le_bytes_mod_order(&self.state); - // Odd round tag = squeeze operation - let round_f = Fr::from(u64::from(self.n_rounds) * 2 + 1); - - let output = self - .poseidon - .hash(&[state_f, round_f, Fr::zero()]) - .expect("Poseidon hash failed"); - - output - .serialize_uncompressed(&mut out[..]) - .expect("Fr serialization failed"); - - self.update_state(*out); - } - - /// Fill `out` with challenge bytes using ceil(len / 32) hash invocations. - fn challenge_bytes(&mut self, out: &mut [u8]) { - let mut remaining = out.len(); - let mut offset = 0; - - while remaining > BYTES_PER_CHUNK { - let mut chunk = [0u8; 32]; - self.challenge_bytes32(&mut chunk); - out[offset..offset + BYTES_PER_CHUNK].copy_from_slice(&chunk); - offset += BYTES_PER_CHUNK; - remaining -= BYTES_PER_CHUNK; +impl Clone for PoseidonSponge { + fn clone(&self) -> Self { + Self { + hasher: fresh_hasher(), + state: self.state, + pending_squeeze: self.pending_squeeze, + squeeze_offset: self.squeeze_offset, } - - let mut final_chunk = [0u8; 32]; - self.challenge_bytes32(&mut final_chunk); - out[offset..offset + remaining].copy_from_slice(&final_chunk[..remaining]); } +} - fn update_state(&mut self, new_state: [u8; 32]) { - self.state = new_state; - self.n_rounds += 1; - - #[cfg(test)] - { - if let Some(ref expected) = self.expected_state_history { - assert!( - (self.n_rounds as usize) < expected.len(), - "Fiat-Shamir transcript: n_rounds {} exceeds expected history length {}", - self.n_rounds, - expected.len() - ); - assert_eq!( - new_state, expected[self.n_rounds as usize], - "Fiat-Shamir transcript mismatch at round {}", - self.n_rounds - ); - } - self.state_history.push(new_state); - } +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 Transcript for PoseidonTranscript { - type Challenge = F; - - #[expect(clippy::expect_used)] - fn new(label: &'static [u8]) -> Self { - use crate::transcript::MAX_LABEL_LEN; - assert!( - label.len() <= MAX_LABEL_LEN, - "label must be at most {MAX_LABEL_LEN} bytes", - ); - - let mut poseidon = Poseidon::::new_circom(NR_INPUTS).expect("Poseidon init failed"); - let label_f = Fr::from_le_bytes_mod_order(label); - let zero = Fr::zero(); - - let initial = poseidon - .hash(&[label_f, zero, zero]) - .expect("Poseidon hash failed"); - - let mut state = [0u8; 32]; - initial - .serialize_uncompressed(&mut state[..]) - .expect("Fr serialization failed"); - - Self { - state, - n_rounds: 0, - poseidon, - #[cfg(test)] - state_history: vec![state], - #[cfg(test)] - expected_state_history: None, - _field: std::marker::PhantomData, +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); } + self } - #[expect(clippy::expect_used)] - fn append_bytes(&mut self, bytes: &[u8]) { - let state_f = Fr::from_le_bytes_mod_order(&self.state); - // Even round tag = absorb operation - let round_f = Fr::from(u64::from(self.n_rounds) * 2); - - let mut chunks = bytes.chunks(BYTES_PER_CHUNK); - - let first_f = chunks - .next() - .map_or(Fr::zero(), Fr::from_le_bytes_mod_order); - - let mut current = self - .poseidon - .hash(&[state_f, round_f, first_f]) - .expect("Poseidon hash failed"); - - for (i, chunk) in chunks.enumerate() { - let chunk_f = Fr::from_le_bytes_mod_order(chunk); - let continuation_idx = Fr::from((i + 1) as u64); - current = self - .poseidon - .hash(&[current, continuation_idx, chunk_f]) - .expect("Poseidon hash failed"); + 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; } - - // Mix in byte length to disambiguate inputs that differ only in - // trailing zeros within a chunk (Fr::from_le_bytes_mod_order - // implicitly zero-pads short slices). - let len_f = Fr::from(bytes.len() as u64); - current = self - .poseidon - .hash(&[current, Fr::zero(), len_f]) - .expect("Poseidon hash failed"); - - let mut new_state = [0u8; 32]; - current - .serialize_uncompressed(&mut new_state[..]) - .expect("Fr serialization failed"); - - self.update_state(new_state); - } - - fn challenge(&mut self) -> F { - let mut buf = [0u8; 16]; - self.challenge_bytes(&mut buf); - F::from_u128(u128::from_le_bytes(buf)) + self } - #[inline] - fn state(&self) -> &[u8; 32] { - &self.state - } - - #[cfg(test)] - fn compare_to(&mut self, other: &Self) { - self.expected_state_history = Some(other.state_history.clone()); + 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 } } #[cfg(test)] -#[expect(clippy::expect_used)] +#[expect( + unused_results, + reason = "DuplexSpongeInterface methods return &mut Self for chaining" +)] mod tests { use super::*; - type Poseidon = PoseidonTranscript; - - #[test] - fn new_initializes_from_label() { - let t1 = Poseidon::new(b"protocol_a"); - let t2 = Poseidon::new(b"protocol_b"); - - assert_ne!(t1.state, t2.state); - assert_eq!(t1.n_rounds, 0); - } - - #[test] - fn same_label_same_state() { - let t1 = Poseidon::new(b"same"); - let t2 = Poseidon::new(b"same"); - - assert_eq!(t1.state, t2.state); - } - - #[test] - fn append_changes_state() { - let mut t = Poseidon::new(b"test"); - let before = t.state; - - t.append_bytes(b"hello"); - assert_ne!(t.state, before); - assert_eq!(t.n_rounds, 1); - } - - #[test] - fn append_order_matters() { - let mut t1 = Poseidon::new(b"test"); - let mut t2 = Poseidon::new(b"test"); - - t1.append_bytes(b"a"); - t1.append_bytes(b"b"); - - t2.append_bytes(b"b"); - t2.append_bytes(b"a"); - - assert_ne!(t1.state, t2.state); - } - - #[test] - fn challenge_advances_state() { - let mut t = Poseidon::new(b"test"); - t.append_bytes(b"data"); - let before = t.state; - - let _ = t.challenge(); - assert_ne!(t.state, before); - } - - #[test] - fn deterministic_challenges() { - let mut t1 = Poseidon::new(b"test"); - let mut t2 = Poseidon::new(b"test"); - - t1.append_bytes(b"same_data"); - t2.append_bytes(b"same_data"); - - assert_eq!(t1.challenge(), t2.challenge()); - } - - #[test] - fn multi_chunk_append() { - let mut t = Poseidon::new(b"test"); - - let data = [0xABu8; 64]; - t.append_bytes(&data); - - assert_eq!(t.n_rounds, 1); - } - #[test] - fn challenge_vector_produces_distinct() { - let mut t = Poseidon::new(b"test"); - t.append_bytes(b"seed"); - - let challenges: Vec = t.challenge_vector(5); - for i in 0..5 { - for j in (i + 1)..5 { - assert_ne!(challenges[i], challenges[j]); - } - } + 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); } #[test] - fn clone_independence() { - let mut t = Poseidon::new(b"test"); - t.append_bytes(b"shared"); - - let mut fork = t.clone(); - t.append_bytes(b"branch_a"); - fork.append_bytes(b"branch_b"); - - assert_ne!(t.state, fork.state); + 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); } #[test] - fn hash_zeros_produces_known_output() { - let mut hasher = - light_poseidon::Poseidon::::new_circom(NR_INPUTS).expect("Poseidon init failed"); - let result = hasher - .hash(&[Fr::zero(), Fr::zero(), Fr::zero()]) - .expect("hash failed"); - assert_ne!(result, Fr::zero(), "hash(0,0,0) should not be zero"); - } - - #[test] - fn transcript_comparison() { - let mut prover = Poseidon::new(b"test"); - prover.append_bytes(b"data"); - let _ = prover.challenge(); - - let mut verifier = Poseidon::new(b"test"); - verifier.compare_to(&prover); - verifier.append_bytes(b"data"); - let _ = verifier.challenge(); + fn empty_distinct_from_zero_absorb() { + 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); } } diff --git a/crates/jolt-transcript/src/prover.rs b/crates/jolt-transcript/src/prover.rs new file mode 100644 index 0000000000..b9078adf2c --- /dev/null +++ b/crates/jolt-transcript/src/prover.rs @@ -0,0 +1,81 @@ +//! Spongefish-native [`ProverTranscript`] surface. +//! +//! Implemented directly on `spongefish::ProverState` via the orphan +//! rule. Methods are positional, matching spongefish-native usage +//! (WhiR, sigma-rs). + +use ark_bn254::Fr; +use rand::{CryptoRng, RngCore}; +use spongefish::{ + Decoding, DuplexSpongeInterface, Encoding, NargSerialize, ProverState, +}; + +use crate::codec::FieldElOptimized; + +/// Prover-side spongefish transcript. +/// +/// `H::U` is the sponge alphabet (`u8` for every sponge in this crate). +pub trait ProverTranscript { + /// Absorbs `msg` symmetrically with the verifier; emits no NARG bytes. + fn public_message + ?Sized>(&mut self, msg: &T); + + /// Absorbs `msg` and appends its NARG-serialized form for verifier replay. + fn prover_message + NargSerialize + ?Sized>(&mut self, msg: &T); + + /// Squeezes a verifier challenge. + fn verifier_message>(&mut self) -> T; + + /// Bytes accumulated in the NARG so far. + fn narg_string(&self) -> &[u8]; +} + +impl ProverTranscript for ProverState +where + H: DuplexSpongeInterface, + R: RngCore + CryptoRng, +{ + fn public_message + ?Sized>(&mut self, msg: &T) { + ProverState::public_message(self, msg); + } + + fn prover_message + NargSerialize + ?Sized>(&mut self, msg: &T) { + ProverState::prover_message(self, msg); + } + + fn verifier_message>(&mut self) -> T { + ProverState::verifier_message::(self) + } + + fn narg_string(&self) -> &[u8] { + ProverState::narg_string(self) + } +} + +/// 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. +pub trait OptimizedChallenge { + /// Squeezes a 128-bit-truncated challenge as an [`Fr`]. + fn challenge_128(&mut self) -> Fr; +} + +#[cfg(feature = "transcript-blake2b")] +impl OptimizedChallenge for ProverState +where + R: RngCore + CryptoRng, +{ + fn challenge_128(&mut self) -> Fr { + ProverState::verifier_message::(self).0 + } +} + +#[cfg(feature = "transcript-keccak")] +impl OptimizedChallenge for ProverState +where + R: RngCore + CryptoRng, +{ + fn challenge_128(&mut self) -> Fr { + ProverState::verifier_message::(self).0 + } +} diff --git a/crates/jolt-transcript/src/transcript.rs b/crates/jolt-transcript/src/transcript.rs deleted file mode 100644 index cc4d9580cd..0000000000 --- a/crates/jolt-transcript/src/transcript.rs +++ /dev/null @@ -1,81 +0,0 @@ -//! Core traits for Fiat-Shamir transcript transformation. -//! -//! This module provides the [`Transcript`] trait for building Fiat-Shamir transcripts -//! and the [`AppendToTranscript`] trait for types that can be absorbed into a transcript. - -/// 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, -/// transforming an interactive proof into a non-interactive one. -/// -/// Hash-based transcripts (`Blake2bTranscript`, `KeccakTranscript`) are generic -/// over `F: Field` and produce field-element challenges directly. -/// -/// # Security -/// -/// Domain separation is provided via the label in [`new`](Transcript::new). -/// Use unique labels per protocol to prevent cross-protocol attacks. -pub trait Transcript: Default + Clone + Sync + Send + 'static { - /// The challenge type produced by this transcript. - /// - /// For hash-based transcripts this is `F` (the field type), so challenges - /// can be used directly in polynomial operations without conversion. - type Challenge: Copy + Default + PartialEq + Eq + std::fmt::Debug + std::hash::Hash; - - /// Creates a new transcript with the given domain separation label. - /// - /// # Panics - /// - /// Panics if `label.len() > 32`. - fn new(label: &'static [u8]) -> Self; - - /// Absorbs raw bytes into the transcript. - /// - /// Prefer [`append`](Transcript::append) - /// for a type-safe/ergonomic absorption of data. - fn append_bytes(&mut self, bytes: &[u8]); - - /// Absorbs a value into the transcript. - /// - /// This is the primary method for adding data to the transcript. Any type - /// implementing [`AppendToTranscript`] can be absorbed. - fn append(&mut self, value: &A) { - value.append_to_transcript(self); - } - - /// Squeezes a challenge from the transcript. - /// - /// Each call produces a new challenge and advances the transcript state. - #[must_use] - fn challenge(&mut self) -> Self::Challenge; - - /// Squeezes multiple challenges from the transcript. - #[must_use] - fn challenge_vector(&mut self, len: usize) -> Vec { - (0..len).map(|_| self.challenge()).collect() - } - - /// Returns the current 256-bit transcript state. - /// - /// Useful for debugging and testing transcript synchronization. - #[must_use] - fn state(&self) -> &[u8; 32]; - - /// Enables transcript comparison for testing. - /// - /// After calling this, the transcript will panic if its state ever diverges - /// from the expected state history recorded in `other`. - #[cfg(test)] - fn compare_to(&mut self, other: &Self); -} - -/// Maximum label length in bytes. Labels are padded to this size before hashing. -pub const MAX_LABEL_LEN: usize = 32; - -/// Implement this trait to define how your type serializes into transcript bytes. -/// This keeps the [`Transcript`] trait decoupled from specific serialization formats. -pub trait AppendToTranscript { - /// Absorbs this value into the transcript. - fn append_to_transcript(&self, transcript: &mut T); -} diff --git a/crates/jolt-transcript/src/verifier.rs b/crates/jolt-transcript/src/verifier.rs new file mode 100644 index 0000000000..c2ec23351b --- /dev/null +++ b/crates/jolt-transcript/src/verifier.rs @@ -0,0 +1,59 @@ +//! Spongefish-native [`VerifierTranscript`] surface. + +use ark_bn254::Fr; +use spongefish::{ + Decoding, DuplexSpongeInterface, Encoding, NargDeserialize, VerificationResult, VerifierState, +}; + +use crate::codec::FieldElOptimized; +use crate::prover::OptimizedChallenge; + +/// Verifier-side spongefish transcript. +pub trait VerifierTranscript { + /// Absorbs `msg` symmetrically with the prover. + fn public_message + ?Sized>(&mut self, msg: &T); + + /// Reads a prover message from the NARG, absorbing it into the sponge. + fn prover_message + NargDeserialize>(&mut self) -> VerificationResult; + + /// Squeezes a verifier challenge. + fn verifier_message>(&mut self) -> T; + + /// Asserts the NARG was fully consumed. + fn check_eof(self) -> VerificationResult<()>; +} + +impl VerifierTranscript for VerifierState<'_, H> +where + H: DuplexSpongeInterface, +{ + fn public_message + ?Sized>(&mut self, msg: &T) { + VerifierState::public_message(self, msg); + } + + fn prover_message + NargDeserialize>(&mut self) -> VerificationResult { + VerifierState::prover_message::(self) + } + + fn verifier_message>(&mut self) -> T { + VerifierState::verifier_message::(self) + } + + fn check_eof(self) -> VerificationResult<()> { + VerifierState::check_eof(self) + } +} + +#[cfg(feature = "transcript-blake2b")] +impl OptimizedChallenge for VerifierState<'_, spongefish::instantiations::Blake2b512> { + fn challenge_128(&mut self) -> Fr { + VerifierState::verifier_message::(self).0 + } +} + +#[cfg(feature = "transcript-keccak")] +impl OptimizedChallenge for VerifierState<'_, spongefish::instantiations::Keccak> { + fn challenge_128(&mut self) -> Fr { + VerifierState::verifier_message::(self).0 + } +} diff --git a/crates/jolt-transcript/tests/blake2b_tests.rs b/crates/jolt-transcript/tests/blake2b_tests.rs index 3dba55874c..280b83eac8 100644 --- a/crates/jolt-transcript/tests/blake2b_tests.rs +++ b/crates/jolt-transcript/tests/blake2b_tests.rs @@ -4,7 +4,6 @@ mod common; use jolt_field::Fr; use jolt_transcript::Blake2bTranscript; -use num_traits::Zero; type B2b = Blake2bTranscript; @@ -12,30 +11,23 @@ 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(); - assert!(!challenge.is_zero()); - - let mut transcript2 = Blake2bTranscript::::new(b"Jolt"); - transcript2.append_bytes(&12345u64.to_be_bytes()); - assert_eq!(challenge, transcript2.challenge()); -} - -#[test] -fn test_blake2b_state_accessor() { - use jolt_transcript::Transcript; - - let transcript = Blake2bTranscript::::new(b"test"); - let state = transcript.state(); - - assert_eq!(state.len(), 32); - - assert!(!state.iter().all(|&b| b == 0)); + // 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. + let expected: ark_bn254::Fr = ark_bn254::Fr::from_le_bytes_mod_order(&[ + 0x6B, 0xAE, 0x98, 0xBB, 0x70, 0x31, 0xDE, 0xEA, 0x8B, 0x57, 0x22, 0xB0, 0x0F, 0xC5, 0x83, + 0x62, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + ]); + let got: ark_bn254::Fr = challenge.into(); + assert_eq!(got, expected, "Blake2b known-vector regression"); } #[test] diff --git a/crates/jolt-transcript/tests/common/mod.rs b/crates/jolt-transcript/tests/common/mod.rs index 5ff6cc303e..bb9f5ff97c 100644 --- a/crates/jolt-transcript/tests/common/mod.rs +++ b/crates/jolt-transcript/tests/common/mod.rs @@ -2,60 +2,45 @@ /// Standardized test suite macro for any `Transcript` implementation. /// -/// This macro generates a comprehensive test suite that verifies the core -/// properties required of any Fiat-Shamir transcript implementation: -/// -/// - Determinism: Same inputs produce same outputs -/// - Domain separation: Different labels produce different transcripts -/// - Challenge uniqueness: Sequential challenges are unique -/// - State mutation: Appending data changes the state -/// - Prover/verifier consistency: Both sides derive identical challenges +/// 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 mut t1 = <$transcript_type>::new(b"determinism_test"); - let mut t2 = <$transcript_type>::new(b"determinism_test"); - - t1.append_bytes(&42u64.to_be_bytes()); - t2.append_bytes(&42u64.to_be_bytes()); - assert_eq!( - t1.state(), - t2.state(), - "States should match after identical operations" - ); - - t1.append_bytes(b"hello world"); - t2.append_bytes(b"hello world"); - assert_eq!(t1.state(), t2.state()); - + 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!( - t1.challenge(), - t2.challenge(), - "Challenges should be identical for identical transcripts" + c1, c2, + "Identical operations must yield identical challenges" ); } #[test] fn test_domain_separation() { - let mut t1 = <$transcript_type>::new(b"protocol_a"); - let mut t2 = <$transcript_type>::new(b"protocol_b"); - - assert_ne!( - t1.state(), - t2.state(), - "Different labels should produce different initial states" - ); - - assert_ne!( - t1.challenge(), - t2.challenge(), - "Different labels should produce different challenges" - ); + 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] @@ -74,86 +59,55 @@ macro_rules! transcript_tests { #[test] fn test_append_changes_state() { - let mut transcript = <$transcript_type>::new(b"mutation_test"); - let initial_state = *transcript.state(); - - transcript.append_bytes(&1u64.to_be_bytes()); - assert_ne!( - *transcript.state(), - initial_state, - "append should change state" - ); - - let state_after_append = *transcript.state(); - transcript.append_bytes(b"test"); - assert_ne!( - *transcript.state(), - state_after_append, - "append_bytes should change state" - ); - } - - #[test] - fn test_challenge_changes_state() { - let mut transcript = <$transcript_type>::new(b"challenge_mutation"); - let initial_state = *transcript.state(); - - let _ = transcript.challenge(); + 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!( - *transcript.state(), - initial_state, - "challenge should change state" + baseline, after_append, + "append must change observable challenge" ); } #[test] fn test_order_matters() { - let mut t1 = <$transcript_type>::new(b"order_test"); - let mut t2 = <$transcript_type>::new(b"order_test"); - - t1.append_bytes(&1u64.to_be_bytes()); - t1.append_bytes(&2u64.to_be_bytes()); - - t2.append_bytes(&2u64.to_be_bytes()); - t2.append_bytes(&1u64.to_be_bytes()); - - assert_ne!( - t1.state(), - t2.state(), - "Order of operations should affect state" - ); + 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 mut t1 = <$transcript_type>::new(b"data_test"); - let mut t2 = <$transcript_type>::new(b"data_test"); - - t1.append_bytes(&0u64.to_be_bytes()); - t2.append_bytes(&1u64.to_be_bytes()); - - assert_ne!( - t1.state(), - t2.state(), - "Different data should produce different states" - ); + 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 mut t1 = <$transcript_type>::new(b"empty_test"); - let mut t2 = <$transcript_type>::new(b"empty_test"); - let initial_state = *t1.state(); - - t1.append_bytes(&[]); + let baseline = challenge_after(b"empty_test", |_| {}); + let with_empty = challenge_after(b"empty_test", |t| { + t.append_bytes(&[]); + }); assert_ne!( - *t1.state(), - initial_state, - "Empty bytes should change state" + baseline, with_empty, + "append_bytes(&[]) must observably change challenge" ); - - t2.append_bytes(&[]); - assert_eq!(t1.state(), t2.state()); + // 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] @@ -179,7 +133,7 @@ macro_rules! transcript_tests { assert_eq!( prover_challenge, verifier_challenge, - "Prover and verifier should derive identical challenges" + "Prover and verifier must derive identical challenges" ); } @@ -189,7 +143,6 @@ macro_rules! transcript_tests { original.append_bytes(&1u64.to_be_bytes()); let mut cloned = original.clone(); - cloned.append_bytes(&2u64.to_be_bytes()); let original_challenge = original.challenge(); @@ -201,39 +154,18 @@ macro_rules! transcript_tests { assert_ne!( original_challenge, fresh_challenge, - "Clone mutation should not affect original" - ); - } - - #[test] - fn test_debug_impl() { - let transcript = <$transcript_type>::new(b"debug_test"); - let debug_str = format!("{:?}", transcript); - - assert!( - debug_str.contains("state"), - "Debug output should contain state" - ); - assert!( - debug_str.contains("n_rounds"), - "Debug output should contain n_rounds" + "Clone mutation must not affect original" ); } #[test] fn test_default_delegates_to_new() { - let default_transcript = <$transcript_type>::default(); - let new_transcript = <$transcript_type>::new(b""); - + let mut default_transcript = <$transcript_type>::default(); + let mut new_transcript = <$transcript_type>::new(b""); assert_eq!( - default_transcript.state(), - new_transcript.state(), - "Default should delegate to new(b\"\")" - ); - - assert!( - !default_transcript.state().iter().all(|&b| b == 0), - "Default should produce a non-zero initial state" + default_transcript.challenge(), + new_transcript.challenge(), + "Default must delegate to new(b\"\")" ); } @@ -247,8 +179,9 @@ macro_rules! transcript_tests { #[test] fn test_max_valid_label() { let max_label: &[u8; 32] = &[b'L'; 32]; - let transcript = <$transcript_type>::new(max_label); - assert!(!transcript.state().iter().all(|&b| b == 0)); + let mut t1 = <$transcript_type>::new(max_label); + let mut t2 = <$transcript_type>::new(max_label); + assert_eq!(t1.challenge(), t2.challenge()); } #[test] diff --git a/crates/jolt-transcript/tests/keccak_tests.rs b/crates/jolt-transcript/tests/keccak_tests.rs index 9058f8da61..ea822c72be 100644 --- a/crates/jolt-transcript/tests/keccak_tests.rs +++ b/crates/jolt-transcript/tests/keccak_tests.rs @@ -4,7 +4,6 @@ mod common; use jolt_field::Fr; use jolt_transcript::KeccakTranscript; -use num_traits::Zero; type Kec = KeccakTranscript; @@ -12,28 +11,19 @@ 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(); - assert!(!challenge.is_zero()); - - let mut transcript2 = KeccakTranscript::::new(b"Jolt"); - transcript2.append_bytes(&12345u64.to_be_bytes()); - assert_eq!(challenge, transcript2.challenge()); -} - -#[test] -fn test_keccak_state_accessor() { - use jolt_transcript::Transcript; - - let transcript = KeccakTranscript::::new(b"test"); - let state = transcript.state(); - - assert_eq!(state.len(), 32); - - assert!(!state.iter().all(|&b| b == 0)); + // Pinned wire-format check; see Blake2b counterpart for rationale. + let expected: ark_bn254::Fr = ark_bn254::Fr::from_le_bytes_mod_order(&[ + 0x1E, 0x1D, 0x14, 0x83, 0xB5, 0x56, 0xB0, 0x9C, 0x1C, 0xEC, 0x84, 0x40, 0x02, 0x78, 0x38, + 0x5F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + ]); + let got: ark_bn254::Fr = challenge.into(); + assert_eq!(got, expected, "Keccak known-vector regression"); } diff --git a/crates/jolt-transcript/tests/poseidon_tests.rs b/crates/jolt-transcript/tests/poseidon_tests.rs index f147be2d27..9f0fab91ec 100644 --- a/crates/jolt-transcript/tests/poseidon_tests.rs +++ b/crates/jolt-transcript/tests/poseidon_tests.rs @@ -8,3 +8,22 @@ 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(&[ + 0xF8, 0x63, 0xA0, 0x6D, 0xF7, 0xFC, 0xCF, 0x35, 0xC3, 0xD1, 0x85, 0x0C, 0xC1, 0x9C, 0x2D, + 0x7E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, + ]); + let got: ark_bn254::Fr = challenge.into(); + assert_eq!(got, expected, "Poseidon known-vector regression"); +} From 042f00ce0780980475f6c857b2a6c775acb849b2 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Tue, 12 May 2026 12:56:31 +0530 Subject: [PATCH 06/21] feat(jolt-eval): add transcript_prover_verifier_consistency invariant Reasons: - Provide an in-tree mechanical correctness gauge for the new jolt-transcript spongefish wiring, since jolt-core does not yet exercise the new ProverTranscript / VerifierTranscript surface and the muldiv e2e only touches the compat facade. - The differential check (prover/verifier pair driven by the same operation sequence must round-trip every prover_message and agree on every verifier_message) directly mechanises the sponge-symmetry invariant declared in the spec, and any future encoding regression in BytesMsg, FieldEl, or the DomainSeparator routing will surface here without waiting for a full e2e run. Adds three concrete Invariant structs (one per sponge) sharing an Op enum that covers PublicBytes, PublicScalar, ProverBytes, ProverScalar, and Challenge. Pattern follows SplitEqBindLowHigh / SplitEqBindHighLow since the #[invariant] macro does not support generics. Seed corpus covers empty, single-message per variant, 10-message mixed, and 1000-message mixed. Fuzz targets compile against libfuzzer-sys. Also fixes sync_targets.sh to (a) skip the macro_tests module and (b) match struct names containing digits (e.g. TranscriptConsistency Blake2bInvariant). --- jolt-eval/Cargo.toml | 6 + jolt-eval/fuzz/Cargo.toml | 29 ++ .../fuzz/fuzz_targets/field_mul_scalar.rs | 3 + .../transcript_consistency_blake2b.rs | 3 + .../transcript_consistency_keccak.rs | 3 + .../transcript_consistency_poseidon.rs | 3 + jolt-eval/src/invariant/mod.rs | 16 ++ .../src/invariant/transcript_symmetry.rs | 260 ++++++++++++++++++ jolt-eval/sync_targets.sh | 3 +- 9 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 jolt-eval/fuzz/fuzz_targets/field_mul_scalar.rs create mode 100644 jolt-eval/fuzz/fuzz_targets/transcript_consistency_blake2b.rs create mode 100644 jolt-eval/fuzz/fuzz_targets/transcript_consistency_keccak.rs create mode 100644 jolt-eval/fuzz/fuzz_targets/transcript_consistency_poseidon.rs create mode 100644 jolt-eval/src/invariant/transcript_symmetry.rs diff --git a/jolt-eval/Cargo.toml b/jolt-eval/Cargo.toml index b100b4a505..04e278eb44 100644 --- a/jolt-eval/Cargo.toml +++ b/jolt-eval/Cargo.toml @@ -6,6 +6,12 @@ edition = "2021" [dependencies] jolt-core = { workspace = true, features = ["host"] } jolt-field = { workspace = true } +jolt-transcript = { workspace = true, features = [ + "transcript-blake2b", + "transcript-keccak", + "transcript-poseidon", +] } +spongefish = { workspace = true } common = { workspace = true, features = ["std"] } tracer = { workspace = true } diff --git a/jolt-eval/fuzz/Cargo.toml b/jolt-eval/fuzz/Cargo.toml index a2cf3798a2..76c7164dbd 100644 --- a/jolt-eval/fuzz/Cargo.toml +++ b/jolt-eval/fuzz/Cargo.toml @@ -23,6 +23,13 @@ ignored = ["libfuzzer-sys"] libfuzzer-sys = "0.4" jolt-eval = { path = ".." } +[[bin]] +name = "field_mul_scalar" +path = "fuzz_targets/field_mul_scalar.rs" +test = false +doc = false +bench = false + [[bin]] name = "split_eq_bind_high_low" path = "fuzz_targets/split_eq_bind_high_low.rs" @@ -36,3 +43,25 @@ path = "fuzz_targets/split_eq_bind_low_high.rs" test = false doc = false bench = false + +[[bin]] +name = "transcript_consistency_blake2b" +path = "fuzz_targets/transcript_consistency_blake2b.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "transcript_consistency_keccak" +path = "fuzz_targets/transcript_consistency_keccak.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "transcript_consistency_poseidon" +path = "fuzz_targets/transcript_consistency_poseidon.rs" +test = false +doc = false +bench = false + diff --git a/jolt-eval/fuzz/fuzz_targets/field_mul_scalar.rs b/jolt-eval/fuzz/fuzz_targets/field_mul_scalar.rs new file mode 100644 index 0000000000..77d59d90c7 --- /dev/null +++ b/jolt-eval/fuzz/fuzz_targets/field_mul_scalar.rs @@ -0,0 +1,3 @@ +#![no_main] +use jolt_eval::invariant::field_mul_scalar::FieldMulScalarInvariant; +jolt_eval::fuzz_invariant!(FieldMulScalarInvariant::default()); diff --git a/jolt-eval/fuzz/fuzz_targets/transcript_consistency_blake2b.rs b/jolt-eval/fuzz/fuzz_targets/transcript_consistency_blake2b.rs new file mode 100644 index 0000000000..af890c5123 --- /dev/null +++ b/jolt-eval/fuzz/fuzz_targets/transcript_consistency_blake2b.rs @@ -0,0 +1,3 @@ +#![no_main] +use jolt_eval::invariant::transcript_symmetry::TranscriptConsistencyBlake2bInvariant; +jolt_eval::fuzz_invariant!(TranscriptConsistencyBlake2bInvariant::default()); diff --git a/jolt-eval/fuzz/fuzz_targets/transcript_consistency_keccak.rs b/jolt-eval/fuzz/fuzz_targets/transcript_consistency_keccak.rs new file mode 100644 index 0000000000..5fa486c131 --- /dev/null +++ b/jolt-eval/fuzz/fuzz_targets/transcript_consistency_keccak.rs @@ -0,0 +1,3 @@ +#![no_main] +use jolt_eval::invariant::transcript_symmetry::TranscriptConsistencyKeccakInvariant; +jolt_eval::fuzz_invariant!(TranscriptConsistencyKeccakInvariant::default()); diff --git a/jolt-eval/fuzz/fuzz_targets/transcript_consistency_poseidon.rs b/jolt-eval/fuzz/fuzz_targets/transcript_consistency_poseidon.rs new file mode 100644 index 0000000000..0520577cc0 --- /dev/null +++ b/jolt-eval/fuzz/fuzz_targets/transcript_consistency_poseidon.rs @@ -0,0 +1,3 @@ +#![no_main] +use jolt_eval::invariant::transcript_symmetry::TranscriptConsistencyPoseidonInvariant; +jolt_eval::fuzz_invariant!(TranscriptConsistencyPoseidonInvariant::default()); diff --git a/jolt-eval/src/invariant/mod.rs b/jolt-eval/src/invariant/mod.rs index 9fcb997390..2e40529577 100644 --- a/jolt-eval/src/invariant/mod.rs +++ b/jolt-eval/src/invariant/mod.rs @@ -4,6 +4,7 @@ mod macro_tests; pub mod soundness; pub mod split_eq_bind; pub mod synthesis; +pub mod transcript_symmetry; use std::fmt; @@ -136,6 +137,9 @@ pub enum JoltInvariants { SplitEqBindHighLow(split_eq_bind::SplitEqBindHighLowInvariant), FieldMulScalar(field_mul_scalar::FieldMulScalarInvariant), Soundness(soundness::SoundnessInvariant), + TranscriptConsistencyBlake2b(transcript_symmetry::TranscriptConsistencyBlake2bInvariant), + TranscriptConsistencyKeccak(transcript_symmetry::TranscriptConsistencyKeccakInvariant), + TranscriptConsistencyPoseidon(transcript_symmetry::TranscriptConsistencyPoseidonInvariant), } macro_rules! dispatch { @@ -145,6 +149,9 @@ macro_rules! dispatch { JoltInvariants::SplitEqBindHighLow($inv) => $body, JoltInvariants::FieldMulScalar($inv) => $body, JoltInvariants::Soundness($inv) => $body, + JoltInvariants::TranscriptConsistencyBlake2b($inv) => $body, + JoltInvariants::TranscriptConsistencyKeccak($inv) => $body, + JoltInvariants::TranscriptConsistencyPoseidon($inv) => $body, } }; } @@ -156,6 +163,15 @@ impl JoltInvariants { Self::SplitEqBindHighLow(split_eq_bind::SplitEqBindHighLowInvariant), Self::FieldMulScalar(field_mul_scalar::FieldMulScalarInvariant), Self::Soundness(soundness::SoundnessInvariant), + Self::TranscriptConsistencyBlake2b( + transcript_symmetry::TranscriptConsistencyBlake2bInvariant, + ), + Self::TranscriptConsistencyKeccak( + transcript_symmetry::TranscriptConsistencyKeccakInvariant, + ), + Self::TranscriptConsistencyPoseidon( + transcript_symmetry::TranscriptConsistencyPoseidonInvariant, + ), ] } diff --git a/jolt-eval/src/invariant/transcript_symmetry.rs b/jolt-eval/src/invariant/transcript_symmetry.rs new file mode 100644 index 0000000000..00a1fb2f21 --- /dev/null +++ b/jolt-eval/src/invariant/transcript_symmetry.rs @@ -0,0 +1,260 @@ +//! `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. + +use arbitrary::{Arbitrary, Unstructured}; +use ark_bn254::Fr; +use jolt_field::Fr as JFr; +use spongefish::instantiations::{Blake2b512, Keccak}; + +use jolt_transcript::{to_prover, to_verifier, BytesMsg, FieldEl, PoseidonSponge}; + +use crate::invariant::{CheckError, Invariant, InvariantViolation}; + +const SESSION: &[u8] = b"jolt-eval/transcript-symmetry/v1"; + +/// One operation in the prover/verifier sequence. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +pub enum Op { + /// Both sides absorb the same public bytes. + PublicBytes(Vec), + /// Both sides absorb the same public BN254 `Fr` scalar. + PublicScalar(#[schemars(with = "[u8; 32]")] JFr), + /// Prover absorbs + emits bytes; verifier reads them back from the NARG. + ProverBytes(Vec), + /// Prover absorbs + emits a BN254 `Fr` scalar; verifier reads it back. + ProverScalar(#[schemars(with = "[u8; 32]")] JFr), + /// Both sides squeeze a verifier challenge. + Challenge, +} + +/// Sequence of operations replayed in lockstep by both sides. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)] +pub struct Input { + /// Operations to apply in order. + pub ops: Vec, +} + +impl<'a> Arbitrary<'a> for Input { + fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result { + 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)?; + 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, + }); + } + Ok(Self { ops }) + } +} + +fn arb_bytes(u: &mut Unstructured<'_>) -> arbitrary::Result> { + let len = u.int_in_range(0u8..=64)? as usize; + (0..len).map(|_| u.arbitrary()).collect() +} + +fn arb_scalar(u: &mut Unstructured<'_>) -> arbitrary::Result { + let bytes: [u8; 32] = u.arbitrary()?; + Ok(JFr::from_le_bytes_mod_order(&bytes)) +} + +fn ark(f: JFr) -> Fr { + f.into() +} + +fn run_check(input: &Input, build_sponge: impl Fn() -> H) -> Result<(), CheckError> +where + H: spongefish::DuplexSpongeInterface, +{ + let mut prover = to_prover(build_sponge(), SESSION); + let mut prover_challenges: Vec = Vec::new(); + + for op in &input.ops { + match op { + Op::PublicBytes(b) => prover.public_message(&BytesMsg(b.clone())), + Op::PublicScalar(f) => prover.public_message(&FieldEl(ark(*f))), + Op::ProverBytes(b) => prover.prover_message(&BytesMsg(b.clone())), + Op::ProverScalar(f) => prover.prover_message(&FieldEl(ark(*f))), + Op::Challenge => { + let FieldEl(c) = prover.verifier_message::(); + prover_challenges.push(c); + } + } + } + + let narg: Vec = prover.narg_string().to_vec(); + let mut verifier = to_verifier(build_sponge(), SESSION, &narg); + let mut challenge_idx = 0usize; + + for (op_idx, op) in input.ops.iter().enumerate() { + match op { + Op::PublicBytes(b) => verifier.public_message(&BytesMsg(b.clone())), + Op::PublicScalar(f) => verifier.public_message(&FieldEl(ark(*f))), + Op::ProverBytes(expected) => { + let got: BytesMsg = verifier + .prover_message() + .map_err(|e| violation("prover_message", op_idx, e))?; + if got.as_slice() != expected.as_slice() { + return Err(mismatch("ProverBytes round-trip", op_idx)); + } + } + Op::ProverScalar(expected) => { + let got: FieldEl = verifier + .prover_message() + .map_err(|e| violation("prover_message", op_idx, e))?; + if got.0 != ark(*expected) { + return Err(mismatch("ProverScalar round-trip", op_idx)); + } + } + Op::Challenge => { + let FieldEl(verifier_c) = verifier.verifier_message::(); + if verifier_c != 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))?; + Ok(()) +} + +fn violation( + what: &str, + op_idx: usize, + err: spongefish::VerificationError, +) -> CheckError { + CheckError::Violation(InvariantViolation::with_details( + format!("{what} failed on verifier"), + format!("op_idx={op_idx}, err={err:?}"), + )) +} + +fn mismatch(what: &str, op_idx: usize) -> CheckError { + CheckError::Violation(InvariantViolation::with_details( + format!("{what} mismatch between prover and verifier"), + format!("op_idx={op_idx}"), + )) +} + +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 { + 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, + }); + } + + 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 }, + ] +} + +macro_rules! transcript_invariant { + ($struct:ident, $sponge:ty, $build:expr, $name:literal, $sponge_label:literal) => { + #[doc = concat!( + "Spongefish symmetry invariant for the ", + $sponge_label, + " sponge." + )] + #[jolt_eval_macros::invariant(Test, Fuzz, RedTeam)] + #[derive(Default)] + pub struct $struct; + + impl Invariant for $struct { + type Setup = (); + type Input = Input; + + fn name(&self) -> &str { + $name + } + + fn description(&self) -> String { + format!( + "spongefish ProverState/VerifierState pair ({} sponge) replaying \ + the same operation sequence must round-trip every prover message \ + and agree on every challenge.", + $sponge_label + ) + } + + fn setup(&self) {} + + fn check(&self, _setup: &(), input: Input) -> Result<(), CheckError> { + run_check::<$sponge>(&input, $build) + } + + fn seed_corpus(&self) -> Vec { + seed_corpus_shared() + } + } + }; +} + +transcript_invariant!( + TranscriptConsistencyBlake2bInvariant, + Blake2b512, + Blake2b512::default, + "transcript_prover_verifier_consistency_blake2b", + "Blake2b512" +); + +transcript_invariant!( + TranscriptConsistencyKeccakInvariant, + Keccak, + Keccak::default, + "transcript_prover_verifier_consistency_keccak", + "Keccak" +); + +transcript_invariant!( + TranscriptConsistencyPoseidonInvariant, + PoseidonSponge, + PoseidonSponge::new, + "transcript_prover_verifier_consistency_poseidon", + "Poseidon" +); diff --git a/jolt-eval/sync_targets.sh b/jolt-eval/sync_targets.sh index fc2d1ba422..8e1f9d8b3d 100755 --- a/jolt-eval/sync_targets.sh +++ b/jolt-eval/sync_targets.sh @@ -38,11 +38,12 @@ for file in "$EVAL_DIR"/src/invariant/*.rs; do [ -f "$file" ] || continue basename_rs=$(basename "$file" .rs) [ "$basename_rs" = "mod" ] && continue + [ "$basename_rs" = "macro_tests" ] && continue # Look for #[invariant(...Fuzz...)] annotations { grep -n 'invariant.*Fuzz' "$file" 2>/dev/null || true; } | while IFS=: read -r line _; do struct=$(sed -n "$((line+1)),$((line+5))p" "$file" \ - | grep -o 'pub struct [A-Za-z_]*' | head -1 | awk '{print $3}') + | grep -o 'pub struct [A-Za-z_][A-Za-z0-9_]*' | head -1 | awk '{print $3}') [ -z "$struct" ] && continue snake=$(to_snake "$struct") echo "$snake invariant::${basename_rs}::${struct}" From d387b06a01c3c5b694ee45b68a5656709f845854 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Tue, 12 May 2026 12:56:35 +0530 Subject: [PATCH 07/21] spec: mark jolt-transcript-spongefish status implemented --- specs/jolt-transcript-spongefish.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/specs/jolt-transcript-spongefish.md b/specs/jolt-transcript-spongefish.md index 60f4d97a05..6c25b5afdd 100644 --- a/specs/jolt-transcript-spongefish.md +++ b/specs/jolt-transcript-spongefish.md @@ -4,7 +4,7 @@ | --------- | --------------- | | Author(s) | @shreyas-londhe | | Created | 2026-04-17 | -| Status | proposed | +| Status | implemented | | PR | 1455 | ## Summary From 2125d8f8c5beb125e08a18c617cfe3edb9778c2c Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Tue, 12 May 2026 15:22:29 +0530 Subject: [PATCH 08/21] fix(ci): rustfmt, taplo, and pin generic-array 0.14.7 - rustfmt: post-merge files reformatted (line-wrapping diffs in lib.rs, prover.rs, setup.rs, transcript_symmetry.rs). - taplo: 2-space indentation on Cargo.toml feature lists. - Cargo.lock: pin `generic-array` to 0.14.7 (matches upstream main's lock). The fresh resolution after the upstream merge pulled in 0.14.9, whose new `as_slice` deprecation tripped clippy's `-D warnings` against jolt-inlines-p256 even though that crate is unchanged. - transcript_symmetry.rs: expand the 3-arg `transcript_invariant!` macro into three explicit impls. rustfmt cycles indentation inside `#[doc = concat!(...)]` macro arms on each run, so the macro form failed `cargo fmt --check` deterministically; hand-written impls avoid the unstable rustfmt path. --- Cargo.lock | 4 +- crates/jolt-transcript/src/lib.rs | 3 +- crates/jolt-transcript/src/prover.rs | 4 +- crates/jolt-transcript/src/setup.rs | 6 +- jolt-eval/Cargo.toml | 6 +- jolt-eval/fuzz/Cargo.toml | 1 - .../src/invariant/transcript_symmetry.rs | 149 ++++++++++-------- 7 files changed, 94 insertions(+), 79 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 24affe0c89..175cd7b9c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2238,9 +2238,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", diff --git a/crates/jolt-transcript/src/lib.rs b/crates/jolt-transcript/src/lib.rs index 5bf8a1475b..1fc5a7aec1 100644 --- a/crates/jolt-transcript/src/lib.rs +++ b/crates/jolt-transcript/src/lib.rs @@ -27,8 +27,7 @@ mod verifier; pub use codec::{BytesMsg, FieldEl, FieldElOptimized}; pub use compat::{ - AppendToTranscript, Label, LabelWithCount, SpongeTranscript, Transcript, U64Word, - MAX_LABEL_LEN, + AppendToTranscript, Label, LabelWithCount, SpongeTranscript, Transcript, U64Word, MAX_LABEL_LEN, }; pub use setup::{to_prover, to_verifier, EmptyInstance, PROTOCOL_ID}; diff --git a/crates/jolt-transcript/src/prover.rs b/crates/jolt-transcript/src/prover.rs index b9078adf2c..07d2fe2f92 100644 --- a/crates/jolt-transcript/src/prover.rs +++ b/crates/jolt-transcript/src/prover.rs @@ -6,9 +6,7 @@ use ark_bn254::Fr; use rand::{CryptoRng, RngCore}; -use spongefish::{ - Decoding, DuplexSpongeInterface, Encoding, NargSerialize, ProverState, -}; +use spongefish::{Decoding, DuplexSpongeInterface, Encoding, NargSerialize, ProverState}; use crate::codec::FieldElOptimized; diff --git a/crates/jolt-transcript/src/setup.rs b/crates/jolt-transcript/src/setup.rs index e5b707e9e1..91daa336c2 100644 --- a/crates/jolt-transcript/src/setup.rs +++ b/crates/jolt-transcript/src/setup.rs @@ -50,11 +50,7 @@ where /// Builds a fresh `VerifierState` over `sponge` for the given session bytes /// and NARG. -pub fn to_verifier<'a, H>( - sponge: H, - session: &[u8], - narg: &'a [u8], -) -> VerifierState<'a, H> +pub fn to_verifier<'a, H>(sponge: H, session: &[u8], narg: &'a [u8]) -> VerifierState<'a, H> where H: DuplexSpongeInterface, { diff --git a/jolt-eval/Cargo.toml b/jolt-eval/Cargo.toml index 04e278eb44..8deff886be 100644 --- a/jolt-eval/Cargo.toml +++ b/jolt-eval/Cargo.toml @@ -7,9 +7,9 @@ edition = "2021" jolt-core = { workspace = true, features = ["host"] } jolt-field = { workspace = true } jolt-transcript = { workspace = true, features = [ - "transcript-blake2b", - "transcript-keccak", - "transcript-poseidon", + "transcript-blake2b", + "transcript-keccak", + "transcript-poseidon", ] } spongefish = { workspace = true } common = { workspace = true, features = ["std"] } diff --git a/jolt-eval/fuzz/Cargo.toml b/jolt-eval/fuzz/Cargo.toml index 76c7164dbd..b7fa55a612 100644 --- a/jolt-eval/fuzz/Cargo.toml +++ b/jolt-eval/fuzz/Cargo.toml @@ -64,4 +64,3 @@ path = "fuzz_targets/transcript_consistency_poseidon.rs" test = false doc = false bench = false - diff --git a/jolt-eval/src/invariant/transcript_symmetry.rs b/jolt-eval/src/invariant/transcript_symmetry.rs index 00a1fb2f21..850b61b752 100644 --- a/jolt-eval/src/invariant/transcript_symmetry.rs +++ b/jolt-eval/src/invariant/transcript_symmetry.rs @@ -128,11 +128,7 @@ where Ok(()) } -fn violation( - what: &str, - op_idx: usize, - err: spongefish::VerificationError, -) -> CheckError { +fn violation(what: &str, op_idx: usize, err: spongefish::VerificationError) -> CheckError { CheckError::Violation(InvariantViolation::with_details( format!("{what} failed on verifier"), format!("op_idx={op_idx}, err={err:?}"), @@ -194,67 +190,94 @@ fn seed_corpus_shared() -> Vec { ] } -macro_rules! transcript_invariant { - ($struct:ident, $sponge:ty, $build:expr, $name:literal, $sponge_label:literal) => { - #[doc = concat!( - "Spongefish symmetry invariant for the ", - $sponge_label, - " sponge." - )] - #[jolt_eval_macros::invariant(Test, Fuzz, RedTeam)] - #[derive(Default)] - pub struct $struct; - - impl Invariant for $struct { - type Setup = (); - type Input = Input; - - fn name(&self) -> &str { - $name - } +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." + ) +} - fn description(&self) -> String { - format!( - "spongefish ProverState/VerifierState pair ({} sponge) replaying \ - the same operation sequence must round-trip every prover message \ - and agree on every challenge.", - $sponge_label - ) - } +/// Spongefish symmetry invariant for the Blake2b512 sponge. +#[jolt_eval_macros::invariant(Test, Fuzz, RedTeam)] +#[derive(Default)] +pub struct TranscriptConsistencyBlake2bInvariant; - fn setup(&self) {} +impl Invariant for TranscriptConsistencyBlake2bInvariant { + type Setup = (); + type Input = Input; - fn check(&self, _setup: &(), input: Input) -> Result<(), CheckError> { - run_check::<$sponge>(&input, $build) - } + fn name(&self) -> &str { + "transcript_prover_verifier_consistency_blake2b" + } - fn seed_corpus(&self) -> Vec { - seed_corpus_shared() - } - } - }; + fn description(&self) -> String { + description_for("Blake2b512") + } + + fn setup(&self) {} + + fn check(&self, _setup: &(), input: Input) -> Result<(), CheckError> { + run_check::(&input, Blake2b512::default) + } + + fn seed_corpus(&self) -> Vec { + seed_corpus_shared() + } +} + +/// Spongefish symmetry invariant for the Keccak sponge. +#[jolt_eval_macros::invariant(Test, Fuzz, RedTeam)] +#[derive(Default)] +pub struct TranscriptConsistencyKeccakInvariant; + +impl Invariant for TranscriptConsistencyKeccakInvariant { + type Setup = (); + type Input = Input; + + fn name(&self) -> &str { + "transcript_prover_verifier_consistency_keccak" + } + + fn description(&self) -> String { + description_for("Keccak") + } + + fn setup(&self) {} + + fn check(&self, _setup: &(), input: Input) -> Result<(), CheckError> { + run_check::(&input, Keccak::default) + } + + fn seed_corpus(&self) -> Vec { + seed_corpus_shared() + } } -transcript_invariant!( - TranscriptConsistencyBlake2bInvariant, - Blake2b512, - Blake2b512::default, - "transcript_prover_verifier_consistency_blake2b", - "Blake2b512" -); - -transcript_invariant!( - TranscriptConsistencyKeccakInvariant, - Keccak, - Keccak::default, - "transcript_prover_verifier_consistency_keccak", - "Keccak" -); - -transcript_invariant!( - TranscriptConsistencyPoseidonInvariant, - PoseidonSponge, - PoseidonSponge::new, - "transcript_prover_verifier_consistency_poseidon", - "Poseidon" -); +/// Spongefish symmetry invariant for the Poseidon sponge. +#[jolt_eval_macros::invariant(Test, Fuzz, RedTeam)] +#[derive(Default)] +pub struct TranscriptConsistencyPoseidonInvariant; + +impl Invariant for TranscriptConsistencyPoseidonInvariant { + type Setup = (); + type Input = Input; + + fn name(&self) -> &str { + "transcript_prover_verifier_consistency_poseidon" + } + + fn description(&self) -> String { + description_for("Poseidon") + } + + fn setup(&self) {} + + fn check(&self, _setup: &(), input: Input) -> Result<(), CheckError> { + run_check::(&input, PoseidonSponge::new) + } + + fn seed_corpus(&self) -> Vec { + seed_corpus_shared() + } +} From a68ed765173ae1ec522c6d2f6632588bae7503c3 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Thu, 14 May 2026 01:31:05 +0530 Subject: [PATCH 09/21] fix(jolt-transcript): generic FieldEl and guard BytesMsg length overflow Address PR #1455 review: - Generalize FieldEl / FieldElOptimized over jolt-field traits (CanonicalBytes for Encoding, ReducingBytes/FromPrimitiveInt for Decoding) so the codec is not pinned to ark_bn254::Fr. - BytesMsg::deserialize_from_narg used `8 + len` directly, which wraps on attacker-supplied len = u64::MAX and lets buf[8..8+len] panic instead of returning VerificationError. Use checked_add and add a regression test feeding u64::MAX. --- crates/jolt-transcript/src/codec.rs | 83 ++++++++++--------- crates/jolt-transcript/src/prover.rs | 6 +- crates/jolt-transcript/src/verifier.rs | 6 +- .../src/invariant/transcript_symmetry.rs | 21 ++--- 4 files changed, 56 insertions(+), 60 deletions(-) diff --git a/crates/jolt-transcript/src/codec.rs b/crates/jolt-transcript/src/codec.rs index 6e520b60c8..481d79ba81 100644 --- a/crates/jolt-transcript/src/codec.rs +++ b/crates/jolt-transcript/src/codec.rs @@ -5,41 +5,28 @@ //! because Jolt patches `ark-ff` / `ark-serialize` to a fork. These local //! codecs are injective and prefix-free. -use ark_bn254::Fr; -use ark_ff::{BigInteger, PrimeField}; +use jolt_field::{CanonicalBytes, FixedByteSize, FromPrimitiveInt, ReducingBytes}; use spongefish::{Decoding, Encoding, NargDeserialize, VerificationError, VerificationResult}; -const FR_LE_BYTES: usize = 32; const FR_TRUNCATED_BYTES: usize = 16; -/// Bytes drawn per full-field challenge. 64 bytes mod the BN254 modulus -/// is within `2^{-130}` statistical distance of uniform. +/// Bytes drawn per full-field challenge. 64 bytes mod a ≤254-bit field +/// modulus is within `2^{-130}` statistical distance of uniform. Tuned for +/// BN254; safe for any field up to that width. const FR_UNIFORM_BYTES: usize = 64; -fn fr_to_le_bytes(f: &Fr) -> [u8; FR_LE_BYTES] { - let bytes = f.into_bigint().to_bytes_le(); - debug_assert_eq!( - bytes.len(), - FR_LE_BYTES, - "BN254 Fr LE serialization is fixed-width 32 bytes" - ); - let mut out = [0u8; FR_LE_BYTES]; - out.copy_from_slice(&bytes); - out -} - -/// Wraps a BN254 `Fr` for absorption / decoding as 32 little-endian bytes. +/// Wraps a field element for absorption / decoding as little-endian bytes. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct FieldEl(pub Fr); +pub struct FieldEl(pub F); -impl From for FieldEl { - fn from(f: Fr) -> Self { +impl From for FieldEl { + fn from(f: F) -> Self { Self(f) } } -impl Encoding<[u8]> for FieldEl { +impl Encoding<[u8]> for FieldEl { fn encode(&self) -> impl AsRef<[u8]> { - fr_to_le_bytes(&self.0) + self.0.to_bytes_le_vec() } } @@ -60,42 +47,43 @@ impl AsMut<[u8]> for UniformFrBytes { } } -impl Decoding<[u8]> for FieldEl { +impl Decoding<[u8]> for FieldEl { type Repr = UniformFrBytes; fn decode(buf: Self::Repr) -> Self { - FieldEl(Fr::from_le_bytes_mod_order(&buf.0)) + FieldEl(F::from_le_bytes_mod_order(&buf.0)) } } -impl NargDeserialize for FieldEl { +impl NargDeserialize for FieldEl { fn deserialize_from_narg(buf: &mut &[u8]) -> VerificationResult { - if buf.len() < FR_LE_BYTES { + let n = F::NUM_BYTES; + if buf.len() < n { return Err(VerificationError); } - let (head, tail) = buf.split_at(FR_LE_BYTES); + let (head, tail) = buf.split_at(n); *buf = tail; - Ok(FieldEl(Fr::from_le_bytes_mod_order(head))) + Ok(FieldEl(F::from_le_bytes_mod_order(head))) } } /// 128-bit-truncating challenge wrapper. Decodes 16 squeezed bytes via -/// `Fr::from(u128)`. Used only as a verifier message; the `Encoding` impl -/// is the same 32-byte LE form as [`FieldEl`] so that absorbing one of +/// `F::from_u128`. Used only as a verifier message; the `Encoding` impl +/// is the same little-endian form as [`FieldEl`] so that absorbing one of /// these symmetrically with the other type stays a code error rather than /// a wire-format hazard. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct FieldElOptimized(pub Fr); +pub struct FieldElOptimized(pub F); -impl Encoding<[u8]> for FieldElOptimized { +impl Encoding<[u8]> for FieldElOptimized { fn encode(&self) -> impl AsRef<[u8]> { - fr_to_le_bytes(&self.0) + self.0.to_bytes_le_vec() } } -impl Decoding<[u8]> for FieldElOptimized { +impl Decoding<[u8]> for FieldElOptimized { type Repr = [u8; FR_TRUNCATED_BYTES]; fn decode(buf: Self::Repr) -> Self { - FieldElOptimized(Fr::from(u128::from_le_bytes(buf))) + FieldElOptimized(F::from_u128(u128::from_le_bytes(buf))) } } @@ -134,11 +122,12 @@ impl NargDeserialize for BytesMsg { let mut len_bytes = [0u8; 8]; len_bytes.copy_from_slice(&buf[..8]); let len = u64::from_le_bytes(len_bytes) as usize; - if buf.len() < 8 + len { + let total = 8usize.checked_add(len).ok_or(VerificationError)?; + if buf.len() < total { return Err(VerificationError); } - let body = buf[8..8 + len].to_vec(); - *buf = &buf[8 + len..]; + let body = buf[8..total].to_vec(); + *buf = &buf[total..]; Ok(BytesMsg(body)) } } @@ -146,12 +135,13 @@ impl NargDeserialize for BytesMsg { #[cfg(test)] mod tests { use super::*; + use jolt_field::Fr; #[test] fn fr_le_bytes_round_trip() { for i in 0u64..32 { let f = Fr::from(i.wrapping_mul(0x9E37_79B9_7F4A_7C15)); - let bytes = fr_to_le_bytes(&f); + let bytes = f.to_bytes_le_vec(); assert_eq!(Fr::from_le_bytes_mod_order(&bytes), f); } } @@ -179,10 +169,21 @@ mod tests { assert_eq!(cursor.len(), before, "cursor must not advance on error"); } + #[test] + fn bytes_msg_narg_rejects_oversized_length() { + let mut narg = Vec::new(); + narg.extend_from_slice(&u64::MAX.to_le_bytes()); + let mut cursor: &[u8] = &narg; + let before = cursor.len(); + let result = BytesMsg::deserialize_from_narg(&mut cursor); + assert!(result.is_err()); + assert_eq!(cursor.len(), before, "cursor must not advance on error"); + } + #[test] fn field_el_optimized_decodes_u128() { let buf = 12345u128.to_le_bytes(); - let FieldElOptimized(f) = FieldElOptimized::decode(buf); + let FieldElOptimized(f) = FieldElOptimized::::decode(buf); assert_eq!(f, Fr::from(12345u128)); } } diff --git a/crates/jolt-transcript/src/prover.rs b/crates/jolt-transcript/src/prover.rs index 07d2fe2f92..031401b39f 100644 --- a/crates/jolt-transcript/src/prover.rs +++ b/crates/jolt-transcript/src/prover.rs @@ -4,7 +4,7 @@ //! rule. Methods are positional, matching spongefish-native usage //! (WhiR, sigma-rs). -use ark_bn254::Fr; +use jolt_field::Fr; use rand::{CryptoRng, RngCore}; use spongefish::{Decoding, DuplexSpongeInterface, Encoding, NargSerialize, ProverState}; @@ -64,7 +64,7 @@ where R: RngCore + CryptoRng, { fn challenge_128(&mut self) -> Fr { - ProverState::verifier_message::(self).0 + ProverState::verifier_message::>(self).0 } } @@ -74,6 +74,6 @@ where R: RngCore + CryptoRng, { fn challenge_128(&mut self) -> Fr { - ProverState::verifier_message::(self).0 + ProverState::verifier_message::>(self).0 } } diff --git a/crates/jolt-transcript/src/verifier.rs b/crates/jolt-transcript/src/verifier.rs index c2ec23351b..b359e298e7 100644 --- a/crates/jolt-transcript/src/verifier.rs +++ b/crates/jolt-transcript/src/verifier.rs @@ -1,6 +1,6 @@ //! Spongefish-native [`VerifierTranscript`] surface. -use ark_bn254::Fr; +use jolt_field::Fr; use spongefish::{ Decoding, DuplexSpongeInterface, Encoding, NargDeserialize, VerificationResult, VerifierState, }; @@ -47,13 +47,13 @@ where #[cfg(feature = "transcript-blake2b")] impl OptimizedChallenge for VerifierState<'_, spongefish::instantiations::Blake2b512> { fn challenge_128(&mut self) -> Fr { - VerifierState::verifier_message::(self).0 + VerifierState::verifier_message::>(self).0 } } #[cfg(feature = "transcript-keccak")] impl OptimizedChallenge for VerifierState<'_, spongefish::instantiations::Keccak> { fn challenge_128(&mut self) -> Fr { - VerifierState::verifier_message::(self).0 + VerifierState::verifier_message::>(self).0 } } diff --git a/jolt-eval/src/invariant/transcript_symmetry.rs b/jolt-eval/src/invariant/transcript_symmetry.rs index 850b61b752..7effdb854e 100644 --- a/jolt-eval/src/invariant/transcript_symmetry.rs +++ b/jolt-eval/src/invariant/transcript_symmetry.rs @@ -4,7 +4,6 @@ //! verifier challenges. use arbitrary::{Arbitrary, Unstructured}; -use ark_bn254::Fr; use jolt_field::Fr as JFr; use spongefish::instantiations::{Blake2b512, Keccak}; @@ -64,25 +63,21 @@ fn arb_scalar(u: &mut Unstructured<'_>) -> arbitrary::Result { Ok(JFr::from_le_bytes_mod_order(&bytes)) } -fn ark(f: JFr) -> Fr { - f.into() -} - fn run_check(input: &Input, build_sponge: impl Fn() -> H) -> Result<(), CheckError> where H: spongefish::DuplexSpongeInterface, { let mut prover = to_prover(build_sponge(), SESSION); - let mut prover_challenges: Vec = Vec::new(); + let mut prover_challenges: Vec = Vec::new(); for op in &input.ops { match op { Op::PublicBytes(b) => prover.public_message(&BytesMsg(b.clone())), - Op::PublicScalar(f) => prover.public_message(&FieldEl(ark(*f))), + Op::PublicScalar(f) => prover.public_message(&FieldEl(*f)), Op::ProverBytes(b) => prover.prover_message(&BytesMsg(b.clone())), - Op::ProverScalar(f) => prover.prover_message(&FieldEl(ark(*f))), + Op::ProverScalar(f) => prover.prover_message(&FieldEl(*f)), Op::Challenge => { - let FieldEl(c) = prover.verifier_message::(); + let FieldEl(c) = prover.verifier_message::>(); prover_challenges.push(c); } } @@ -95,7 +90,7 @@ where for (op_idx, op) in input.ops.iter().enumerate() { match op { Op::PublicBytes(b) => verifier.public_message(&BytesMsg(b.clone())), - Op::PublicScalar(f) => verifier.public_message(&FieldEl(ark(*f))), + Op::PublicScalar(f) => verifier.public_message(&FieldEl(*f)), Op::ProverBytes(expected) => { let got: BytesMsg = verifier .prover_message() @@ -105,15 +100,15 @@ where } } Op::ProverScalar(expected) => { - let got: FieldEl = verifier + let got: FieldEl = verifier .prover_message() .map_err(|e| violation("prover_message", op_idx, e))?; - if got.0 != ark(*expected) { + if got.0 != *expected { return Err(mismatch("ProverScalar round-trip", op_idx)); } } Op::Challenge => { - let FieldEl(verifier_c) = verifier.verifier_message::(); + let FieldEl(verifier_c) = verifier.verifier_message::>(); if verifier_c != prover_challenges[challenge_idx] { return Err(mismatch("Challenge", op_idx)); } From f824065018351d62bd9bd5ab4c80b04d065abbea Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Thu, 14 May 2026 01:37:44 +0530 Subject: [PATCH 10/21] perf(jolt-transcript): drop per-op peek_state cache in SpongeTranscript Address PR #1455 review. SpongeTranscript cloned the entire sponge and squeezed 32 bytes on every append_bytes / challenge to keep state() returning a reference cheaply. For PoseidonSponge each clone re-ran Poseidon::new_circom(3), rebuilding BN254 x5 round constants per op. No production caller of state() exists in this branch -- the method is only used by debug-only cross-verifier comparisons. Change the Transcript trait to return owned [u8; 32] and peek on demand inside state(), removing the cache field and the per-op clone. --- crates/jolt-transcript/src/compat.rs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/crates/jolt-transcript/src/compat.rs b/crates/jolt-transcript/src/compat.rs index bd1d813c0b..ac98137d9b 100644 --- a/crates/jolt-transcript/src/compat.rs +++ b/crates/jolt-transcript/src/compat.rs @@ -62,7 +62,7 @@ pub trait Transcript: Default + Clone + Sync + Send + 'static { /// without advancing the real state. Useful for debug-only /// cross-verifier comparison. #[must_use] - fn state(&self) -> &[u8; 32]; + 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 @@ -150,9 +150,6 @@ where F: TranscriptChallenge, { sponge: H, - /// 32-byte non-destructive peek of the sponge state, refreshed after - /// every absorb / squeeze so `state()` can return a reference cheaply. - state: [u8; 32], _field: PhantomData, } @@ -174,7 +171,6 @@ where fn clone(&self) -> Self { Self { sponge: self.sponge.clone(), - state: self.state, _field: PhantomData, } } @@ -212,10 +208,8 @@ where absorb_encoded(&mut sponge, &PROTOCOL_ID); absorb_encoded(&mut sponge, &BytesMsg(label.to_vec())); absorb_encoded(&mut sponge, &EmptyInstance); - let state = peek_state(&sponge); Self { sponge, - state, _field: PhantomData, } } @@ -236,17 +230,15 @@ where buf.extend_from_slice(&(bytes.len() as u64).to_le_bytes()); buf.extend_from_slice(bytes); let _ = self.sponge.absorb(&buf); - self.state = peek_state(&self.sponge); } fn challenge(&mut self) -> F { let mut buf = [0u8; 16]; let _ = self.sponge.squeeze(&mut buf); - self.state = peek_state(&self.sponge); F::from_challenge_bytes(&buf) } - fn state(&self) -> &[u8; 32] { - &self.state + fn state(&self) -> [u8; 32] { + peek_state(&self.sponge) } } From ddb62edeacdcca04b1ce8c90f8ef1fd6b7aca418 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Thu, 14 May 2026 01:50:41 +0530 Subject: [PATCH 11/21] docs(jolt-transcript): note compat challenge() vs OptimizedChallenge divergence Address PR #1455 review. compat::SpongeTranscript::challenge squeezes 16 bytes uniformly across all sponges, including Poseidon, while the split-trait OptimizedChallenge in prover.rs is deliberately not implemented for Poseidon (compile error). The divergence is intentional: compat preserves the legacy jolt-core challenge width for in-flight consumers and goes away once they migrate. Document the asymmetry inline so future readers don't treat compat as a bug. --- crates/jolt-transcript/src/compat.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/jolt-transcript/src/compat.rs b/crates/jolt-transcript/src/compat.rs index ac98137d9b..322ee9fe49 100644 --- a/crates/jolt-transcript/src/compat.rs +++ b/crates/jolt-transcript/src/compat.rs @@ -233,6 +233,14 @@ where } 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 compat 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) From 40b940a7581a842eeb49bc8cb53b5302dfbbca0b Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Thu, 14 May 2026 01:51:31 +0530 Subject: [PATCH 12/21] fix(jolt-transcript): drop Encoding impl on FieldElOptimized to block silent NARG misuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #1455 review. Spongefish 0.7's blanket `impl> NargSerialize for T` (io.rs:62) meant `prover_message(&FieldElOptimized(f))` compiled silently, wrote 32 bytes into the NARG, and the verifier had no `NargDeserialize` impl to read them back — leaving the data orphaned and surfacing as a far-away `check_eof` failure. Drop the `Encoding` impl entirely so misuse fails at the call site. `FieldElOptimized` is verifier-message-only (Decoding via squeeze), so no caller is affected. --- crates/jolt-transcript/src/codec.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/crates/jolt-transcript/src/codec.rs b/crates/jolt-transcript/src/codec.rs index 481d79ba81..db2b64026c 100644 --- a/crates/jolt-transcript/src/codec.rs +++ b/crates/jolt-transcript/src/codec.rs @@ -67,19 +67,15 @@ impl NargDeserialize for FieldEl { } /// 128-bit-truncating challenge wrapper. Decodes 16 squeezed bytes via -/// `F::from_u128`. Used only as a verifier message; the `Encoding` impl -/// is the same little-endian form as [`FieldEl`] so that absorbing one of -/// these symmetrically with the other type stays a code error rather than -/// a wire-format hazard. +/// `F::from_u128`. Verifier-message-only — deliberately implements +/// neither `Encoding` nor `NargSerialize` so that +/// `prover_message(&FieldElOptimized(_))` is a compile error rather than +/// a silent orphan in the NARG. (Spongefish's blanket +/// `impl> NargSerialize for T` would otherwise let any +/// `Encoding` slip into `prover_message` undetected.) #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct FieldElOptimized(pub F); -impl Encoding<[u8]> for FieldElOptimized { - fn encode(&self) -> impl AsRef<[u8]> { - self.0.to_bytes_le_vec() - } -} - impl Decoding<[u8]> for FieldElOptimized { type Repr = [u8; FR_TRUNCATED_BYTES]; fn decode(buf: Self::Repr) -> Self { From 334c78f7ced5b7b0f6348f01b53803def27071d7 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Tue, 19 May 2026 22:43:48 +0530 Subject: [PATCH 13/21] refactor(jolt-transcript): drop Clone bound from compat::Transcript Spongefish deliberately makes prover state non-cloneable to prevent fork-and-resume of a Fiat-Shamir transcript. The compat facade trait required Clone, which defeated that mitigation at the API level. Drop the bound from compat::Transcript and remove the manual Clone impl on SpongeTranscript. Removes test_clone_independence from the shared transcript test macro (no longer applicable). Per mmaker's review on a16z/jolt#1455. --- crates/jolt-transcript/src/compat.rs | 15 +-------------- crates/jolt-transcript/tests/common/mod.rs | 21 --------------------- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/crates/jolt-transcript/src/compat.rs b/crates/jolt-transcript/src/compat.rs index 322ee9fe49..08c92a30f4 100644 --- a/crates/jolt-transcript/src/compat.rs +++ b/crates/jolt-transcript/src/compat.rs @@ -28,7 +28,7 @@ pub const MAX_LABEL_LEN: usize = 32; /// 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 + Clone + Sync + Send + 'static { +pub trait Transcript: Default + Sync + Send + 'static { /// The challenge type produced by this transcript. type Challenge: TranscriptChallenge; @@ -163,19 +163,6 @@ where } } -impl Clone for SpongeTranscript -where - H: DuplexSpongeInterface + Clone + Default + Send + Sync + 'static, - F: TranscriptChallenge, -{ - fn clone(&self) -> Self { - Self { - sponge: self.sponge.clone(), - _field: PhantomData, - } - } -} - fn absorb_encoded(sponge: &mut H, value: &T) where H: DuplexSpongeInterface, diff --git a/crates/jolt-transcript/tests/common/mod.rs b/crates/jolt-transcript/tests/common/mod.rs index bb9f5ff97c..3faaf9e181 100644 --- a/crates/jolt-transcript/tests/common/mod.rs +++ b/crates/jolt-transcript/tests/common/mod.rs @@ -137,27 +137,6 @@ macro_rules! transcript_tests { ); } - #[test] - fn test_clone_independence() { - let mut original = <$transcript_type>::new(b"clone_test"); - original.append_bytes(&1u64.to_be_bytes()); - - let mut cloned = original.clone(); - cloned.append_bytes(&2u64.to_be_bytes()); - - let original_challenge = original.challenge(); - - let mut fresh = <$transcript_type>::new(b"clone_test"); - fresh.append_bytes(&1u64.to_be_bytes()); - fresh.append_bytes(&2u64.to_be_bytes()); - let fresh_challenge = fresh.challenge(); - - assert_ne!( - original_challenge, fresh_challenge, - "Clone mutation must not affect original" - ); - } - #[test] fn test_default_delegates_to_new() { let mut default_transcript = <$transcript_type>::default(); From 4833a93973e1891bcefa397273810c87c1ffcbbe Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Tue, 19 May 2026 22:45:34 +0530 Subject: [PATCH 14/21] refactor(jolt-transcript): drop FieldElOptimized, use spongefish u128 codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FieldElOptimized` and `UniformFrBytes` wrapped spongefish's u128 absorb path with our own newtype. The wrapper added indirection without behavior — spongefish's built-in `u128` codec encodes identical bytes (16-byte LE) and decodes to the same truncated challenge. Delete both wrappers from `codec.rs`. Collapse every `OptimizedChallenge` body in `prover.rs` and `verifier.rs` to `Fr::from(verifier_message::(self))`. Wire format preserved exactly — `to_le_bytes` is spongefish's u128 Encoding too. `FieldEl` (full-field LE absorb / 64-byte uniform decode) stays — spongefish 0.7's `ark-ff` feature would pull in `ark-ff ^0.6`, incompatible with the workspace's pinned `ark-ff 0.5` fork. Per mmaker's review on a16z/jolt#1455. --- crates/jolt-transcript/src/codec.rs | 39 +++++++------------------- crates/jolt-transcript/src/lib.rs | 2 +- crates/jolt-transcript/src/prover.rs | 6 ++-- crates/jolt-transcript/src/verifier.rs | 5 ++-- 4 files changed, 15 insertions(+), 37 deletions(-) diff --git a/crates/jolt-transcript/src/codec.rs b/crates/jolt-transcript/src/codec.rs index db2b64026c..4ea340ee93 100644 --- a/crates/jolt-transcript/src/codec.rs +++ b/crates/jolt-transcript/src/codec.rs @@ -1,14 +1,19 @@ //! Local codecs for absorbing / decoding Jolt-native messages over a //! byte-oriented spongefish sponge. //! -//! Spongefish ships optional arkworks codec features; we don't enable them -//! because Jolt patches `ark-ff` / `ark-serialize` to a fork. These local -//! codecs are injective and prefix-free. +//! Spongefish 0.7 ships an `ark-ff` codec feature, but enabling it pulls +//! in `ark-ff ^0.6` which conflicts with this workspace's pinned +//! `a16z/arkworks-algebra@dev/twist-shout` fork (still on the 0.5 +//! version line). Until the fork bumps to 0.6 we keep these local +//! field-element codecs. Encodings are injective and prefix-free. +//! +//! 128-bit-truncated challenges decode through spongefish's built-in +//! `u128` codec directly (no wrapper needed); see +//! [`crate::prover::OptimizedChallenge`]. -use jolt_field::{CanonicalBytes, FixedByteSize, FromPrimitiveInt, ReducingBytes}; +use jolt_field::{CanonicalBytes, FixedByteSize, ReducingBytes}; use spongefish::{Decoding, Encoding, NargDeserialize, VerificationError, VerificationResult}; -const FR_TRUNCATED_BYTES: usize = 16; /// Bytes drawn per full-field challenge. 64 bytes mod a ≤254-bit field /// modulus is within `2^{-130}` statistical distance of uniform. Tuned for /// BN254; safe for any field up to that width. @@ -66,23 +71,6 @@ impl NargDeserialize for FieldEl { } } -/// 128-bit-truncating challenge wrapper. Decodes 16 squeezed bytes via -/// `F::from_u128`. Verifier-message-only — deliberately implements -/// neither `Encoding` nor `NargSerialize` so that -/// `prover_message(&FieldElOptimized(_))` is a compile error rather than -/// a silent orphan in the NARG. (Spongefish's blanket -/// `impl> NargSerialize for T` would otherwise let any -/// `Encoding` slip into `prover_message` undetected.) -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct FieldElOptimized(pub F); - -impl Decoding<[u8]> for FieldElOptimized { - type Repr = [u8; FR_TRUNCATED_BYTES]; - fn decode(buf: Self::Repr) -> Self { - FieldElOptimized(F::from_u128(u128::from_le_bytes(buf))) - } -} - /// Length-prefixed byte string. 8-byte LE length keeps `BytesMsg(a) ; BytesMsg(b)` /// distinguishable from `BytesMsg(a||b)`. #[derive(Clone, Debug, PartialEq, Eq)] @@ -175,11 +163,4 @@ mod tests { assert!(result.is_err()); assert_eq!(cursor.len(), before, "cursor must not advance on error"); } - - #[test] - fn field_el_optimized_decodes_u128() { - let buf = 12345u128.to_le_bytes(); - let FieldElOptimized(f) = FieldElOptimized::::decode(buf); - assert_eq!(f, Fr::from(12345u128)); - } } diff --git a/crates/jolt-transcript/src/lib.rs b/crates/jolt-transcript/src/lib.rs index 1fc5a7aec1..e0a5cf98a8 100644 --- a/crates/jolt-transcript/src/lib.rs +++ b/crates/jolt-transcript/src/lib.rs @@ -25,7 +25,7 @@ mod prover; mod setup; mod verifier; -pub use codec::{BytesMsg, FieldEl, FieldElOptimized}; +pub use codec::{BytesMsg, FieldEl}; pub use compat::{ AppendToTranscript, Label, LabelWithCount, SpongeTranscript, Transcript, U64Word, MAX_LABEL_LEN, }; diff --git a/crates/jolt-transcript/src/prover.rs b/crates/jolt-transcript/src/prover.rs index 031401b39f..b9add707a3 100644 --- a/crates/jolt-transcript/src/prover.rs +++ b/crates/jolt-transcript/src/prover.rs @@ -8,8 +8,6 @@ use jolt_field::Fr; use rand::{CryptoRng, RngCore}; use spongefish::{Decoding, DuplexSpongeInterface, Encoding, NargSerialize, ProverState}; -use crate::codec::FieldElOptimized; - /// Prover-side spongefish transcript. /// /// `H::U` is the sponge alphabet (`u8` for every sponge in this crate). @@ -64,7 +62,7 @@ where R: RngCore + CryptoRng, { fn challenge_128(&mut self) -> Fr { - ProverState::verifier_message::>(self).0 + Fr::from(ProverState::verifier_message::(self)) } } @@ -74,6 +72,6 @@ where R: RngCore + CryptoRng, { fn challenge_128(&mut self) -> Fr { - ProverState::verifier_message::>(self).0 + Fr::from(ProverState::verifier_message::(self)) } } diff --git a/crates/jolt-transcript/src/verifier.rs b/crates/jolt-transcript/src/verifier.rs index b359e298e7..d89fc3d705 100644 --- a/crates/jolt-transcript/src/verifier.rs +++ b/crates/jolt-transcript/src/verifier.rs @@ -5,7 +5,6 @@ use spongefish::{ Decoding, DuplexSpongeInterface, Encoding, NargDeserialize, VerificationResult, VerifierState, }; -use crate::codec::FieldElOptimized; use crate::prover::OptimizedChallenge; /// Verifier-side spongefish transcript. @@ -47,13 +46,13 @@ where #[cfg(feature = "transcript-blake2b")] impl OptimizedChallenge for VerifierState<'_, spongefish::instantiations::Blake2b512> { fn challenge_128(&mut self) -> Fr { - VerifierState::verifier_message::>(self).0 + Fr::from(VerifierState::verifier_message::(self)) } } #[cfg(feature = "transcript-keccak")] impl OptimizedChallenge for VerifierState<'_, spongefish::instantiations::Keccak> { fn challenge_128(&mut self) -> Fr { - VerifierState::verifier_message::>(self).0 + Fr::from(VerifierState::verifier_message::(self)) } } From eec24ad3861b66a680d9ac2dc7ac929fbbe70400 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Tue, 19 May 2026 22:46:15 +0530 Subject: [PATCH 15/21] refactor(jolt-transcript): rename `compat` module to `legacy` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source-compatible facade (`Transcript`, `SpongeTranscript`, `AppendToTranscript`, `Label`, `LabelWithCount`, `U64Word`) is slated for deletion once jolt-sumcheck / jolt-openings / jolt-crypto migrate to the native split-trait surface. Rename `mod compat` to `mod legacy` to advertise the lifecycle — "compat" implied a permanent compatibility shim; "legacy" names what it is. Internal rename only — public API surface unchanged. Per mmaker's review on a16z/jolt#1455. --- crates/jolt-transcript/src/{compat.rs => legacy.rs} | 10 +++++----- crates/jolt-transcript/src/lib.rs | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) rename crates/jolt-transcript/src/{compat.rs => legacy.rs} (96%) diff --git a/crates/jolt-transcript/src/compat.rs b/crates/jolt-transcript/src/legacy.rs similarity index 96% rename from crates/jolt-transcript/src/compat.rs rename to crates/jolt-transcript/src/legacy.rs index 08c92a30f4..d55c39fc30 100644 --- a/crates/jolt-transcript/src/compat.rs +++ b/crates/jolt-transcript/src/legacy.rs @@ -66,7 +66,7 @@ pub trait Transcript: Default + Sync + Send + 'static { /// Enables transcript comparison for tests; mirrors upstream's signature. /// Spongefish sponges have no replayable state history, so this is a - /// no-op on the compat facade — call sites already only use it under + /// 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) {} @@ -137,7 +137,7 @@ impl AppendToTranscript for U64Word { /// Sponge-backed transcript driving a duplex sponge directly. /// -/// The compat facade does not produce or consume a NARG byte string — +/// 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. @@ -204,10 +204,10 @@ where fn append_bytes(&mut self, bytes: &[u8]) { // 1-byte non-zero domain marker + 8-byte LE length + body. // - // The marker sub-domain-separates compat-facade `append_bytes` calls + // 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 compat append collide with a spongefish-native + // 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)`. @@ -224,7 +224,7 @@ where // 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 compat facade preserves the legacy + // 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. diff --git a/crates/jolt-transcript/src/lib.rs b/crates/jolt-transcript/src/lib.rs index e0a5cf98a8..2060dd4f42 100644 --- a/crates/jolt-transcript/src/lib.rs +++ b/crates/jolt-transcript/src/lib.rs @@ -18,7 +18,7 @@ #![deny(missing_docs)] mod codec; -mod compat; +mod legacy; #[cfg(feature = "transcript-poseidon")] mod poseidon; mod prover; @@ -26,7 +26,7 @@ mod setup; mod verifier; pub use codec::{BytesMsg, FieldEl}; -pub use compat::{ +pub use legacy::{ AppendToTranscript, Label, LabelWithCount, SpongeTranscript, Transcript, U64Word, MAX_LABEL_LEN, }; pub use setup::{to_prover, to_verifier, EmptyInstance, PROTOCOL_ID}; @@ -35,7 +35,7 @@ pub use setup::{to_prover, to_verifier, EmptyInstance, PROTOCOL_ID}; /// under their `jolt_transcript::domain::*` path (matches the path used /// by jolt-dory and earlier modular consumers). pub mod domain { - pub use crate::compat::{Label, LabelWithCount, U64Word}; + pub use crate::legacy::{Label, LabelWithCount, U64Word}; } #[cfg(feature = "transcript-poseidon")] From dfb7ce98de8b5c22e0a8088cf23bcdee608fd8c5 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Tue, 19 May 2026 22:49:03 +0530 Subject: [PATCH 16/21] =?UTF-8?q?feat(jolt-transcript):=20native=20constru?= =?UTF-8?q?ction=20API=20=E2=80=94=20prover=5Ftranscript=20/=20verifier=5F?= =?UTF-8?q?transcript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two positional factory functions plus one escape hatch: prover_transcript(session: &[u8], instance: [u8; 32], sponge) -> ProverState verifier_transcript(session: &[u8], instance: [u8; 32], sponge, narg) -> VerifierState<'_, H> transcript_builder() -> DomainSeparator `instance: [u8; 32]` is positional and required. `transcript_builder()` exposes spongefish's `DomainSeparator` with PROTOCOL_ID pre-bound for composite verifiers (e.g. `BlindFold` sub-rounds) that need the full type-state builder. `EmptyInstance` is retained in setup.rs for the legacy facade, which still absorbs an empty instance step in its own constructor. The new factory uses `InstanceDigest` internally for the 32-byte digest. Update jolt-eval transcript-symmetry invariant to the new shape with a placeholder INSTANCE_DIGEST. Per mmaker's review on a16z/jolt#1455. --- crates/jolt-transcript/src/lib.rs | 2 +- crates/jolt-transcript/src/setup.rs | 122 +++++++++++++++--- .../src/invariant/transcript_symmetry.rs | 9 +- 3 files changed, 112 insertions(+), 21 deletions(-) diff --git a/crates/jolt-transcript/src/lib.rs b/crates/jolt-transcript/src/lib.rs index 2060dd4f42..617ad44b7f 100644 --- a/crates/jolt-transcript/src/lib.rs +++ b/crates/jolt-transcript/src/lib.rs @@ -29,7 +29,7 @@ pub use codec::{BytesMsg, FieldEl}; pub use legacy::{ AppendToTranscript, Label, LabelWithCount, SpongeTranscript, Transcript, U64Word, MAX_LABEL_LEN, }; -pub use setup::{to_prover, to_verifier, EmptyInstance, 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 diff --git a/crates/jolt-transcript/src/setup.rs b/crates/jolt-transcript/src/setup.rs index 91daa336c2..a0fd71973f 100644 --- a/crates/jolt-transcript/src/setup.rs +++ b/crates/jolt-transcript/src/setup.rs @@ -1,18 +1,41 @@ -//! Spongefish domain-separator wiring used by both the new split-trait -//! surface and the source-compatible facade. +//! Spongefish domain-separator entry points for the native split-trait +//! surface. //! -//! All three sponges in this crate use byte units (`H::U = u8`); the protocol -//! identifier is fixed crate-wide. Per-construction disambiguation is carried -//! by the spongefish session value (e.g. the legacy `Transcript::new(label)` -//! is mapped to `session(label)`). +//! Two factory functions cover every Jolt caller: +//! +//! - [`prover_transcript`] — `(session, instance, sponge) → ProverState` +//! - [`verifier_transcript`] — `(session, instance, sponge, narg) → VerifierState` +//! +//! Both pre-bind [`PROTOCOL_ID`] and absorb the spongefish +//! `session`/`instance` steps internally. Wire format mirrors spongefish's +//! `DomainSeparator` exactly: +//! +//! ```text +//! PROTOCOL_ID (fixed, crate-wide) +//! ↓ +//! session(bytes) +//! ↓ +//! instance(32-byte digest) +//! ↓ +//! to_prover(sponge) | to_verifier(sponge, narg) +//! ``` +//! +//! Power users (e.g. `BlindFold` sub-verifiers, custom Encoding types) can +//! drop down to [`transcript_builder`], which returns spongefish's +//! `DomainSeparator` with PROTOCOL_ID pre-bound — the full type-state +//! builder is then available. use rand::rngs::StdRng; -use spongefish::{DomainSeparator, DuplexSpongeInterface, Encoding, ProverState, VerifierState}; +use spongefish::{ + DomainSeparator, DuplexSpongeInterface, Encoding, ProverState, VerifierState, WithoutInstance, + WithoutSession, +}; use crate::codec::BytesMsg; /// Crate-wide spongefish protocol identifier (ASCII left, zero-padded to -/// 64 bytes). +/// 64 bytes). Pre-bound by every factory in this module; exposed so callers +/// who reach for [`transcript_builder`] can align on the same identifier. pub const PROTOCOL_ID: [u8; 64] = pad_id(b"a16z/jolt-transcript/spongefish/v1"); const fn pad_id(src: &[u8]) -> [u8; 64] { @@ -26,8 +49,8 @@ const fn pad_id(src: &[u8]) -> [u8; 64] { buf } -/// Empty `instance` value so the spongefish builder reaches the `to_prover` -/// stage. Encodes to zero bytes. +/// 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; @@ -37,25 +60,90 @@ impl Encoding<[u8]> for EmptyInstance { } } -/// Builds a fresh `ProverState` over `sponge` for the given session bytes. -pub fn to_prover(sponge: H, session: &[u8]) -> ProverState +/// 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`] / +/// [`verifier_transcript`]; the factory wraps internally. +#[derive(Clone, Copy, Debug, Default)] +struct InstanceDigest([u8; 32]); + +impl Encoding<[u8]> for InstanceDigest { + fn encode(&self) -> impl AsRef<[u8]> { + self.0 + } +} + +/// Build a prover transcript bound to `session` and `instance`. +/// +/// `session` is protocol-version bytes (e.g. `b"jolt-rv64imac/v1"`). +/// `instance` is the 32-byte digest of the public statement (typically +/// `Blake2b(CanonicalSerialize(public_state))`). Both are absorbed under +/// spongefish's domain-separator steps after [`PROTOCOL_ID`]. +/// +/// # Example +/// +/// ``` +/// use jolt_transcript::{prover_transcript, verifier_transcript, BytesMsg, ProverTranscript, VerifierTranscript}; +/// use spongefish::instantiations::Blake2b512; +/// +/// const SESSION: &[u8] = b"jolt-rv64imac/v1"; +/// let instance: [u8; 32] = [0xAB; 32]; +/// +/// let mut prover = prover_transcript(SESSION, instance, Blake2b512::default()); +/// ProverTranscript::::prover_message( +/// &mut prover, +/// &BytesMsg(b"commitment-a".to_vec()), +/// ); +/// let narg = ProverTranscript::::narg_string(&prover).to_vec(); +/// +/// let mut verifier = verifier_transcript(SESSION, instance, Blake2b512::default(), &narg); +/// let _: BytesMsg = +/// VerifierTranscript::::prover_message(&mut verifier).unwrap(); +/// VerifierTranscript::::check_eof(verifier).unwrap(); +/// ``` +#[must_use] +pub fn prover_transcript( + session: &[u8], + instance: [u8; 32], + sponge: H, +) -> ProverState where H: DuplexSpongeInterface, { DomainSeparator::new(PROTOCOL_ID) .session(BytesMsg(session.to_vec())) - .instance(EmptyInstance) + .instance(InstanceDigest(instance)) .to_prover(sponge) } -/// Builds a fresh `VerifierState` over `sponge` for the given session bytes -/// and NARG. -pub fn to_verifier<'a, H>(sponge: H, session: &[u8], narg: &'a [u8]) -> VerifierState<'a, H> +/// Build a verifier transcript bound to `session` and `instance` over +/// `narg`. The verifier MUST pass the same `session`/`instance` as the +/// prover or the transcript states diverge. +#[must_use] +pub fn verifier_transcript<'a, H>( + session: &[u8], + instance: [u8; 32], + sponge: H, + narg: &'a [u8], +) -> VerifierState<'a, H> where H: DuplexSpongeInterface, { DomainSeparator::new(PROTOCOL_ID) .session(BytesMsg(session.to_vec())) - .instance(EmptyInstance) + .instance(InstanceDigest(instance)) .to_verifier(sponge, narg) } + +/// Escape hatch returning spongefish's `DomainSeparator` with +/// [`PROTOCOL_ID`] pre-bound. Use only when the standard +/// `(session, instance)` shape doesn't fit — e.g. `BlindFold` sub-verifiers +/// that compose sub-domain-separators, or callers absorbing a non-32-byte +/// instance. +/// +/// Almost every Jolt caller should use [`prover_transcript`] / +/// [`verifier_transcript`] instead. +#[must_use] +pub fn transcript_builder() -> DomainSeparator { + DomainSeparator::new(PROTOCOL_ID) +} diff --git a/jolt-eval/src/invariant/transcript_symmetry.rs b/jolt-eval/src/invariant/transcript_symmetry.rs index 7effdb854e..3654871370 100644 --- a/jolt-eval/src/invariant/transcript_symmetry.rs +++ b/jolt-eval/src/invariant/transcript_symmetry.rs @@ -7,11 +7,14 @@ use arbitrary::{Arbitrary, Unstructured}; use jolt_field::Fr as JFr; use spongefish::instantiations::{Blake2b512, Keccak}; -use jolt_transcript::{to_prover, to_verifier, BytesMsg, FieldEl, PoseidonSponge}; +use jolt_transcript::{ + prover_transcript, verifier_transcript, BytesMsg, FieldEl, PoseidonSponge, +}; 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)] @@ -67,7 +70,7 @@ fn run_check(input: &Input, build_sponge: impl Fn() -> H) -> Result<(), Check where H: spongefish::DuplexSpongeInterface, { - let mut prover = to_prover(build_sponge(), SESSION); + let mut prover = prover_transcript(SESSION, INSTANCE_DIGEST, build_sponge()); let mut prover_challenges: Vec = Vec::new(); for op in &input.ops { @@ -84,7 +87,7 @@ where } let narg: Vec = prover.narg_string().to_vec(); - let mut verifier = to_verifier(build_sponge(), SESSION, &narg); + let mut verifier = verifier_transcript(SESSION, INSTANCE_DIGEST, build_sponge(), &narg); let mut challenge_idx = 0usize; for (op_idx, op) in input.ops.iter().enumerate() { From b82947c0836b0689beb109bc9cdd98f1a506a5f6 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Tue, 19 May 2026 22:49:36 +0530 Subject: [PATCH 17/21] docs(jolt-transcript): check_eof contract + regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Strengthen the `VerifierTranscript::check_eof` doc-comment to spell out the malleability consequence of skipping it and the contract for callers: invoke once at the end of the top-level verify function on the success path; error paths skip it. Add `tests/narg_eof_tests.rs` covering the regression: exact-NARG verifies, trailing 7 bytes / 1 byte rejects, unread prover messages reject. Add `tests/native_traits_tests.rs` covering the native split-trait surface (`ProverTranscript`, `VerifierTranscript`, `OptimizedChallenge`) — round-trip, 128-bit truncation, session and instance domain separation — for Blake2b and Keccak backends. Per mmaker's review on a16z/jolt#1455. --- crates/jolt-transcript/src/verifier.rs | 13 ++ .../jolt-transcript/tests/narg_eof_tests.rs | 102 ++++++++++++++++ .../tests/native_traits_tests.rs | 113 ++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 crates/jolt-transcript/tests/narg_eof_tests.rs create mode 100644 crates/jolt-transcript/tests/native_traits_tests.rs diff --git a/crates/jolt-transcript/src/verifier.rs b/crates/jolt-transcript/src/verifier.rs index d89fc3d705..7be0d04302 100644 --- a/crates/jolt-transcript/src/verifier.rs +++ b/crates/jolt-transcript/src/verifier.rs @@ -19,6 +19,19 @@ pub trait VerifierTranscript { fn verifier_message>(&mut self) -> T; /// Asserts the NARG was fully consumed. + /// + /// Soundness-critical: without this check, `valid_proof || garbage` + /// verifies as if the trailing bytes were absent, making the top-level + /// proof bytes malleable. Call once at the end of the top-level verify + /// function on the success path; error paths skip it (the proof is + /// already rejected). Composite verifiers follow the same rule at + /// their own finalize boundary. + /// + /// Takes `self` by value to prevent reuse. + /// + /// # Errors + /// + /// Returns `Err` if the NARG has unread bytes. fn check_eof(self) -> VerificationResult<()>; } diff --git a/crates/jolt-transcript/tests/narg_eof_tests.rs b/crates/jolt-transcript/tests/narg_eof_tests.rs new file mode 100644 index 0000000000..e12085d598 --- /dev/null +++ b/crates/jolt-transcript/tests/narg_eof_tests.rs @@ -0,0 +1,102 @@ +//! Soundness regression: NARG strings appended with trailing garbage must +//! be rejected by `check_eof`. Without this check, `valid_proof || garbage` +//! would round-trip through verification, making top-level proof bytes +//! malleable. +//! +//! See PR #1455 review for the original report. + +#![cfg(feature = "transcript-blake2b")] +#![expect(clippy::expect_used, reason = "tests")] + +use jolt_transcript::{ + prover_transcript, verifier_transcript, BytesMsg, ProverTranscript, VerifierTranscript, + PROTOCOL_ID, +}; +use spongefish::instantiations::Blake2b512; + +const SESSION: &[u8] = b"narg-eof-test"; +const INSTANCE: [u8; 32] = [0x42; 32]; + +fn build_valid_narg(messages: &[&[u8]]) -> Vec { + let mut prover = prover_transcript(SESSION, INSTANCE, Blake2b512::default()); + for m in messages { + ProverTranscript::::prover_message(&mut prover, &BytesMsg(m.to_vec())); + } + ProverTranscript::::narg_string(&prover).to_vec() +} + +fn build_verifier(narg: &[u8]) -> spongefish::VerifierState<'_, Blake2b512> { + verifier_transcript(SESSION, INSTANCE, Blake2b512::default(), narg) +} + +#[test] +fn check_eof_accepts_exact_narg() { + let msgs: &[&[u8]] = &[b"alpha", b"beta", b"gamma"]; + let narg = build_valid_narg(msgs); + + let mut verifier = build_verifier(&narg); + for expected in msgs { + let got: BytesMsg = VerifierTranscript::::prover_message(&mut verifier) + .expect("valid prover message must deserialize"); + assert_eq!(got.as_slice(), *expected); + } + VerifierTranscript::::check_eof(verifier).expect("exact narg must pass check_eof"); +} + +#[test] +fn check_eof_rejects_trailing_garbage() { + let msgs: &[&[u8]] = &[b"alpha", b"beta", b"gamma"]; + let mut narg = build_valid_narg(msgs); + let original_len = narg.len(); + narg.extend_from_slice(&[0xFFu8; 7]); + + let mut verifier = build_verifier(&narg); + for expected in msgs { + let got: BytesMsg = VerifierTranscript::::prover_message(&mut verifier) + .expect("valid prefix must deserialize"); + assert_eq!(got.as_slice(), *expected); + } + let result = VerifierTranscript::::check_eof(verifier); + assert!( + result.is_err(), + "narg with {} trailing bytes (original_len={}) must fail check_eof", + 7, + original_len, + ); +} + +#[test] +fn check_eof_rejects_single_trailing_byte() { + let narg = { + let mut n = build_valid_narg(&[b"only"]); + n.push(0x00); + n + }; + let mut verifier = build_verifier(&narg); + let _: BytesMsg = VerifierTranscript::::prover_message(&mut verifier) + .expect("valid prefix must deserialize"); + assert!( + VerifierTranscript::::check_eof(verifier).is_err(), + "even a single trailing byte must fail check_eof", + ); +} + +#[test] +fn check_eof_rejects_unread_messages() { + let msgs: &[&[u8]] = &[b"alpha", b"beta"]; + let narg = build_valid_narg(msgs); + + let mut verifier = build_verifier(&narg); + let _: BytesMsg = VerifierTranscript::::prover_message(&mut verifier) + .expect("first message must deserialize"); + assert!( + VerifierTranscript::::check_eof(verifier).is_err(), + "leaving prover messages unread must fail check_eof", + ); +} + +#[test] +fn protocol_id_byte_check() { + assert_eq!(&PROTOCOL_ID[..34], b"a16z/jolt-transcript/spongefish/v1"); + assert!(PROTOCOL_ID[34..].iter().all(|&b| b == 0)); +} diff --git a/crates/jolt-transcript/tests/native_traits_tests.rs b/crates/jolt-transcript/tests/native_traits_tests.rs new file mode 100644 index 0000000000..aef35ad7d0 --- /dev/null +++ b/crates/jolt-transcript/tests/native_traits_tests.rs @@ -0,0 +1,113 @@ +//! Coverage for the native split-trait surface — `ProverTranscript`, +//! `VerifierTranscript`, `OptimizedChallenge`. The existing per-backend +//! `transcript_tests!` macro exercises only the `legacy::Transcript` +//! facade. + +#![cfg(any(feature = "transcript-blake2b", feature = "transcript-keccak"))] +#![expect(clippy::expect_used, reason = "tests")] + +use jolt_field::Fr; +use jolt_transcript::{ + prover_transcript, verifier_transcript, BytesMsg, OptimizedChallenge, ProverTranscript, + VerifierTranscript, +}; + +const SESSION: &[u8] = b"native-traits"; +const INSTANCE: [u8; 32] = [0x77; 32]; + +#[cfg(feature = "transcript-blake2b")] +mod blake2b { + use super::*; + use spongefish::instantiations::Blake2b512; + + #[test] + fn prover_verifier_round_trip() { + let mut prover = prover_transcript(SESSION, INSTANCE, Blake2b512::default()); + ProverTranscript::::public_message(&mut prover, &BytesMsg(b"pub".to_vec())); + ProverTranscript::::prover_message(&mut prover, &BytesMsg(b"private".to_vec())); + let _c1: Fr = + as OptimizedChallenge>::challenge_128(&mut prover); + let narg = ProverTranscript::::narg_string(&prover).to_vec(); + + let mut verifier = verifier_transcript(SESSION, INSTANCE, Blake2b512::default(), &narg); + VerifierTranscript::::public_message(&mut verifier, &BytesMsg(b"pub".to_vec())); + let got: BytesMsg = VerifierTranscript::::prover_message(&mut verifier) + .expect("prover_message must deserialize"); + assert_eq!(got.as_slice(), b"private"); + let _c2: Fr = + as OptimizedChallenge>::challenge_128( + &mut verifier, + ); + VerifierTranscript::::check_eof(verifier).expect("eof"); + } + + #[test] + fn optimized_challenge_is_128_bit_truncated() { + use ark_ff::PrimeField; + let mut prover = prover_transcript(SESSION, INSTANCE, Blake2b512::default()); + let c: Fr = + as OptimizedChallenge>::challenge_128(&mut prover); + let ark_c: ark_bn254::Fr = c.into(); + let bigint = ark_c.into_bigint().0; + assert_eq!( + bigint[2], 0, + "challenge_128 must fit in the low 128 bits — limb 2 leaked", + ); + assert_eq!( + bigint[3], 0, + "challenge_128 must fit in the low 128 bits — limb 3 leaked", + ); + } + + #[test] + fn distinct_sessions_diverge() { + let mut a = prover_transcript(b"a", INSTANCE, Blake2b512::default()); + let mut b = prover_transcript(b"b", INSTANCE, Blake2b512::default()); + let ca: Fr = + as OptimizedChallenge>::challenge_128(&mut a); + let cb: Fr = + as OptimizedChallenge>::challenge_128(&mut b); + assert_ne!( + ca, cb, + "distinct session bytes must yield distinct first challenge" + ); + } + + #[test] + fn distinct_instances_diverge() { + let mut a = prover_transcript(SESSION, [0x11; 32], Blake2b512::default()); + let mut b = prover_transcript(SESSION, [0x22; 32], Blake2b512::default()); + let ca: Fr = + as OptimizedChallenge>::challenge_128(&mut a); + let cb: Fr = + as OptimizedChallenge>::challenge_128(&mut b); + assert_ne!( + ca, cb, + "distinct instance digests must yield distinct first challenge", + ); + } +} + +#[cfg(feature = "transcript-keccak")] +mod keccak { + use super::*; + use spongefish::instantiations::Keccak; + + #[test] + fn prover_verifier_round_trip() { + let mut prover = prover_transcript(SESSION, INSTANCE, Keccak::default()); + ProverTranscript::::prover_message(&mut prover, &BytesMsg(b"a".to_vec())); + let _c: Fr = + as OptimizedChallenge>::challenge_128(&mut prover); + let narg = ProverTranscript::::narg_string(&prover).to_vec(); + + let mut verifier = verifier_transcript(SESSION, INSTANCE, Keccak::default(), &narg); + let got: BytesMsg = + VerifierTranscript::::prover_message(&mut verifier).expect("ok"); + assert_eq!(got.as_slice(), b"a"); + let _c2: Fr = as OptimizedChallenge>::challenge_128( + &mut verifier, + ); + VerifierTranscript::::check_eof(verifier).expect("eof"); + } +} From 1e623e698ccf1c274c65bfeccda969c122a86315 Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Tue, 19 May 2026 22:50:22 +0530 Subject: [PATCH 18/21] chore(jolt-transcript): rename PROTOCOL_ID to drop "spongefish" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROTOCOL_ID should name the protocol, not the cryptographic substrate it runs on. Spongefish is the duplex-sponge framework we're built on; it's an implementation choice, not the identity of this transcript. Change `a16z/jolt-transcript/spongefish/v1` → `a16z/jolt-transcript/v1`. Wire-format-breaking — PROTOCOL_ID is absorbed first, so every downstream challenge shifts. Repin all four known-vector tests (Blake2b, Keccak, Poseidon, narg_eof PROTOCOL_ID byte check). Spec doc updated to match. Acceptable here because the spongefish-based wire format isn't shipped yet. --- crates/jolt-transcript/src/setup.rs | 2 +- crates/jolt-transcript/tests/blake2b_tests.rs | 4 ++-- crates/jolt-transcript/tests/keccak_tests.rs | 4 ++-- crates/jolt-transcript/tests/narg_eof_tests.rs | 4 ++-- crates/jolt-transcript/tests/poseidon_tests.rs | 4 ++-- specs/jolt-transcript-spongefish.md | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/crates/jolt-transcript/src/setup.rs b/crates/jolt-transcript/src/setup.rs index a0fd71973f..5b27b4524a 100644 --- a/crates/jolt-transcript/src/setup.rs +++ b/crates/jolt-transcript/src/setup.rs @@ -36,7 +36,7 @@ use crate::codec::BytesMsg; /// Crate-wide spongefish protocol identifier (ASCII left, zero-padded to /// 64 bytes). Pre-bound by every factory in this module; exposed so callers /// who reach for [`transcript_builder`] can align on the same identifier. -pub const PROTOCOL_ID: [u8; 64] = pad_id(b"a16z/jolt-transcript/spongefish/v1"); +pub const PROTOCOL_ID: [u8; 64] = pad_id(b"a16z/jolt-transcript/v1"); const fn pad_id(src: &[u8]) -> [u8; 64] { assert!(src.len() <= 64, "protocol id exceeds 64 bytes"); diff --git a/crates/jolt-transcript/tests/blake2b_tests.rs b/crates/jolt-transcript/tests/blake2b_tests.rs index 19d17467f8..4dfbe4165b 100644 --- a/crates/jolt-transcript/tests/blake2b_tests.rs +++ b/crates/jolt-transcript/tests/blake2b_tests.rs @@ -22,8 +22,8 @@ fn test_blake2b_known_vector() { // encoding, the append_bytes layout, or the challenge decoder will // flip these bytes. Update only with an audit trail. let expected: ark_bn254::Fr = ark_bn254::Fr::from_le_bytes_mod_order(&[ - 0x6B, 0xAE, 0x98, 0xBB, 0x70, 0x31, 0xDE, 0xEA, 0x8B, 0x57, 0x22, 0xB0, 0x0F, 0xC5, 0x83, - 0x62, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xC2, 0x67, 0xDF, 0x28, 0x0C, 0xEA, 0xA8, 0xD7, 0x19, 0x6E, 0x25, 0xB3, 0x32, 0x4A, 0x47, + 0x18, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]); let got: ark_bn254::Fr = challenge.into(); diff --git a/crates/jolt-transcript/tests/keccak_tests.rs b/crates/jolt-transcript/tests/keccak_tests.rs index ea822c72be..1b30219401 100644 --- a/crates/jolt-transcript/tests/keccak_tests.rs +++ b/crates/jolt-transcript/tests/keccak_tests.rs @@ -20,8 +20,8 @@ fn test_keccak_known_vector() { // Pinned wire-format check; see Blake2b counterpart for rationale. let expected: ark_bn254::Fr = ark_bn254::Fr::from_le_bytes_mod_order(&[ - 0x1E, 0x1D, 0x14, 0x83, 0xB5, 0x56, 0xB0, 0x9C, 0x1C, 0xEC, 0x84, 0x40, 0x02, 0x78, 0x38, - 0x5F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x14, 0x84, 0xCC, 0x16, 0xD8, 0x2A, 0x56, 0x4C, 0x06, 0x01, 0xCB, 0xDB, 0x3E, 0xE5, 0xB6, + 0xE8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]); let got: ark_bn254::Fr = challenge.into(); diff --git a/crates/jolt-transcript/tests/narg_eof_tests.rs b/crates/jolt-transcript/tests/narg_eof_tests.rs index e12085d598..4de2ea6f29 100644 --- a/crates/jolt-transcript/tests/narg_eof_tests.rs +++ b/crates/jolt-transcript/tests/narg_eof_tests.rs @@ -97,6 +97,6 @@ fn check_eof_rejects_unread_messages() { #[test] fn protocol_id_byte_check() { - assert_eq!(&PROTOCOL_ID[..34], b"a16z/jolt-transcript/spongefish/v1"); - assert!(PROTOCOL_ID[34..].iter().all(|&b| b == 0)); + assert_eq!(&PROTOCOL_ID[..23], b"a16z/jolt-transcript/v1"); + assert!(PROTOCOL_ID[23..].iter().all(|&b| b == 0)); } diff --git a/crates/jolt-transcript/tests/poseidon_tests.rs b/crates/jolt-transcript/tests/poseidon_tests.rs index 9f0fab91ec..04825bb857 100644 --- a/crates/jolt-transcript/tests/poseidon_tests.rs +++ b/crates/jolt-transcript/tests/poseidon_tests.rs @@ -20,8 +20,8 @@ fn test_poseidon_known_vector() { // Pinned wire-format check; see Blake2b counterpart for rationale. let expected: ark_bn254::Fr = ark_bn254::Fr::from_le_bytes_mod_order(&[ - 0xF8, 0x63, 0xA0, 0x6D, 0xF7, 0xFC, 0xCF, 0x35, 0xC3, 0xD1, 0x85, 0x0C, 0xC1, 0x9C, 0x2D, - 0x7E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x84, 0xC7, 0xFD, 0xF6, 0x80, 0xC5, 0x5D, 0xB5, 0xB0, 0x7D, 0x2F, 0x68, 0x6F, 0x82, 0x89, + 0xA3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]); let got: ark_bn254::Fr = challenge.into(); diff --git a/specs/jolt-transcript-spongefish.md b/specs/jolt-transcript-spongefish.md index 6c25b5afdd..2c645de0b9 100644 --- a/specs/jolt-transcript-spongefish.md +++ b/specs/jolt-transcript-spongefish.md @@ -93,7 +93,7 @@ Rationale: transcript operations are fundamentally hash calls (absorb, squeeze). - Adds `spongefish` `0.7.x` and `light-poseidon` workspace dependencies. Enable only the spongefish hash features required for the selected backends; do not enable spongefish arkworks codec features unless they are proven compatible with Jolt's patched `ark-ff` / `ark-serialize` dependency graph. - Defines `ProverTranscript` and `VerifierTranscript` traits with positional method signatures matching spongefish-native shape. Domain separation lives in the one-time `DomainSeparator` used at transcript construction. Production spongefish consumers (WhiR, sigma-rs) use this same positional style. Note: the current `crates/jolt-transcript/src/transcript.rs` is already positional per-call; the positional-API choice is structural preparation for the follow-up jolt-core migration, where jolt-core's labeled per-call style (`append_scalar(b"opening_claim", &x)` at ~148 callsites in `jolt-core/src/transcripts/`) will transform into positional `prover_message(&x)` calls. Within this PR's scope, no jolt-core callsite transformation is observable. -- Uses a concrete Spongefish domain setup before constructing prover/verifier states: `DomainSeparator::new(JOLT_TRANSCRIPT_PROTOCOL_ID)`, where the 64-byte protocol id is ASCII `a16z/jolt-transcript/spongefish/v1` followed by zero padding; an explicit session decision (`session(...)` or `without_session()`); and a prefix-free instance encoding. The instance encoding is a local codec struct containing at least a version tag, sponge/backend id, challenge-width mode, and length-prefixed caller instance bytes. For the compatibility facade, `Transcript::new(label)` maps `label` into the session context with length-prefixing; the native split API requires callers to choose either an explicit session value or `without_session()` and to bind an instance value before calling `to_prover` / `to_verifier`. +- Uses a concrete Spongefish domain setup before constructing prover/verifier states: `DomainSeparator::new(JOLT_TRANSCRIPT_PROTOCOL_ID)`, where the 64-byte protocol id is ASCII `a16z/jolt-transcript/v1` followed by zero padding; an explicit session decision (`session(...)` or `without_session()`); and a prefix-free instance encoding. The instance encoding is a local codec struct containing at least a version tag, sponge/backend id, challenge-width mode, and length-prefixed caller instance bytes. For the compatibility facade, `Transcript::new(label)` maps `label` into the session context with length-prefixing; the native split API requires callers to choose either an explicit session value or `without_session()` and to bind an instance value before calling `to_prover` / `to_verifier`. - Provides local codec/newtype implementations for Jolt field/scalar/byte/message types instead of relying on spongefish's optional arkworks codecs. Jolt-local encodings must remain injective and prefix-free; the compatibility facade must preserve today's scalar endianness and label/count packing semantics for existing modular consumers. - Challenge decoders implement `spongefish::Decoding<[H::U]>`. Per-sponge contract: - **Blake2b** and **Keccak**: both a 128-bit-truncating optimized decoder and a full-field decoder. From 2f1215f7186988505daaae42b3c481e1e0784cdf Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Tue, 19 May 2026 23:01:18 +0530 Subject: [PATCH 19/21] refactor(jolt-transcript): drop FieldEl, use spongefish ark-ff codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Downgrade spongefish 0.7 → 0.6.1 and switch `[patch.crates-io]` → `[replace]`. Spongefish 0.6.1 requires `ark-ff ^0.5`, matching the workspace's pinned `a16z/arkworks-algebra@dev/twist-shout` fork (0.5.0); `[replace]` overrides by exact `name:version` so the spongefish-side transitive ark-ff dep resolves to our fork, eliminating the multi-version skew that forced the local `FieldEl` wrapper. Enable `spongefish/ark-ff` in jolt-transcript and delete `FieldEl` and `UniformFrBytes`. Field absorbs now go through spongefish's `Encoding<[u8]>` for `ark_ff::Fp` directly — big-endian canonical encoding per RFC8017. `codec.rs` retains only `BytesMsg` (length-prefixed byte string framing) since spongefish lacks a length-prefixed Vec. Wire-format-breaking for scalar absorbs (LE → BE). Known-vector tests in this crate use `append_bytes` (byte-level) not field absorbs, so they are unaffected. `jolt-eval/transcript_symmetry` updated to use `ark_bn254::Fr` directly (spongefish's Encoding impl is on `Fp`, not on the `jolt_field::Fr` newtype — orphan rule blocks forwarding impls inside jolt-transcript). Per mmaker's review on a16z/jolt#1455. --- Cargo.lock | 173 +++++++++++------- Cargo.toml | 14 +- crates/jolt-transcript/Cargo.toml | 2 +- crates/jolt-transcript/src/codec.rs | 85 +-------- crates/jolt-transcript/src/lib.rs | 2 +- .../src/invariant/transcript_symmetry.rs | 25 ++- 6 files changed, 134 insertions(+), 167 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 175cd7b9c1..903353f5ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,6 +118,13 @@ dependencies = [ "cc", ] +[[package]] +name = "allocative" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fac2ce611db8b8cee9b2aa886ca03c924e9da5e5295d0dbd0526e5d0b0710f7" +replace = "allocative 0.3.4 (git+https://github.com/facebookexperimental/allocative?rev=85b773d85d526d068ce94724ff7a7b81203fc95e)" + [[package]] name = "allocative" version = "0.3.4" @@ -559,26 +566,40 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "ark-bn254" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" +replace = "ark-bn254 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)" + [[package]] name = "ark-bn254" version = "0.5.0" source = "git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout#76bb3a4518928f1ff7f15875f940d614bb9845e6" dependencies = [ - "ark-ec", - "ark-ff 0.5.0", - "ark-serialize 0.5.0", + "ark-ec 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)", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "ark-std 0.5.0", ] +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +replace = "ark-ec 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)" + [[package]] name = "ark-ec" version = "0.5.0" source = "git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout#76bb3a4518928f1ff7f15875f940d614bb9845e6" dependencies = [ "ahash", - "ark-ff 0.5.0", + "ark-ff 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "ark-poly", - "ark-serialize 0.5.0", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "ark-std 0.5.0", "educe", "fnv", @@ -629,15 +650,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +replace = "ark-ff 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)" + [[package]] name = "ark-ff" version = "0.5.0" source = "git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout#76bb3a4518928f1ff7f15875f940d614bb9845e6" dependencies = [ - "allocative", + "allocative 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", "ark-ff-asm 0.5.0", "ark-ff-macros 0.5.0", - "ark-serialize 0.5.0", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "ark-std 0.5.0", "arrayvec", "digest 0.10.7", @@ -721,9 +749,9 @@ name = "ark-grumpkin" version = "0.5.0" source = "git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout#76bb3a4518928f1ff7f15875f940d614bb9845e6" dependencies = [ - "ark-bn254", - "ark-ec", - "ark-ff 0.5.0", + "ark-bn254 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", + "ark-ec 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)", "ark-std 0.5.0", ] @@ -733,8 +761,8 @@ version = "0.5.0" source = "git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout#76bb3a4518928f1ff7f15875f940d614bb9845e6" dependencies = [ "ahash", - "ark-ff 0.5.0", - "ark-serialize 0.5.0", + "ark-ff 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "ark-std 0.5.0", "educe", "fnv", @@ -746,8 +774,8 @@ name = "ark-secp256k1" version = "0.5.0" source = "git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout#76bb3a4518928f1ff7f15875f940d614bb9845e6" dependencies = [ - "ark-ec", - "ark-ff 0.5.0", + "ark-ec 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)", "ark-std 0.5.0", ] @@ -756,8 +784,8 @@ name = "ark-secp256r1" version = "0.5.0" source = "git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout#76bb3a4518928f1ff7f15875f940d614bb9845e6" dependencies = [ - "ark-ec", - "ark-ff 0.5.0", + "ark-ec 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)", "ark-std 0.5.0", ] @@ -782,6 +810,13 @@ dependencies = [ "num-bigint", ] +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +replace = "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)" + [[package]] name = "ark-serialize" version = "0.5.0" @@ -1349,8 +1384,8 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" name = "common" version = "0.2.0" dependencies = [ - "allocative", - "ark-serialize 0.5.0", + "allocative 0.3.4 (git+https://github.com/facebookexperimental/allocative?rev=85b773d85d526d068ce94724ff7a7b81203fc95e)", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "serde", "syn 2.0.117", ] @@ -1842,10 +1877,10 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c58baea9f0ed973489cd1981b0e6a8c91aafddb05e3903b1dd54175ddcb52d" dependencies = [ - "ark-bn254", - "ark-ec", - "ark-ff 0.5.0", - "ark-serialize 0.5.0", + "ark-bn254 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "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)", "ark-std 0.5.0", "bincode 1.3.3", "blake2 0.10.6", @@ -2726,7 +2761,7 @@ name = "jolt" version = "0.1.0" dependencies = [ "anyhow", - "ark-bn254", + "ark-bn254 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "clap", "common", "env_logger", @@ -2745,11 +2780,11 @@ dependencies = [ name = "jolt-core" version = "0.1.0" dependencies = [ - "allocative", - "ark-bn254", - "ark-ec", - "ark-ff 0.5.0", - "ark-serialize 0.5.0", + "allocative 0.3.4 (git+https://github.com/facebookexperimental/allocative?rev=85b773d85d526d068ce94724ff7a7b81203fc95e)", + "ark-bn254 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", + "ark-ec 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)", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "ark-std 0.5.0", "bincode 2.0.1", "blake2 0.11.0-rc.6", @@ -2798,10 +2833,10 @@ dependencies = [ name = "jolt-crypto" version = "0.1.0" dependencies = [ - "ark-bn254", - "ark-ec", - "ark-ff 0.5.0", - "ark-serialize 0.5.0", + "ark-bn254 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", + "ark-ec 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)", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "ark-std 0.5.0", "bincode 2.0.1", "criterion", @@ -2821,7 +2856,7 @@ dependencies = [ name = "jolt-dory" version = "0.1.0" dependencies = [ - "ark-serialize 0.5.0", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "criterion", "dory-pcs", "jolt-crypto", @@ -2842,7 +2877,7 @@ name = "jolt-eval" version = "0.1.0" dependencies = [ "arbitrary", - "ark-bn254", + "ark-bn254 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "clap", "common", "criterion", @@ -2880,10 +2915,10 @@ dependencies = [ name = "jolt-field" version = "0.1.0" dependencies = [ - "allocative", - "ark-bn254", - "ark-ff 0.5.0", - "ark-serialize 0.5.0", + "allocative 0.3.4 (git+https://github.com/facebookexperimental/allocative?rev=85b773d85d526d068ce94724ff7a7b81203fc95e)", + "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)", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "ark-std 0.5.0", "criterion", "num-traits", @@ -2897,7 +2932,7 @@ dependencies = [ name = "jolt-inlines-bigint" version = "0.1.0" dependencies = [ - "ark-ff 0.5.0", + "ark-ff 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "jolt-inlines-sdk", "rand 0.8.6", "tracer", @@ -2928,8 +2963,8 @@ dependencies = [ name = "jolt-inlines-grumpkin" version = "0.1.0" dependencies = [ - "ark-ec", - "ark-ff 0.5.0", + "ark-ec 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)", "ark-grumpkin", "jolt-inlines-sdk", "serde", @@ -2950,8 +2985,8 @@ dependencies = [ name = "jolt-inlines-p256" version = "0.1.0" dependencies = [ - "ark-ec", - "ark-ff 0.5.0", + "ark-ec 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)", "ark-secp256r1", "jolt-inlines-sdk", "num-bigint", @@ -2976,7 +3011,7 @@ dependencies = [ name = "jolt-inlines-secp256k1" version = "0.1.0" dependencies = [ - "ark-ff 0.5.0", + "ark-ff 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "ark-secp256k1", "jolt-inlines-sdk", "num-bigint", @@ -3028,10 +3063,10 @@ name = "jolt-optimizations" version = "0.5.0" source = "git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout#76bb3a4518928f1ff7f15875f940d614bb9845e6" dependencies = [ - "ark-bn254", - "ark-ec", - "ark-ff 0.5.0", - "ark-serialize 0.5.0", + "ark-bn254 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", + "ark-ec 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)", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "ark-std 0.5.0", "arrayvec", "num-bigint", @@ -3070,7 +3105,7 @@ dependencies = [ name = "jolt-profiling" version = "0.1.0" dependencies = [ - "allocative", + "allocative 0.3.4 (git+https://github.com/facebookexperimental/allocative?rev=85b773d85d526d068ce94724ff7a7b81203fc95e)", "inferno 0.12.6", "memory-stats", "pprof", @@ -3085,7 +3120,7 @@ dependencies = [ name = "jolt-program" version = "0.1.0" dependencies = [ - "ark-serialize 0.5.0", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "common", "jolt-riscv", "object 0.39.1", @@ -3111,7 +3146,7 @@ dependencies = [ name = "jolt-riscv" version = "0.1.0" dependencies = [ - "ark-serialize 0.5.0", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "rand 0.8.6", "serde", "strum 0.28.0", @@ -3163,8 +3198,8 @@ dependencies = [ name = "jolt-transcript" version = "0.1.0" dependencies = [ - "ark-bn254", - "ark-ff 0.5.0", + "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", "jolt-field", "light-poseidon", @@ -3282,8 +3317,8 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47a1ccadd0bb5a32c196da536fd72c59183de24a055f6bf0513bf845fefab862" dependencies = [ - "ark-bn254", - "ark-ff 0.5.0", + "ark-bn254 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)", "num-bigint", "thiserror 1.0.69", ] @@ -4673,7 +4708,7 @@ dependencies = [ name = "recursion" version = "0.1.0" dependencies = [ - "ark-serialize 0.5.0", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "clap", "jolt-sdk", "postcard", @@ -4685,7 +4720,7 @@ dependencies = [ name = "recursion-guest" version = "0.1.0" dependencies = [ - "ark-serialize 0.5.0", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "jolt-sdk", ] @@ -4942,7 +4977,7 @@ dependencies = [ "alloy-rlp", "ark-ff 0.3.0", "ark-ff 0.4.2", - "ark-ff 0.5.0", + "ark-ff 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", "bytes", "fastrlp 0.3.1", "fastrlp 0.4.0", @@ -5648,10 +5683,12 @@ dependencies = [ [[package]] name = "spongefish" -version = "0.7.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a2b3b9771b4059c025d11039a51294f7ccd9430d594e79aada5fca1e7cb4e3" +checksum = "074f823019979d89e8d46a966feb3d173f3db9a21c6764f8c2282e137017bba5" dependencies = [ + "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", "digest 0.11.3", "keccak 0.1.6", @@ -6019,7 +6056,7 @@ name = "tracer" version = "0.2.0" dependencies = [ "addr2line 0.26.1", - "ark-serialize 0.5.0", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "clap", "common", "derive_more", @@ -6117,9 +6154,9 @@ dependencies = [ name = "transpiler" version = "0.1.0" dependencies = [ - "ark-bn254", - "ark-ff 0.5.0", - "ark-serialize 0.5.0", + "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)", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "ark-std 0.5.0", "clap", "common", @@ -7136,10 +7173,10 @@ dependencies = [ name = "zklean-extractor" version = "0.1.0" dependencies = [ - "allocative", - "ark-bn254", - "ark-ff 0.5.0", - "ark-serialize 0.5.0", + "allocative 0.3.4 (git+https://github.com/facebookexperimental/allocative?rev=85b773d85d526d068ce94724ff7a7b81203fc95e)", + "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)", + "ark-serialize 0.5.0 (git+https://github.com/a16z/arkworks-algebra?branch=dev%2Ftwist-shout)", "ark-std 0.5.0", "build-fs-tree", "clap", diff --git a/Cargo.toml b/Cargo.toml index bc3a7eacef..e7f47bdb10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -161,12 +161,12 @@ lto = "fat" strip = false codegen-units = 1 -[patch.crates-io] -ark-bn254 = { 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-ec = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" } -ark-serialize = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" } -allocative = { git = "https://github.com/facebookexperimental/allocative", rev = "85b773d85d526d068ce94724ff7a7b81203fc95e" } +[replace] +"ark-bn254:0.5.0" = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" } +"ark-ff:0.5.0" = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" } +"ark-ec:0.5.0" = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" } +"ark-serialize:0.5.0" = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" } +"allocative:0.3.4" = { git = "https://github.com/facebookexperimental/allocative", rev = "85b773d85d526d068ce94724ff7a7b81203fc95e" } [workspace.metadata.cargo-machete] ignored = ["jolt-sdk"] @@ -253,7 +253,7 @@ blake2 = "0.11.0-rc.6" blake3 = { version = "1.5.0" } light-poseidon = "0.4" digest = "0.11" -spongefish = { version = "0.7", default-features = false, features = ["sha3"] } +spongefish = { version = "0.6.1", default-features = false, features = ["sha3"] } jolt-optimizations = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" } dory = { package = "dory-pcs", version = "0.3.0", features = [ "backends", diff --git a/crates/jolt-transcript/Cargo.toml b/crates/jolt-transcript/Cargo.toml index 01a844e8c9..6e84889d6e 100644 --- a/crates/jolt-transcript/Cargo.toml +++ b/crates/jolt-transcript/Cargo.toml @@ -21,7 +21,7 @@ transcript-poseidon = ["dep:light-poseidon"] ark-bn254.workspace = true ark-ff.workspace = true light-poseidon = { workspace = true, optional = true } -spongefish = { workspace = true } +spongefish = { workspace = true, features = ["ark-ff"] } jolt-field = { path = "../jolt-field", features = ["bn254"] } rand.workspace = true diff --git a/crates/jolt-transcript/src/codec.rs b/crates/jolt-transcript/src/codec.rs index 4ea340ee93..e6e2ee8c69 100644 --- a/crates/jolt-transcript/src/codec.rs +++ b/crates/jolt-transcript/src/codec.rs @@ -1,75 +1,16 @@ //! Local codecs for absorbing / decoding Jolt-native messages over a //! byte-oriented spongefish sponge. //! -//! Spongefish 0.7 ships an `ark-ff` codec feature, but enabling it pulls -//! in `ark-ff ^0.6` which conflicts with this workspace's pinned -//! `a16z/arkworks-algebra@dev/twist-shout` fork (still on the 0.5 -//! version line). Until the fork bumps to 0.6 we keep these local -//! field-element codecs. Encodings are injective and prefix-free. +//! 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`]. //! -//! 128-bit-truncated challenges decode through spongefish's built-in -//! `u128` codec directly (no wrapper needed); see -//! [`crate::prover::OptimizedChallenge`]. +//! This module keeps only `BytesMsg`, the length-prefixed byte string +//! framing that spongefish 0.6 does not provide. -use jolt_field::{CanonicalBytes, FixedByteSize, ReducingBytes}; -use spongefish::{Decoding, Encoding, NargDeserialize, VerificationError, VerificationResult}; - -/// Bytes drawn per full-field challenge. 64 bytes mod a ≤254-bit field -/// modulus is within `2^{-130}` statistical distance of uniform. Tuned for -/// BN254; safe for any field up to that width. -const FR_UNIFORM_BYTES: usize = 64; - -/// Wraps a field element for absorption / decoding as little-endian bytes. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct FieldEl(pub F); - -impl From for FieldEl { - fn from(f: F) -> Self { - Self(f) - } -} - -impl Encoding<[u8]> for FieldEl { - fn encode(&self) -> impl AsRef<[u8]> { - self.0.to_bytes_le_vec() - } -} - -/// 64-byte squeeze buffer used as the [`Decoding::Repr`] for full-field -/// challenges. See `FR_UNIFORM_BYTES`. -#[derive(Clone, Copy)] -pub struct UniformFrBytes(pub [u8; FR_UNIFORM_BYTES]); - -impl Default for UniformFrBytes { - fn default() -> Self { - Self([0u8; FR_UNIFORM_BYTES]) - } -} - -impl AsMut<[u8]> for UniformFrBytes { - fn as_mut(&mut self) -> &mut [u8] { - &mut self.0 - } -} - -impl Decoding<[u8]> for FieldEl { - type Repr = UniformFrBytes; - fn decode(buf: Self::Repr) -> Self { - FieldEl(F::from_le_bytes_mod_order(&buf.0)) - } -} - -impl NargDeserialize for FieldEl { - fn deserialize_from_narg(buf: &mut &[u8]) -> VerificationResult { - let n = F::NUM_BYTES; - if buf.len() < n { - return Err(VerificationError); - } - let (head, tail) = buf.split_at(n); - *buf = tail; - Ok(FieldEl(F::from_le_bytes_mod_order(head))) - } -} +use spongefish::{Encoding, NargDeserialize, VerificationError, VerificationResult}; /// Length-prefixed byte string. 8-byte LE length keeps `BytesMsg(a) ; BytesMsg(b)` /// distinguishable from `BytesMsg(a||b)`. @@ -119,16 +60,6 @@ impl NargDeserialize for BytesMsg { #[cfg(test)] mod tests { use super::*; - use jolt_field::Fr; - - #[test] - fn fr_le_bytes_round_trip() { - for i in 0u64..32 { - let f = Fr::from(i.wrapping_mul(0x9E37_79B9_7F4A_7C15)); - let bytes = f.to_bytes_le_vec(); - assert_eq!(Fr::from_le_bytes_mod_order(&bytes), f); - } - } #[test] fn bytes_msg_is_length_prefixed() { diff --git a/crates/jolt-transcript/src/lib.rs b/crates/jolt-transcript/src/lib.rs index 617ad44b7f..4b43496a85 100644 --- a/crates/jolt-transcript/src/lib.rs +++ b/crates/jolt-transcript/src/lib.rs @@ -25,7 +25,7 @@ mod prover; mod setup; mod verifier; -pub use codec::{BytesMsg, FieldEl}; +pub use codec::BytesMsg; pub use legacy::{ AppendToTranscript, Label, LabelWithCount, SpongeTranscript, Transcript, U64Word, MAX_LABEL_LEN, }; diff --git a/jolt-eval/src/invariant/transcript_symmetry.rs b/jolt-eval/src/invariant/transcript_symmetry.rs index 3654871370..1473be11b7 100644 --- a/jolt-eval/src/invariant/transcript_symmetry.rs +++ b/jolt-eval/src/invariant/transcript_symmetry.rs @@ -3,13 +3,12 @@ //! sequence must round-trip every prover message and produce the same //! verifier challenges. +use ark_bn254::Fr as ArkFr; use arbitrary::{Arbitrary, Unstructured}; use jolt_field::Fr as JFr; use spongefish::instantiations::{Blake2b512, Keccak}; -use jolt_transcript::{ - prover_transcript, verifier_transcript, BytesMsg, FieldEl, PoseidonSponge, -}; +use jolt_transcript::{prover_transcript, verifier_transcript, BytesMsg, PoseidonSponge}; use crate::invariant::{CheckError, Invariant, InvariantViolation}; @@ -76,12 +75,12 @@ where for op in &input.ops { match op { Op::PublicBytes(b) => prover.public_message(&BytesMsg(b.clone())), - Op::PublicScalar(f) => prover.public_message(&FieldEl(*f)), + Op::PublicScalar(f) => prover.public_message(&ArkFr::from(*f)), Op::ProverBytes(b) => prover.prover_message(&BytesMsg(b.clone())), - Op::ProverScalar(f) => prover.prover_message(&FieldEl(*f)), + Op::ProverScalar(f) => prover.prover_message(&ArkFr::from(*f)), Op::Challenge => { - let FieldEl(c) = prover.verifier_message::>(); - prover_challenges.push(c); + let c: ArkFr = prover.verifier_message(); + prover_challenges.push(JFr::from(c)); } } } @@ -93,7 +92,7 @@ where for (op_idx, op) in input.ops.iter().enumerate() { match op { Op::PublicBytes(b) => verifier.public_message(&BytesMsg(b.clone())), - Op::PublicScalar(f) => verifier.public_message(&FieldEl(*f)), + Op::PublicScalar(f) => verifier.public_message(&ArkFr::from(*f)), Op::ProverBytes(expected) => { let got: BytesMsg = verifier .prover_message() @@ -103,16 +102,16 @@ where } } Op::ProverScalar(expected) => { - let got: FieldEl = verifier + let got: ArkFr = verifier .prover_message() - .map_err(|e| violation("prover_message", op_idx, e))?; - if got.0 != *expected { + .map_err(|e| violation("prover_message", op_idx, e))?; + if JFr::from(got) != *expected { return Err(mismatch("ProverScalar round-trip", op_idx)); } } Op::Challenge => { - let FieldEl(verifier_c) = verifier.verifier_message::>(); - if verifier_c != prover_challenges[challenge_idx] { + let verifier_c: ArkFr = verifier.verifier_message(); + if JFr::from(verifier_c) != prover_challenges[challenge_idx] { return Err(mismatch("Challenge", op_idx)); } challenge_idx += 1; From 67a1a3c27eaa5128f195612671d818fc6d9368ba Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Wed, 20 May 2026 08:56:26 +0530 Subject: [PATCH 20/21] chore: apply rustfmt and taplo after merge --- Cargo.toml | 4 +++- crates/jolt-transcript/src/setup.rs | 6 +----- jolt-eval/src/invariant/transcript_symmetry.rs | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b7de7b0337..38204243fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -254,7 +254,9 @@ 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 = ["sha3"] } +spongefish = { version = "0.6.1", default-features = false, features = [ + "sha3", +] } jolt-optimizations = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" } dory = { package = "dory-pcs", version = "0.3.0", features = [ "backends", diff --git a/crates/jolt-transcript/src/setup.rs b/crates/jolt-transcript/src/setup.rs index 5b27b4524a..c6fb650b42 100644 --- a/crates/jolt-transcript/src/setup.rs +++ b/crates/jolt-transcript/src/setup.rs @@ -102,11 +102,7 @@ impl Encoding<[u8]> for InstanceDigest { /// VerifierTranscript::::check_eof(verifier).unwrap(); /// ``` #[must_use] -pub fn prover_transcript( - session: &[u8], - instance: [u8; 32], - sponge: H, -) -> ProverState +pub fn prover_transcript(session: &[u8], instance: [u8; 32], sponge: H) -> ProverState where H: DuplexSpongeInterface, { diff --git a/jolt-eval/src/invariant/transcript_symmetry.rs b/jolt-eval/src/invariant/transcript_symmetry.rs index 1473be11b7..6e78faf699 100644 --- a/jolt-eval/src/invariant/transcript_symmetry.rs +++ b/jolt-eval/src/invariant/transcript_symmetry.rs @@ -3,8 +3,8 @@ //! sequence must round-trip every prover message and produce the same //! verifier challenges. -use ark_bn254::Fr as ArkFr; use arbitrary::{Arbitrary, Unstructured}; +use ark_bn254::Fr as ArkFr; use jolt_field::Fr as JFr; use spongefish::instantiations::{Blake2b512, Keccak}; From b8f4b23f17b4aca98d78e832532adf047327634c Mon Sep 17 00:00:00 2001 From: shreyas-londhe Date: Tue, 26 May 2026 11:50:12 +0530 Subject: [PATCH 21/21] refactor(jolt-transcript): use dot-call syntax in tests per mmaker feedback --- .../jolt-transcript/tests/narg_eof_tests.rs | 29 +++++----- .../tests/native_traits_tests.rs | 57 +++++++------------ 2 files changed, 36 insertions(+), 50 deletions(-) diff --git a/crates/jolt-transcript/tests/narg_eof_tests.rs b/crates/jolt-transcript/tests/narg_eof_tests.rs index 4de2ea6f29..c763d6e1f5 100644 --- a/crates/jolt-transcript/tests/narg_eof_tests.rs +++ b/crates/jolt-transcript/tests/narg_eof_tests.rs @@ -8,10 +8,7 @@ #![cfg(feature = "transcript-blake2b")] #![expect(clippy::expect_used, reason = "tests")] -use jolt_transcript::{ - prover_transcript, verifier_transcript, BytesMsg, ProverTranscript, VerifierTranscript, - PROTOCOL_ID, -}; +use jolt_transcript::{prover_transcript, verifier_transcript, BytesMsg, PROTOCOL_ID}; use spongefish::instantiations::Blake2b512; const SESSION: &[u8] = b"narg-eof-test"; @@ -20,9 +17,9 @@ const INSTANCE: [u8; 32] = [0x42; 32]; fn build_valid_narg(messages: &[&[u8]]) -> Vec { let mut prover = prover_transcript(SESSION, INSTANCE, Blake2b512::default()); for m in messages { - ProverTranscript::::prover_message(&mut prover, &BytesMsg(m.to_vec())); + prover.prover_message(&BytesMsg(m.to_vec())); } - ProverTranscript::::narg_string(&prover).to_vec() + prover.narg_string().to_vec() } fn build_verifier(narg: &[u8]) -> spongefish::VerifierState<'_, Blake2b512> { @@ -36,11 +33,12 @@ fn check_eof_accepts_exact_narg() { let mut verifier = build_verifier(&narg); for expected in msgs { - let got: BytesMsg = VerifierTranscript::::prover_message(&mut verifier) + let got: BytesMsg = verifier + .prover_message() .expect("valid prover message must deserialize"); assert_eq!(got.as_slice(), *expected); } - VerifierTranscript::::check_eof(verifier).expect("exact narg must pass check_eof"); + verifier.check_eof().expect("exact narg must pass check_eof"); } #[test] @@ -52,11 +50,12 @@ fn check_eof_rejects_trailing_garbage() { let mut verifier = build_verifier(&narg); for expected in msgs { - let got: BytesMsg = VerifierTranscript::::prover_message(&mut verifier) + let got: BytesMsg = verifier + .prover_message() .expect("valid prefix must deserialize"); assert_eq!(got.as_slice(), *expected); } - let result = VerifierTranscript::::check_eof(verifier); + let result = verifier.check_eof(); assert!( result.is_err(), "narg with {} trailing bytes (original_len={}) must fail check_eof", @@ -73,10 +72,11 @@ fn check_eof_rejects_single_trailing_byte() { n }; let mut verifier = build_verifier(&narg); - let _: BytesMsg = VerifierTranscript::::prover_message(&mut verifier) + let _: BytesMsg = verifier + .prover_message() .expect("valid prefix must deserialize"); assert!( - VerifierTranscript::::check_eof(verifier).is_err(), + verifier.check_eof().is_err(), "even a single trailing byte must fail check_eof", ); } @@ -87,10 +87,11 @@ fn check_eof_rejects_unread_messages() { let narg = build_valid_narg(msgs); let mut verifier = build_verifier(&narg); - let _: BytesMsg = VerifierTranscript::::prover_message(&mut verifier) + let _: BytesMsg = verifier + .prover_message() .expect("first message must deserialize"); assert!( - VerifierTranscript::::check_eof(verifier).is_err(), + verifier.check_eof().is_err(), "leaving prover messages unread must fail check_eof", ); } diff --git a/crates/jolt-transcript/tests/native_traits_tests.rs b/crates/jolt-transcript/tests/native_traits_tests.rs index aef35ad7d0..fe66a8f0ed 100644 --- a/crates/jolt-transcript/tests/native_traits_tests.rs +++ b/crates/jolt-transcript/tests/native_traits_tests.rs @@ -7,10 +7,7 @@ #![expect(clippy::expect_used, reason = "tests")] use jolt_field::Fr; -use jolt_transcript::{ - prover_transcript, verifier_transcript, BytesMsg, OptimizedChallenge, ProverTranscript, - VerifierTranscript, -}; +use jolt_transcript::{prover_transcript, verifier_transcript, BytesMsg, OptimizedChallenge}; const SESSION: &[u8] = b"native-traits"; const INSTANCE: [u8; 32] = [0x77; 32]; @@ -23,30 +20,26 @@ mod blake2b { #[test] fn prover_verifier_round_trip() { let mut prover = prover_transcript(SESSION, INSTANCE, Blake2b512::default()); - ProverTranscript::::public_message(&mut prover, &BytesMsg(b"pub".to_vec())); - ProverTranscript::::prover_message(&mut prover, &BytesMsg(b"private".to_vec())); - let _c1: Fr = - as OptimizedChallenge>::challenge_128(&mut prover); - let narg = ProverTranscript::::narg_string(&prover).to_vec(); + prover.public_message(&BytesMsg(b"pub".to_vec())); + prover.prover_message(&BytesMsg(b"private".to_vec())); + let _c1: Fr = prover.challenge_128(); + let narg = prover.narg_string().to_vec(); let mut verifier = verifier_transcript(SESSION, INSTANCE, Blake2b512::default(), &narg); - VerifierTranscript::::public_message(&mut verifier, &BytesMsg(b"pub".to_vec())); - let got: BytesMsg = VerifierTranscript::::prover_message(&mut verifier) + verifier.public_message(&BytesMsg(b"pub".to_vec())); + let got: BytesMsg = verifier + .prover_message() .expect("prover_message must deserialize"); assert_eq!(got.as_slice(), b"private"); - let _c2: Fr = - as OptimizedChallenge>::challenge_128( - &mut verifier, - ); - VerifierTranscript::::check_eof(verifier).expect("eof"); + let _c2: Fr = verifier.challenge_128(); + verifier.check_eof().expect("eof"); } #[test] fn optimized_challenge_is_128_bit_truncated() { use ark_ff::PrimeField; let mut prover = prover_transcript(SESSION, INSTANCE, Blake2b512::default()); - let c: Fr = - as OptimizedChallenge>::challenge_128(&mut prover); + let c: Fr = prover.challenge_128(); let ark_c: ark_bn254::Fr = c.into(); let bigint = ark_c.into_bigint().0; assert_eq!( @@ -63,10 +56,8 @@ mod blake2b { fn distinct_sessions_diverge() { let mut a = prover_transcript(b"a", INSTANCE, Blake2b512::default()); let mut b = prover_transcript(b"b", INSTANCE, Blake2b512::default()); - let ca: Fr = - as OptimizedChallenge>::challenge_128(&mut a); - let cb: Fr = - as OptimizedChallenge>::challenge_128(&mut b); + let ca: Fr = a.challenge_128(); + let cb: Fr = b.challenge_128(); assert_ne!( ca, cb, "distinct session bytes must yield distinct first challenge" @@ -77,10 +68,8 @@ mod blake2b { fn distinct_instances_diverge() { let mut a = prover_transcript(SESSION, [0x11; 32], Blake2b512::default()); let mut b = prover_transcript(SESSION, [0x22; 32], Blake2b512::default()); - let ca: Fr = - as OptimizedChallenge>::challenge_128(&mut a); - let cb: Fr = - as OptimizedChallenge>::challenge_128(&mut b); + let ca: Fr = a.challenge_128(); + let cb: Fr = b.challenge_128(); assert_ne!( ca, cb, "distinct instance digests must yield distinct first challenge", @@ -96,18 +85,14 @@ mod keccak { #[test] fn prover_verifier_round_trip() { let mut prover = prover_transcript(SESSION, INSTANCE, Keccak::default()); - ProverTranscript::::prover_message(&mut prover, &BytesMsg(b"a".to_vec())); - let _c: Fr = - as OptimizedChallenge>::challenge_128(&mut prover); - let narg = ProverTranscript::::narg_string(&prover).to_vec(); + prover.prover_message(&BytesMsg(b"a".to_vec())); + let _c: Fr = prover.challenge_128(); + let narg = prover.narg_string().to_vec(); let mut verifier = verifier_transcript(SESSION, INSTANCE, Keccak::default(), &narg); - let got: BytesMsg = - VerifierTranscript::::prover_message(&mut verifier).expect("ok"); + let got: BytesMsg = verifier.prover_message().expect("ok"); assert_eq!(got.as_slice(), b"a"); - let _c2: Fr = as OptimizedChallenge>::challenge_128( - &mut verifier, - ); - VerifierTranscript::::check_eof(verifier).expect("eof"); + let _c2: Fr = verifier.challenge_128(); + verifier.check_eof().expect("eof"); } }