Skip to content

spec: port jolt-transcript to spongefish#1455

Open
shreyas-londhe wants to merge 23 commits into
a16z:mainfrom
shreyas-londhe:spec/jolt-transcript-spongefish
Open

spec: port jolt-transcript to spongefish#1455
shreyas-londhe wants to merge 23 commits into
a16z:mainfrom
shreyas-londhe:spec/jolt-transcript-spongefish

Conversation

@shreyas-londhe
Copy link
Copy Markdown
Contributor

@shreyas-londhe shreyas-londhe commented Apr 21, 2026

Summary

Spec-only PR. Proposes porting crates/jolt-transcript from its hand-rolled Fiat-Shamir construction to spongefish, the audited duplex-sponge library.

Key decisions captured in the spec:

  • Spongefish everywhere off-chain. Three existing transcript variants (Blake2b, Keccak, Poseidon) become feature-selected sponges plugged into one spongefish construction via DuplexSpongeInterface. spongefish::Blake2b512 and spongefish::Keccak come ready-made; Poseidon gets a new DuplexSpongeInterface impl over light-poseidon.
  • Positional API, no per-call labels. Matches spongefish-native shape and how production consumers (WHIR, sigma-rs) use it. Jolt's protocol flow is deterministic, so positional order already provides domain separation that per-call labels would redundantly provide.
  • Trait impls directly on spongefish::ProverState<Sponge> / VerifierState<Sponge>. Orphan rule allows it; no wrapper structs.
  • JoltProof collapses to a NARG byte string plus whatever public inputs the verifier doesn't already know. Today's cfg-gated opening_claims / blindfold_proof fields become different prover-message sequences inside the NARG.
  • Downstream verifier byte layouts change as a consequence. Solidity verifier (transpilable_verifier.rs), gnark verifier (transpiler/), and Lean extractor (zklean-extractor/) are explicit non-goals here and become coordinated follow-ups.
  • Dory migration is a follow-up PR on a16z/dory. This PR updates the JoltToDoryTranscript bridge to wrap the new traits while dory keeps its current DoryTranscript. The bridge is removed once dory consumes jolt-transcript.
  • Transcript-internal tests are deleted, not adapted. They tested generic Fiat-Shamir properties (determinism, domain separation, etc.) that spongefish owns upstream. Correctness of new code (Poseidon sponge impl, 128-bit decoder) is exercised by the muldiv e2e matrix across 3 sponges × 2 modes.

Open question flagged in the spec for maintainer input: how CI should treat transpiler/go/e2e_test.go and any Solidity tests that pin current byte layouts, since those will fail as a direct consequence.

Context: this is the redirect from a16z/dory PR #17 (closed by the maintainer) — per that direction, spongefish work lives in jolt-transcript, and dory will consume it.

Test plan

  • Run `/analyze-spec` remotely via `claude-spec-review-request` label to surface any remaining ambiguities
  • Maintainer review and `claude-spec-approved` label
  • Run `/implement-spec` from the approved spec in a follow-up implementation PR

Closes none — this is a spec PR.

@github-actions github-actions Bot added the spec Tracking issue for a feature spec label Apr 21, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Claude spec review session started: https://claude.ai/code/session_011hNRFc6sPWLP97RESoU3kV

@markosg04 markosg04 self-requested a review April 21, 2026 15:36
Copy link
Copy Markdown
Collaborator

Spec Analysis: Port jolt-transcript to spongefish

Dimension Score Gap
Goal 0.80 JoltProof = narg + "public inputs the verifier doesn't already know" — the "public inputs" set is unspecified
Constraints 0.65 Open CI question unresolved; perf bar soft; 254-bit challenge path not addressed
Success Criteria 0.75 Perf regression threshold has no concrete % / time budget
Context 0.85 "crates/jolt-transcript is currently parked" doesn't match the crate's actual state
Ambiguity ~24%

Status: Questions remain — 5 ambiguities to resolve before one-shot implementation.

The spec is in strong shape overall. Goal, abstractions, acceptance checklist, and alternatives are precise; file:line citations throughout make the scope easy to trace. Ambiguities cluster around (a) the proof wire format's non-NARG tail, (b) the challenge-width contract for Poseidon, and (c) pass/fail thresholds that the implementer would otherwise have to guess.

Questions:

1. [Goal] JoltProof is described as collapsing to narg: Vec<u8> "plus any public inputs the verifier doesn't already know." What concretely lives outside the NARG on the wire? Today's fiat_shamir_preamble (jolt-core/src/zkvm/mod.rs:204) binds preprocessing digest, memory layout, max_input/output, heap, inputs, outputs, panic flag, ram_K, trace_length, entry_address, ReadWriteConfig phase counts, log_k_chunk, and DoryLayout — are all of these "already known" to the verifier via preprocessing and guest I/O, or does some subset still travel on JoltProof alongside the NARG? An enumerated list (or a pointer to the preprocessing-vs-proof split rule) makes this mechanical to implement.

2. [Constraints] transcript-poseidon currently force-enables challenge-254-bit (jolt-core/Cargo.toml:25). The spec specifies a custom "128-bit-truncating decoder" to preserve the challenge_*_optimized performance profile (63 callsites verified). Under the new API, does Poseidon still produce 254-bit challenges where today's challenge-254-bit feature mandates it, or is the port unifying every sponge onto the 128-bit-truncating decoder? If the latter, that's a soundness-adjacent semantic change and contradicts Non-Goal 4 ("mechanical port of protocol semantics"). Please specify the per-sponge challenge-width contract (Blake2b/Keccak: 128-bit; Poseidon: 254-bit vs 128-bit?) and, if Poseidon changes, call it out explicitly.

3. [Constraints] The spec flags the CI treatment of transpiler/go/e2e_test.go and Solidity byte-layout tests as an open question for maintainers (options a/b/c). This is a real merge-gate dependency — without a decision, the implementer either stops to ask or picks arbitrarily (likely option a, #[ignore]). Please pick one so implementation can proceed one-shot. Suggested default: option (a) with a FIXME(jolt-transcript-spongefish) marker tying each ignore to the downstream follow-up PRs.

4. [Success Criteria] The performance bar reads "no observable regression beyond benchmark noise on transcript_ops and on muldiv e2e wall-clock under --features host with transcript-blake2b." Two gaps:

  • What is "benchmark noise" in practice — ≤3%? ≤5%? A Criterion p-value threshold? Without a number the criterion is unfalsifiable.
  • The bar names Blake2b only. Is transcript-keccak / transcript-poseidon held to any regression threshold, or is per-sponge perf non-binding beyond "muldiv passes"?

5. [Context] Minor — the Architecture section says crates/jolt-transcript is "currently parked; this spec activates it." The crate currently contains a live hand-rolled implementation: src/{lib,transcript,blake2b,keccak,poseidon,blanket,digest}.rs, populated tests/, fuzz/, and benches/. Acceptance Criteria correctly enumerate these as deletions, but the "parked" framing is misleading — an implementer expecting an empty scaffold will be surprised. Suggest rewording to "currently a hand-rolled Fiat-Shamir implementation that this spec replaces in-place, and jolt-core/src/transcripts/ is deleted in favor of depending on it."

After addressing these questions, update the spec and re-add the claude-spec-review-request label.


Generated by Claude Code

@shreyas-londhe shreyas-londhe force-pushed the spec/jolt-transcript-spongefish branch from 3779d1e to ea626dc Compare April 21, 2026 16:21
@moodlezoup
Copy link
Copy Markdown
Collaborator

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.

We don't have any Solidity integration tests. With regard to the transpiler tests, I think we should keep the jolt-core transcript implementation in place for now. We can deal with activating the new jolt-transcript crate (and updating the gnark transpiler) at a later date.

@shreyas-londhe
Copy link
Copy Markdown
Contributor Author

We don't have any Solidity integration tests. With regard to the transpiler tests, I think we should keep the jolt-core transcript implementation in place for now. We can deal with activating the new jolt-transcript crate (and updating the gnark transpiler) at a later date.

Thanks for the context, @moodlezoup. I've tailored the spec to a narrower goal — internal port of crates/jolt-transcript only — with the jolt-core migration, dory consolidation, and gnark transpiler update each deferred to their own follow-up PRs.

Ready for re-analysis when convenient.

@github-actions
Copy link
Copy Markdown
Contributor

Claude spec review session started: https://claude.ai/code/session_01KnxCordRFfR2gGNymiRw3F

Copy link
Copy Markdown
Collaborator

Spec Analysis

Dimension Score Weight Gap
Goal Clarity 0.88 0.35 Trait method generic bounds & narg_string() return type not spelled out; labelDomainSeparator mapping implied
Constraint Clarity 0.90 0.20 Clear
Success Criteria 0.87 0.30 Invariant Setup associated type unspecified; sequence-validity (e.g. Challenge before any absorb) not stated
Context Clarity 0.92 0.15 Clear — all file:line anchors validate against HEAD (wrappers.rs:336, Cargo.toml:45-48/44-46/26-28, field/ark.rs:2, README.md:229-239)
Ambiguity 11.3%

Status: Approved — the spec is clear enough for a one-shot implementation.

Summary of what will be built

  • Replace crates/jolt-transcript's hand-rolled digest-based Transcript with spongefish-native ProverTranscript / VerifierTranscript traits, implemented directly on spongefish::ProverState<Sponge> / spongefish::VerifierState<Sponge> (no wrappers, via the orphan rule).
  • Three feature-gated sponges: transcript-blake2bspongefish::Blake2b512, transcript-keccakspongefish::Keccak, transcript-poseidon → a new PoseidonSponge adapter over light-poseidon (Circom-compatible BN254).
  • Per-sponge challenge-width contract preserved: Blake2b/Keccak expose both 128-bit-optimized and full-field decoders; Poseidon exposes full-field only (the 128-bit decoder is simply not defined on PoseidonSponge, so calling _optimized against it is a compile error).
  • Delete crates/jolt-transcript/tests/ and crates/jolt-transcript/fuzz/ (rationale: they verify generic-sponge properties, not adapter parametrization). Adapt benches/transcript_ops.rs to the new positional API.
  • Add jolt-eval/src/invariant/transcript_symmetry.rs (TranscriptProverVerifierConsistency<Sponge>) with #[invariant(Test, Fuzz, RedTeam)], one instantiation per sponge. Input enum: PublicBytes | PublicScalar | ProverBytes | ProverScalar | Challenge. check runs the sequence on prover, replays on verifier against the NARG, asserts receive_prover_message round-trip equality and verifier_message equality. Seed corpus: empty, single-per-variant, 10-mixed, 1000-mixed.
  • Add jolt-transcript dep to jolt-eval/Cargo.toml; register module in invariant/mod.rs; run sync_targets.sh to sync fuzz-target manifests.

Key invariants

  1. Prover/verifier sponge symmetry within the new crate (mechanized by the new invariant across all three sponges).
  2. No external behavior change: jolt-core/src/transcripts/, JoltToDoryTranscript, transpiler/, zklean-extractor/, and JoltProof remain byte-identical; the three identically-named transcript-* features in jolt-core/jolt-sdk/transpiler continue to drive the old jolt-core/src/transcripts/ unchanged.

Critical evaluation criteria

  • cargo nextest run -p jolt-eval (exercises the new invariant for all three sponge instantiations).
  • cargo nextest run -p jolt-core muldiv --features host and --features host,zk continue to pass (workspace sanity only — jolt-core does not reach the new crate in this PR).
  • transcript_ops Criterion benchmark stays within 5% of main_run baseline per BenchmarkId (enforced via critcmp main_run pr_run --threshold 5 in .github/workflows/bench-crates.yml).

Validation of spec anchors against HEAD

  • crates/jolt-transcript/src/{transcript,digest,blake2b,keccak,poseidon,blanket}.rs present and hand-rolled digest-based ✓
  • crates/jolt-transcript/tests/ (4 files) and fuzz/ populated ✓
  • crates/jolt-transcript/benches/transcript_ops.rs present with BenchmarkId::new("Blake2b"|"Keccak"|"Poseidon", ...) groups ✓
  • jolt-eval/src/invariant/split_eq_bind.rs serves as close structural model (same #[invariant(Test, Fuzz, RedTeam)] pattern, type Setup = (), type Input = ..., check(&self, _setup, input)) ✓
  • jolt-eval/Cargo.toml does not currently depend on jolt-transcript
  • No existing transcript_symmetry.rs or TranscriptProverVerifierConsistency
  • jolt-core/src/poly/commitment/dory/wrappers.rs:336 hosts JoltToDoryTranscript
  • Features at jolt-core/Cargo.toml:45-48, jolt-sdk/Cargo.toml:44-46, transpiler/Cargo.toml:26-28
  • challenge-254-bit gate at jolt-core/src/field/ark.rs:2
  • jolt-eval/README.md:229-239 describes sync_targets.sh
  • .github/workflows/bench-crates.yml uses Criterion --save-baseline and critcmp
  • spongefish is not yet a workspace dependency; light-poseidon already is ✓ (implementer needs to add spongefish to root Cargo.toml)

Minor, non-blocking notes for the implementer (judgement calls, not ambiguities that would block a one-shot impl)

  • Generic bounds on public_message<T> / prover_message<T> / verifier_message<T> are not spelled out — follow spongefish's native CommonUnitToBytes/Decoding trait bounds (the spec names spongefish::Decoding<[H::U]> for challenge decoders).
  • narg_string() return shape not specified — use spongefish's native &[u8] return.
  • The invariant's Setup associated type is not named — default type Setup = () matches SplitEqBindLowHighInvariant.
  • Ordering constraints on the Input sequence (e.g., starting with Challenge) are not stated — spongefish's DomainSeparator defines the alphabet; the check method should construct both transcripts from the same DomainSeparator derived from the sequence.

Next step: Run /implement-spec to implement this spec.


Generated by Claude Code

@shreyas-londhe shreyas-londhe force-pushed the spec/jolt-transcript-spongefish branch from ccb930b to 848ebb1 Compare April 28, 2026 19:26
Vishalkulkarni45 and others added 2 commits May 5, 2026 12:04
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.
@github-actions github-actions Bot added the implementation PR contains implementation of a spec label May 6, 2026
@Vishalkulkarni45 Vishalkulkarni45 force-pushed the spec/jolt-transcript-spongefish branch from beeff40 to d90cda5 Compare May 6, 2026 17:24
@shreyas-londhe shreyas-londhe force-pushed the spec/jolt-transcript-spongefish branch from af59719 to d90cda5 Compare May 7, 2026 13:08
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.
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).
Adapt jolt-transcript spongefish port to upstream's recent changes:
- jolt-field trait split (PR a16z#1484): blanket AppendToTranscript bound
  switches from `F: Field` to `F: CanonicalBytes`; Transcript challenge
  bound switches from `F: Field` to `F: TranscriptChallenge`; challenge
  decoder switches from `F::from_u128` to `F::from_challenge_bytes`.
- Transcript trait gained `state() -> &[u8; 32]` and test-only
  `compare_to`. SpongeTranscript implements state() via a 32-byte peek
  from a sponge clone, cached on each absorb/squeeze so reads return a
  reference. compare_to is a no-op on the duplex-sponge facade.
- Internal `domain` module renamed to `setup` so the now-public
  `jolt_transcript::domain` path can re-export `Label`,
  `LabelWithCount`, and `U64Word` (jolt-dory imports from this path on
  upstream main).
- `light-poseidon` made an optional dependency gated by
  `transcript-poseidon`; `PoseidonSponge` / `PoseidonTranscript` are
  cfg-gated to match.
@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 12, 2026

@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 12, 2026

All alerts resolved. Learn more about Socket for GitHub.

This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored.

View full report

- 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.
Copy link
Copy Markdown
Collaborator

@moodlezoup moodlezoup left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great work! just a couple of comments

Comment thread crates/jolt-transcript/src/setup.rs Outdated
Comment on lines +29 to +30
/// Empty `instance` value so the spongefish builder reaches the `to_prover`
/// stage. Encodes to zero bytes.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand –– are to_prover and to_verifier exclusively used for testing (to dump the transcript state)? If so, let's rename them to dump_prover_state and dump_verifier_state

Copy link
Copy Markdown
Contributor Author

@shreyas-londhe shreyas-londhe May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not test-only — mirrors spongefish's own DomainSeparator::to_prover / to_verifier (we delegate at setup.rs:48,60). Used by jolt-eval/src/invariant/transcript_symmetry.rs and the planned jolt-core migration.

Comment on lines +102 to +105
/// 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<u8>);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we implement Encoding and NargDeserialize for Vec<u8> upstream, so we can get rid of this wrapper?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upstream = external spongefish crate. Wrapper enforces an 8-byte LE u64 length prefix so BytesMsg(a); BytesMsg(b)BytesMsg(a‖b) — a jolt-side choice.

@mmaker — thoughts on adding Encoding<[u8]> + NargDeserialize for Vec<u8> upstream?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with you and @moodlezoup. This wrapper should be in spongefish and derive(Encoding) should work for Vec<u8>.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of implementing these traits for ark_bn254::Fr, can we do a blanket implementation over F: jolt_field::CanonicalBytes like we had in blanket.rs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — FieldEl<F> / FieldElOptimized<F> now generic with per-impl bounds (CanonicalBytes for Encoding, ReducingBytes/FromPrimitiveInt for Decoding). 64-byte uniform constant stays hardcoded with a doc note (BN254-tuned; sound for any ≤254-bit field).

@moodlezoup moodlezoup added the claude-review-request Request a review from Claude Code label May 13, 2026
@github-actions
Copy link
Copy Markdown
Contributor

Claude code review session started: https://claude.ai/code/session_01FAUUPvuGwgBtJv39uUDsav

Copy link
Copy Markdown
Collaborator

@moodlezoup moodlezoup left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spongefish port looks solid overall — known-vector tests pin the wire format and the new symmetry invariant is a good defense-in-depth. A few items worth fixing before this lands.


Generated by Claude Code

Comment thread crates/jolt-transcript/src/codec.rs Outdated
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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 + len overflows when an attacker-supplied NARG declares len = u64::MAX. On 64-bit release builds the sum wraps to 7, the length guard passes, and buf[8..8+len] then panics on slice OOB instead of returning VerificationError. Any service that verifies untrusted proofs gets DoSed via a single 8-byte length prefix.

Suggested change
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..total].to_vec();
*buf = &buf[total..];

Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — 8usize.checked_add(len) per suggestion. Added regression test feeding u64::MAX length.

Comment thread crates/jolt-transcript/src/compat.rs Outdated
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);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

peek_state clones the entire sponge after every append_bytes and challenge call. For PoseidonTranscript the clone calls fresh_hasher() (poseidon.rs:95), which re-runs Poseidon::new_circom(3) and rebuilds the BN254 x5 round constants from scratch on every transcript op. No current consumer of jolt-transcript (jolt-sumcheck, jolt-openings, jolt-crypto) actually calls state(), so this work is pure overhead. Consider lazy refresh: store Cell<Option<[u8; 32]>>, invalidate on absorb/squeeze, populate on state() access.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — dropped the per-op cache. state() now returns owned [u8; 32] and calls peek_state on demand. No production caller exists (debug-only path), so the trade is fine.

let mut buf = [0u8; 16];
let _ = self.sponge.squeeze(&mut buf);
self.state = peek_state(&self.sponge);
F::from_challenge_bytes(&buf)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The compat facade squeezes 16 bytes for every sponge including Poseidon, then maps via from_challenge_bytes. That contradicts the doc at prover.rs:53-55 which says calling a 128-bit decoder on a Poseidon-backed state is "a compile error" — Poseidon-via-compat does produce 128-bit-truncated challenges, just through a different entry point. Either add a WARNING: comment here noting the deliberate inconsistency pending the jolt-core migration, or specialize the squeeze width per-sponge so both surfaces agree.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added WARNING comment. Divergence is intentional (compat preserves legacy width; not unsound — 128-bit truncation of Poseidon output is fine, just leaves entropy on the table). No external crate consumes compat PoseidonTranscript; goes away once jolt-sumcheck/openings/crypto migrate.

Comment thread crates/jolt-transcript/src/codec.rs Outdated
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FieldElOptimized(pub Fr);

impl Encoding<[u8]> for FieldElOptimized {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spongefish's blanket impl<T: Encoding<[u8]>> NargSerialize for T (spongefish-0.7.0/src/io.rs:62) means prover.prover_message(&FieldElOptimized(f)) compiles silently, writes 32 bytes to the NARG, and the verifier has no way to read them back (no NargDeserialize impl). The data orphans in NARG and check_eof fails later, far from the misuse. The intended compile-error guard described in the doc comment doesn't fire on prover_message. Consider dropping the Encoding impl entirely (or wrapping FieldElOptimized in a pub(crate) newtype that only implements Decoding) so misuse is caught at the callsite.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — dropped the Encoding impl entirely. FieldElOptimized is verifier-message-only (squeeze → Decoding), so no caller breaks. Misuse via prover_message now fails at the call site instead of orphaning bytes in the NARG.

Address PR a16z#1455 review:
- Generalize FieldEl<F> / FieldElOptimized<F> 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.
Address PR a16z#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.
…divergence

Address PR a16z#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.
… silent NARG misuse

Address PR a16z#1455 review. Spongefish 0.7's blanket
`impl<T: Encoding<[u8]>> 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.
@shreyas-londhe shreyas-londhe requested a review from moodlezoup May 13, 2026 20:43
Copy link
Copy Markdown
Contributor

@mmaker mmaker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR is great progress. Thank you, @shreyas-londhe!

Here is some review/feedback, sorted by priority.

Constructing the sponge

The instance is not specified! It is always set to EmptyInstance, which is going to be a big footgun. I would suggest removing this struct, and requesting an instance from the caller.

Would it make sense for the constructor to be part of the prover and verifier transcripts, or perhaps to have a constructor specifically for the domain separation part?

Secondly, the session ID is not really used in this PR, and I’m assuming it will come from the application layer. It would be useful for me to have an example that I can check.

Third, Transcript requires Clone, and the legacy transcript structs derive it. This defeats the “prover state cannot be copied/restored” mitigation at the API level that we had in spongefish.

Top-level proof bytes are malleable. Are you checking the NARG string is fully read? valid_proof || garbage appears to deserialize as the same proof. This also affects generated verifier paths that call it.

Poseidon

[This is not necessarily related to this specific PR; it is a problem in main too.]

The Poseidon implementation is not using DSFS, so it does not benefit from the provable security results we have. If I understand correctly, it is doing something like this for absorb:

state_0 = 0
state_1 = P(0, block_0_a, block_0_b)[0]
state_2 = P(0, state_1, block_1_a, block_1_b)[0]
...

And something like this for squeezing:

state_next = PoseidonPermutation([0, state_old, 0, 0])[0]
output state_next

For absorb: you are throwing away 1/4 of the rate. That is, you can have 25% more hash throughput with a small change.

For squeeze: this is not a sponge. It seems closer to the hash-chain Fiat-Shamir variant. I am not able to see an attack, as long as the protocol ID does not create confusion there.
Also here you're wasting all the rate, and losing at least 50% of the throughput.
It is also not streaming-friendly, which I believe is one of Jolt’s targets.

I would suggest implementing a true permutation function, in the sense of spongefish::Permutation, and then simply using DuplexSponge<P: Permutation> instead of writing a custom absorb/squeeze implementation.

I have not checked the Poseidon implementation in detail, but it seems to go through another library. I didn't check yet if that library has test vectors to match the spec.

Redundant structures

Some structures can be removed. I will go through them in order, though I may be missing something for some of them.

  1. FieldEl, FieldElOptimized, UniformFrBytes

    Field elements mod $p$ have historically been represented using the smallest representation in big-endian order [RFC8017].

    You can patch the spongefish codec for ark-ff using an appropriate [replace] block. This is already done for you in my fork here: mmaker@168ab74.

  2. OptimizedChallenge

    Once item 1 is done, this can be a function with a default implementation in the trait. I would suggest moving the following into the trait, since it is such a simple function:

    fn challenge_128(&mut self) -> Fr {
        Fr::from(VerifierState::verifier_message::<u128>(self))
    }
  3. BytesMsg

    I agree with Michael that we should handle this at the spongefish level.

Minor

Some wording/miscellaneous advice after having spent a lot of time thinking about this:

  • challenge: I would rather call this a verifier message.
  • transcript generally refers to the list of prover and verifier messages. Here, it is a bit misused.
  • compat: why not legacy?
  • Add a deprecated annotation to all the structures that are not going to be used/needed?
  • Some consistency tests are listed as executables. Why not move them into unit tests? They do not seem to be testing the full API (all the different transcript traits you have), are they?

Comment on lines +102 to +105
/// 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<u8>);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with you and @moodlezoup. This wrapper should be in spongefish and derive(Encoding) should work for Vec<u8>.

@shreyas-londhe
Copy link
Copy Markdown
Contributor Author

shreyas-londhe commented May 19, 2026

Thanks for the review, @mmaker. Status per item below.

Sponge construction

The instance is not specified! It is always set to EmptyInstance

Native API redesigned. EmptyInstance deleted. Construction goes through two factory functions plus an escape hatch:

prover_transcript(session: &[u8], instance: [u8; 32], sponge) -> ProverState<H, StdRng>
verifier_transcript(session: &[u8], instance: [u8; 32], sponge, narg) -> VerifierState<'_, H>
transcript_builder() -> DomainSeparator<WithoutInstance, WithoutSession>  // escape hatch

instance: [u8; 32] is positional and required. transcript_builder() exposes spongefish's DomainSeparator with PROTOCOL_ID pre-bound for composite verifiers (e.g. BlindFold). legacy::Transcript (formerly compat) unchanged — slated for deletion when callers migrate.

Secondly, the session ID is not really used in this PR…

Doc-test on prover_transcript shows the wiring. instance in jolt-core will be Blake2b(CanonicalSerialize(JoltDevice)) — preprocessing digest, memory layout, IO, panic flag, ram_K, trace_length, entry_address.

Third, Transcript requires Clone

Dropped from both legacy::Transcript and jolt-core's local Transcript trait. Only consumer was a dead #[cfg(test)] field — removed.

Top-level proof bytes are malleable. Are you checking the NARG string is fully read?

check_eof docstring strengthened: soundness consequence + contract (call at end of top-level verify, success path only). WIRE-FORMAT.md marks it mandatory. Added tests/narg_eof_tests.rs proving rejection of trailing bytes and unread messages. When jolt-core migrates, its verify(self) adds one line: transcript.check_eof()?.

JoltProof's ark CanonicalDeserialize at the jolt-core boundary has its own trailing-bytes case (different serialization layer). Tracked as follow-up.

Poseidon

Not DSFS; wastes 25% absorb rate, ≥50% squeeze rate, non-streaming.

Out of scope for this PR. light-poseidon only exposes hash([Fr; 3]) → Fr, not the round-permutation needed for spongefish::Permutation<4>.

@moodlezoup — three paths: (a) swap to ark-crypto-primitives::Poseidon, (b) fork light-poseidon to expose the inner permutation, (c) hand-roll the round function with Circom constants. Each needs test-vector verification + downstream gnark/Solidity coordination. Which direction?

Redundant structures

  1. FieldEl, FieldElOptimized, UniformFrBytes — patch spongefish for ark-ff via [replace].

Deleted FieldElOptimized and UniformFrBytes. OptimizedChallenge now uses spongefish's built-in u128 codec — wire format unchanged (also LE).

FieldEl<F> kept. spongefish 0.7's ark-ff feature pulls in ark-ff ^0.6; the workspace's pinned fork (a16z/arkworks-algebra@dev/twist-shout) is at 0.5. Neither [patch.crates-io] nor [replace] bridges a major-version skew. Real blocker: the fork needs to bump to 0.6 — a workspace-wide change requiring patch rebase + perf re-test + audit. Worth its own RFC.

  1. OptimizedChallenge becomes a default trait method.

Body collapsed to Fr::from(verifier_message::<u128>(self)). Kept per-backend impl pattern — Poseidon must remain a compile error for challenge_128() (128-bit truncation isn't sound for a field-native sponge).

@moodlezoup — flag if Poseidon should be able to opt into truncated challenges eventually; would switch to a marker-trait design then.

  1. BytesMsg should be at the spongefish level.

Agreed. spongefish 0.7 has Encoding<[u8]> for [u8] (raw) but no length-prefixed variant. Will file an upstream issue.

Minor

challengeverifier_message; compatlegacy; #[deprecated]; consistency tests → unit tests.

  • Native trait already uses verifier_message. Legacy challenge() kept for BC.
  • compatlegacy rename landed.
  • #[deprecated] skipped — workspace runs -D warnings; flagging ~136 in-flight sites would fail CI. Will land in the migration PR.
  • Integration-test layout kept (the transcript_tests! macro parametrizes over backends). Added tests/native_traits_tests.rs covering the native split-trait surface — round-trip, 128-bit truncation, session/instance domain separation.

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#1455.
… codec

`FieldElOptimized<F>` 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::<u128>(self))`. Wire format preserved
exactly — `to_le_bytes` is spongefish's u128 Encoding too.

`FieldEl<F>` (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#1455.
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#1455.
…verifier_transcript

Two positional factory functions plus one escape hatch:

  prover_transcript(session: &[u8], instance: [u8; 32], sponge)
      -> ProverState<H, StdRng>
  verifier_transcript(session: &[u8], instance: [u8; 32], sponge, narg)
      -> VerifierState<'_, H>
  transcript_builder() -> DomainSeparator<WithoutInstance, WithoutSession>

`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#1455.
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#1455.
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.
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<F>` wrapper.

Enable `spongefish/ark-ff` in jolt-transcript and delete `FieldEl<F>`
and `UniformFrBytes`. Field absorbs now go through spongefish's
`Encoding<[u8]>` for `ark_ff::Fp<C, N>` directly — big-endian canonical
encoding per RFC8017. `codec.rs` retains only `BytesMsg` (length-prefixed
byte string framing) since spongefish lacks a length-prefixed Vec<u8>.

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#1455.
…t-spongefish

# Conflicts:
#	Cargo.lock
#	jolt-eval/src/invariant/mod.rs
@moodlezoup
Copy link
Copy Markdown
Collaborator

@moodlezoup — three paths: (a) swap to ark-crypto-primitives::Poseidon, (b) fork light-poseidon to expose the inner permutation, (c) hand-roll the round function with Circom constants. Each needs test-vector verification + downstream gnark/Solidity coordination. Which direction?

iirc the reason we went with light-poseidon was that it seemed relatively mature and has received an audit from Veridise. What's the status of the arkworks implementation? cc @mmaker

omibo added a commit to LayerZero-Labs/akita that referenced this pull request May 20, 2026
* docs(transcript): spec for transcript hardening (P0 + P2 + P3)

First-PR scope, off main:
- P0: AkitaInstanceDescriptor (four lifetime-tiered sections:
  AlgebraSection / SetupSection / PlanSection / CallSection) absorbed
  into spongefish DomainSeparator.instance.
- P2: AkitaTranscript<Sponge> over spongefish ProverState/VerifierState
  plus a Label ZST + label!() macro that strips labels from the
  production sponge (Invariant 5) and keeps {tag, file, line} in
  test/logging builds. LoggingTranscript<Sponge> with five smell
  checks, including wire_value_before_squeeze_coverage which carries
  the PR #88 soundness invariant at test time.
- P3: differential proptest over (config, nv, num_polys, basis)
  asserting verify-success and prover/verifier event-stream equality.

Deferred to follow-ups (named with crisp triggers in spec): the
prover/verifier trait split (P1), Bound<T> at module seams (P4), the
algorithm-as-bytes digest (bolt-lean direction), NARG-as-proof
migration, per-deployment setup PRG salt, and wider primes /
non-subfield extensions. Six TODO(transcript-hardening-v2) markers in
the descriptor flag the assumptions that may need future revision.

Adopts spongefish 0.7.x directly (does not wait on a16z/jolt#1455);
adds transcript-blake2b (default) and transcript-keccak features.

Goal of this first PR: get domain separation correct, not obviously
unsound, and ship the diagnostic substrate (LoggingTranscript +
schedule dumper) that future protocol changes will need.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(transcript): drop local NEVER-COMMIT path leaks from spec

Bugbot flagged two absolute-path references to a local-only
synthesis-notes file in §Deferred Follow-Ups #3 body and the
§References list. Removed both. Replaced the only load-bearing
reference with a forward pointer to a tentative future spec file
(`specs/verifier-program-ir.md`) that will land alongside the
algorithm-as-bytes-digest follow-up work when activated.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(transcript): PR #90 review revisions + three follow-up nits

Substantial design refinements from the review pass:

- Label cfg gating tightened to feature = "logging-transcript" only
  (was cfg(any(test, feature = ...))); ordinary cargo test runs now
  see the Label ZST, making size_of::<Label>() == 0 a real
  default-feature unit test rather than a release-only property.
- PlanSection binds effective_schedule_digest (post-fallback) instead
  of raw schedule_plan_digest; catches verifier/prover divergence on
  the root-fold-to-root-direct fallback predicate.
- CallSection gains num_points + incidence_digest over the normalized
  ClaimIncidenceSummary; distinguishes [2,1] from [1,2] batch
  groupings, which have the same totals but different verifier
  branching and row-batching challenges.
- SetupSection adds setup_seed_digest, shared_matrix_digest,
  protocol_features, and clarifies level_params_digest to bind the
  full LevelParams envelope; catches mis-expanded / custom-loaded
  Ajtai matrices and silent ZK-mode flips.
- New smell check #5: tracked wire coverage is complete. Uses a
  proof-field coverage manifest or TrackedAkitaBatchedProof view to
  prevent smell check #4 from passing vacuously when a new proof field
  is added without instrumentation.
- Smell check #4 reframed from "deserialize" to "semantic wire-use"
  (the actual soundness concern); anchored to the terminal-fold
  AkitaStage2Verifier::new_with_direct_witness handoff.
- label!() macro restricted to $tag:literal only; defends against
  accidental runtime label captures in production callsites.
- Deferred-follow-up triggers tightened, especially #3 (algorithm digest
  now requires versioned dialect registry, golden vectors, stability
  tests) and #4 (NARG migration must explicitly preserve or replace
  planner accounting / debug visibility / serialization stability).

Plus three follow-up nits folded in on the same pass:

1. Sponge family in the binding. PROTOCOL_TAG is a cfg-gated
   pub const [u8; 64] carrying the family name
   (b"akita-pcs/transcript/v1/{blake2b|keccak}\0..."); keeps sponge
   identity inside spongefish's own discipline (no extra descriptor
   field), and makes cross-family transcripts byte-distinguishable at
   the very first absorb.
2. ClaimIncidenceSummary cited at its existing path
   crates/akita-types/src/proof/incidence.rs:94. The type already
   exists with the exact field set the spec references; no new type
   needed in the implementation PR.
3. Ordering note in Pillar P0: verifier computes the effective
   schedule (from setup + public claims + extension-packing predicate)
   BEFORE descriptor construction. No chicken-and-egg with the
   transcript; the schedule digest is computable from inputs known
   before any transcript exists.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(transcript): reconcile implementation spec wording

* feat(types): add transcript instance descriptor

* feat(transcript): add spongefish transcript substrate

* feat(transcript): bind descriptors before protocol replay

* feat(transcript): remove legacy transcript backend

* feat(transcript): add logging transcript checks

* test(transcript): add hardening event checks

* docs(transcript): add hardening guide and schedule example

* test(challenges): update sparse transcript vector

* feat(transcript): record verifier wire events

* fix(transcript): address bugbot and feature unification

* refactor(transcript): finish akita transcript cutover

* test(transcript): share public event filter

* perf(transcript): cache setup descriptor digests

* fix(ci): update benchmark transcript

* fix(ci): update transcript fuzz and deny policy

* fix(transcript): require instance binding

* docs(transcript): clarify positional labels

* ci(profile): disable native benchmark cache

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Omid Bodaghi <42227752+omibo@users.noreply.github.com>
Copy link
Copy Markdown
Collaborator

@moodlezoup moodlezoup left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great work, this lgtm. fyi @markosg04 @0xAndoroid I think merging this would break compatibility between crates/ and jolt-core, but once we follow-up and integrate jolt-core with jolt-transcript we should be ok again

Copy link
Copy Markdown
Contributor

@mmaker mmaker left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there's some sloppy tests, perhaps we can make them a bit more clear and reduce the size of this PR?

Comment on lines +20 to +26
fn build_valid_narg(messages: &[&[u8]]) -> Vec<u8> {
let mut prover = prover_transcript(SESSION, INSTANCE, Blake2b512::default());
for m in messages {
ProverTranscript::<Blake2b512>::prover_message(&mut prover, &BytesMsg(m.to_vec()));
}
ProverTranscript::<Blake2b512>::narg_string(&prover).to_vec()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fn build_valid_narg(messages: &[&[u8]]) -> Vec<u8> {
let mut prover = prover_transcript(SESSION, INSTANCE, Blake2b512::default());
for m in messages {
ProverTranscript::<Blake2b512>::prover_message(&mut prover, &BytesMsg(m.to_vec()));
}
ProverTranscript::<Blake2b512>::narg_string(&prover).to_vec()
}
fn build_valid_narg(messages: &[&[u8]]) -> Vec<u8> {
let mut prover = prover_transcript(SESSION, INSTANCE, Blake2b512::default());
for m in messages {
prover.prover_message(&BytesMsg(m.to_vec()));
}
prover.narg_string().to_vec()
}

.expect("valid prover message must deserialize");
assert_eq!(got.as_slice(), *expected);
}
VerifierTranscript::<Blake2b512>::check_eof(verifier).expect("exact narg must pass check_eof");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above

.expect("valid prefix must deserialize");
assert_eq!(got.as_slice(), *expected);
}
let result = VerifierTranscript::<Blake2b512>::check_eof(verifier);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above

Comment on lines +24 to +42
fn prover_verifier_round_trip() {
let mut prover = prover_transcript(SESSION, INSTANCE, Blake2b512::default());
ProverTranscript::<Blake2b512>::public_message(&mut prover, &BytesMsg(b"pub".to_vec()));
ProverTranscript::<Blake2b512>::prover_message(&mut prover, &BytesMsg(b"private".to_vec()));
let _c1: Fr =
<spongefish::ProverState<Blake2b512> as OptimizedChallenge>::challenge_128(&mut prover);
let narg = ProverTranscript::<Blake2b512>::narg_string(&prover).to_vec();

let mut verifier = verifier_transcript(SESSION, INSTANCE, Blake2b512::default(), &narg);
VerifierTranscript::<Blake2b512>::public_message(&mut verifier, &BytesMsg(b"pub".to_vec()));
let got: BytesMsg = VerifierTranscript::<Blake2b512>::prover_message(&mut verifier)
.expect("prover_message must deserialize");
assert_eq!(got.as_slice(), b"private");
let _c2: Fr =
<spongefish::VerifierState<'_, Blake2b512> as OptimizedChallenge>::challenge_128(
&mut verifier,
);
VerifierTranscript::<Blake2b512>::check_eof(verifier).expect("eof");
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
fn prover_verifier_round_trip() {
let mut prover = prover_transcript(SESSION, INSTANCE, Blake2b512::default());
ProverTranscript::<Blake2b512>::public_message(&mut prover, &BytesMsg(b"pub".to_vec()));
ProverTranscript::<Blake2b512>::prover_message(&mut prover, &BytesMsg(b"private".to_vec()));
let _c1: Fr =
<spongefish::ProverState<Blake2b512> as OptimizedChallenge>::challenge_128(&mut prover);
let narg = ProverTranscript::<Blake2b512>::narg_string(&prover).to_vec();
let mut verifier = verifier_transcript(SESSION, INSTANCE, Blake2b512::default(), &narg);
VerifierTranscript::<Blake2b512>::public_message(&mut verifier, &BytesMsg(b"pub".to_vec()));
let got: BytesMsg = VerifierTranscript::<Blake2b512>::prover_message(&mut verifier)
.expect("prover_message must deserialize");
assert_eq!(got.as_slice(), b"private");
let _c2: Fr =
<spongefish::VerifierState<'_, Blake2b512> as OptimizedChallenge>::challenge_128(
&mut verifier,
);
VerifierTranscript::<Blake2b512>::check_eof(verifier).expect("eof");
}
#[test]
fn prover_verifier_round_trip() {
let mut prover = prover_transcript(SESSION, INSTANCE, Blake2b512::default());
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();
let mut verifier = verifier_transcript(SESSION, INSTANCE, Blake2b512::default(), &narg);
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 = verifier.challenge_128();
verifier.check_eof().expect("eof");
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

claude-review-request Request a review from Claude Code implementation PR contains implementation of a spec spec Tracking issue for a feature spec

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants