Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1f8bbcf
refactor(transcript): one sealed transcript over spongefish states; d…
quangvdao Oct 21, 2026
848af65
refactor(sumcheck): run over the sealed spongefish transcript
quangvdao Oct 21, 2026
382bdc8
refactor(pcs, blindfold): run openings, HyperKZG, Dory, Akita and Bli…
quangvdao Oct 21, 2026
b307a5f
refactor(jolt): port verifier and prover libs to the spongefish trans…
quangvdao Oct 21, 2026
a5b0ada
test(jolt): port verifier and prover tests to the spongefish transcript
quangvdao Oct 21, 2026
18530be
test(jolt-verifier): bind each statement field and re-bless the Fiat-…
quangvdao Oct 21, 2026
b6c5f2b
refactor(jolt): port the SDK, examples, jolt-eval and fuzz targets to…
quangvdao Oct 21, 2026
c17ff02
fix(transcript): per-crate clippy configs after the sponge port
quangvdao Oct 21, 2026
7db4652
fix(transcript): address the soundness audit's notes
quangvdao Oct 21, 2026
f2f4b01
chore(transcript): imported short names, rustfmt, regenerated fixtures
quangvdao Oct 21, 2026
0bf9cd1
chore(transcript): typos and an unused dev-dependency
quangvdao Oct 21, 2026
bfb85f0
fix(transcript): keep the prover's grinding out of verifier builds
quangvdao Oct 21, 2026
d04df1b
test(jolt-verifier): re-bless the challenge census for the single sta…
quangvdao Oct 21, 2026
f57ca65
feat(transcript): width-5 native-Fr Poseidon sponge on spongefish Pro…
quangvdao Oct 21, 2026
3cfbd10
test(transcript): restore 16-unit grind comparisons and Poseidon mess…
quangvdao Oct 21, 2026
aa43351
Merge transcript/j7-sponge-port (6cea4bc85) into transcript/j4b-posei…
quangvdao Oct 23, 2026
ebefa58
fix(transcript): drop the short-word carry explicitly, test Poseidon …
quangvdao Oct 23, 2026
0b3e125
Merge commit '13e149195' into transcript/j4b-poseidon-native-fr
quangvdao Oct 23, 2026
262a654
Merge remote-tracking branch 'upstream/transcript/j7-sponge-port' int…
quangvdao Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions crates/jolt-prover/src/blindfold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use jolt_crypto::{HomomorphicCommitment, VectorCommitment};
use jolt_field::{Accumulator, JoltField, WithAccumulator};
use jolt_openings::{AdditivelyHomomorphic, CommitmentScheme, ZkOpeningScheme};
use jolt_sumcheck::{CommittedSumcheckWitness, SumcheckDomainSpec};
use jolt_transcript::{Encoding, FieldTranscript, Label, Probe, ProverInit};
use jolt_transcript::{Encoding, FieldTranscript, Label, Probe, ProverInit, Unit};
use jolt_verifier::proof::JoltProof;
use jolt_verifier::stages::zk::{blindfold, inputs::BlindFoldInputs};
use jolt_verifier::stages::{
Expand Down Expand Up @@ -126,17 +126,17 @@ where
shell,
trusted_advice_commitment,
)?;
// A 32-byte compare guarding the exact seam the design rests on: the
// A 32-unit compare guarding the exact seam the design rests on: the
// replay landing on the prover's forward sponge state, compared by what
// copies of the two sponges squeeze next. Hard error (not debug-only) so
// release provers diagnose drift here rather than as a downstream
// BlindFold verification failure.
let next_bytes = |transcript: &T| {
let mut out = [0u8; 32];
let next_units = |transcript: &T| {
let mut out = [<T::Unit as Unit>::ZERO; 32];
transcript.message_search().probe(&[], &mut out);
out
};
if next_bytes(&transcript) != next_bytes(forward) {
if next_units(&transcript) != next_units(forward) {
return Err(ProverError::InvariantViolation {
reason: "the verifier replay diverged from the prover's forward transcript",
});
Expand Down
21 changes: 17 additions & 4 deletions crates/jolt-transcript/benches/transcript_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ use criterion::{
};
use jolt_field::Fr;
use jolt_transcript::{
prover_transcript, Blake2b512, FieldTranscript, Keccak, PoseidonSponge, ProverState, Transcript,
prover_transcript, Blake2b512, FieldTranscript, Keccak, PoseidonSponge, ProverState,
SpongeUnit, Transcript,
};
use spongefish::{DuplexSpongeInit, DuplexSpongeInterface};
use spongefish::DuplexSpongeInit;

fn fresh<H: DuplexSpongeInterface<U = u8> + DuplexSpongeInit>() -> ProverState<H> {
fn fresh<H: DuplexSpongeInit<U: SpongeUnit>>() -> ProverState<H> {
prover_transcript::<H>(b"bench", b"bench")
}

Expand All @@ -22,7 +23,7 @@ fn bench_append_bytes_with<H>(
label: &str,
data: &[u8],
) where
H: DuplexSpongeInterface<U = u8> + DuplexSpongeInit,
H: DuplexSpongeInit<U: SpongeUnit>,
{
group.bench_with_input(BenchmarkId::new(name, label), data, |bench, data| {
bench.iter_batched(
Expand Down Expand Up @@ -76,6 +77,18 @@ fn bench_challenge(c: &mut Criterion) {
);
});

group.bench_function("Poseidon", |bench| {
bench.iter_batched(
|| {
let mut t = fresh::<PoseidonSponge>();
t.append_bytes(&[42u8; 32]);
t
},
|mut t| FieldTranscript::<Fr>::challenge(&mut t),
BatchSize::SmallInput,
);
});

group.finish();
}

Expand Down
12 changes: 8 additions & 4 deletions crates/jolt-transcript/fuzz/fuzz_targets/encoding_injectivity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,13 @@
//! move changes the framing and MUST change the state. A matching state would
//! mean the framing convention fails to separate `[ab]` from `[a][b]`, the
//! classic transcript-malleability footgun. The state is observed as the next
//! 32 squeezed bytes.
//! 32 squeezed units: 32 bytes, or 32 `Fr` elements on Poseidon.

use jolt_transcript::{prover_transcript, Blake2b512, Keccak, PoseidonSponge, Transcript};
use std::fmt::Debug;

use jolt_transcript::{
prover_transcript, Blake2b512, Keccak, PoseidonSponge, SpongeUnit, Transcript,
};
use libfuzzer_sys::fuzz_target;
use spongefish::DuplexSpongeInit;

Expand All @@ -32,12 +36,12 @@ fn parse_chunks(data: &[u8]) -> Vec<&[u8]> {
chunks
}

fn framed_state<H: DuplexSpongeInit<U = u8>>(chunks: &[Vec<u8>]) -> [u8; 32] {
fn framed_state<H: DuplexSpongeInit<U: SpongeUnit + Debug>>(chunks: &[Vec<u8>]) -> Vec<H::U> {
let mut transcript = prover_transcript::<H>(b"fuzz-injectivity", b"");
for chunk in chunks {
transcript.append_bytes(chunk);
}
transcript.verifier_messages::<u8, 32>()
transcript.verifier_message_as(32, <[H::U]>::to_vec)
}

fuzz_target!(|data: &[u8]| {
Expand Down
22 changes: 13 additions & 9 deletions crates/jolt-transcript/fuzz/fuzz_targets/transcript_determinism.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
//!
//! Twin instances driven by the same fuzzer-chosen op sequence must agree on
//! every squeeze and on the final state, observed as the next 32 squeezed
//! bytes, and replaying the sequence with one absorbed byte flipped must reach
//! a different final state — a matching state would mean absorbed bytes are
//! ignored or collide.
//! units (bytes, or `Fr` elements on Poseidon), and replaying the sequence
//! with one absorbed byte flipped must reach a different final state — a
//! matching state would mean absorbed bytes are ignored or collide.

use jolt_transcript::{prover_transcript, Blake2b512, Keccak, PoseidonSponge, Transcript};
use std::fmt::Debug;

use jolt_transcript::{
prover_transcript, Blake2b512, Keccak, PoseidonSponge, SpongeUnit, Transcript,
};
use libfuzzer_sys::fuzz_target;
use spongefish::DuplexSpongeInit;

Expand Down Expand Up @@ -41,10 +45,10 @@ fn parse_ops(data: &[u8]) -> Vec<Op<'_>> {

/// Replays `ops`, optionally XOR-ing `mask` into the absorbed byte at global
/// offset `position`. Returns the challenge stream and the final state.
fn run<H: DuplexSpongeInit<U = u8>>(
fn run<H: DuplexSpongeInit<U: SpongeUnit>>(
ops: &[Op<'_>],
flip: Option<(usize, u8)>,
) -> (Vec<[u8; 16]>, [u8; 32]) {
) -> (Vec<Vec<H::U>>, Vec<H::U>) {
let mut transcript = prover_transcript::<H>(b"fuzz-determinism", b"");
let mut challenges = Vec::new();
let mut absorbed = 0usize;
Expand All @@ -63,13 +67,13 @@ fn run<H: DuplexSpongeInit<U = u8>>(
}
absorbed += chunk.len();
}
Op::Challenge => challenges.push(transcript.verifier_messages::<u8, 16>()),
Op::Challenge => challenges.push(transcript.verifier_message_as(16, <[H::U]>::to_vec)),
}
}
(challenges, transcript.verifier_messages::<u8, 32>())
(challenges, transcript.verifier_message_as(32, <[H::U]>::to_vec))
}

fn check<H: DuplexSpongeInit<U = u8>>(
fn check<H: DuplexSpongeInit<U: SpongeUnit + Debug>>(
ops: &[Op<'_>],
total_absorbed: usize,
position: usize,
Expand Down
81 changes: 55 additions & 26 deletions crates/jolt-transcript/src/challenge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@ use jolt_field::{Fq, Fr};
use jolt_field::{F128, F192, F64, F8};
#[cfg(feature = "transcript-keccak")]
use spongefish::instantiations::Keccak;
use spongefish::{DuplexSpongeInterface, VerificationError};
use spongefish::DuplexSpongeInterface;

#[cfg(feature = "transcript-blake2b")]
use crate::Blake2b512;
use crate::ChallengeSetSize;
use crate::{ChallengeSetSize, SpongeUnit};

use sealed::Sealed;

mod sealed {
pub(crate) mod sealed {
pub trait Sealed {}
}

Expand Down Expand Up @@ -93,9 +93,10 @@ mod sealed {
///
/// # Use outside this crate
///
/// `ChallengeCodec` bounds the field of every `FieldTranscript<F>`
/// implementation on spongefish's prover and verifier states, so a protocol
/// that draws field challenges from them names it as a bound. Callers cannot
/// `ChallengeCodec` bounds the field of the byte sponges' [`ChallengeSponge`]
/// implementation, behind the `FieldTranscript<F>` implementations on
/// spongefish's prover and verifier states, so a protocol that draws field
/// challenges from a byte sponge can name it as a bound. Callers cannot
/// implement it. The widths and decodings above are part of the transcript's
/// wire format: changing one changes every proof that draws challenges in
/// that field.
Expand All @@ -120,14 +121,53 @@ pub trait ChallengeCodec: Sized + Sealed {
/// security. Field challenges are decoded from raw squeezed bytes, so the
/// codecs' TV bounds hold only on such a sponge.
///
/// `PoseidonSponge` is not one: each block it squeezes is the little-endian
/// encoding of a BN254 scalar, whose top bits are far from uniform.
/// `PoseidonSponge` squeezes field elements, not bytes, and is not one; it
/// draws `Fr` challenges natively through [`ChallengeSponge`].
///
/// It is public because it bounds the sponge of the public
/// `FieldTranscript<F>` implementations. Callers name it only as that bound
/// and cannot implement it.
/// It is public because it bounds the byte sponges' [`ChallengeSponge`]
/// implementation, behind the public `FieldTranscript<F>` implementations.
/// Callers name it only as that bound and cannot implement it.
pub trait UniformBytes: DuplexSpongeInterface<U = u8> + Sealed {}

/// A sponge that draws one message of `len` challenges in `F`, with
/// conventional TV at most 2^-161 for the whole message.
///
/// Every [`UniformBytes`] sponge draws every [`ChallengeCodec`] field by
/// decoding squeezed bytes. `PoseidonSponge` draws only BN254 `Fr`, one
/// squeezed element per challenge with no decoding; challenges in other
/// fields have no implementation on it, so asking for them does not compile.
///
/// The trait is sealed.
pub trait ChallengeSponge<F>: DuplexSpongeInterface<U: SpongeUnit> + Sealed {
/// `|F|` as this sponge draws it: the number of values one challenge can
/// take. Proof-of-work difficulties are priced from it
/// ([`grind_bits_for_loss`]).
///
/// [`grind_bits_for_loss`]: crate::grind_bits_for_loss
const CHALLENGE_SET: ChallengeSetSize;

/// Units squeezed for `len` elements; depends only on the sponge, `F` and
/// `len`. `None` when `F` does not support `len` or the message would
/// exceed [`MAX_CHALLENGE_BYTES`] bytes of squeezed output.
fn challenge_units(len: usize) -> Option<usize>;

/// Decodes exactly `challenge_units(len)` squeezed units. `None` on a
/// wrong length or rejection-pool exhaustion.
fn decode_challenges(units: &[Self::U], len: usize) -> Option<Vec<F>>;
}

impl<H: UniformBytes, F: ChallengeCodec> ChallengeSponge<F> for H {
const CHALLENGE_SET: ChallengeSetSize = F::CHALLENGE_SET_SIZE;

fn challenge_units(len: usize) -> Option<usize> {
F::challenge_bytes(len).filter(|&bytes| bytes <= MAX_CHALLENGE_BYTES)
}

fn decode_challenges(units: &[u8], len: usize) -> Option<Vec<F>> {
F::decode_challenges(units, len)
}
}

#[cfg(feature = "transcript-keccak")]
impl Sealed for Keccak {}
#[cfg(feature = "transcript-keccak")]
Expand All @@ -137,10 +177,10 @@ impl Sealed for Blake2b512 {}
#[cfg(feature = "transcript-blake2b")]
impl UniformBytes for Blake2b512 {}

/// Largest challenge message, in squeezed bytes. Larger requests are rejected
/// before touching the sponge, so a length taken from the input cannot make
/// the verifier allocate without bound. Callers can compare a length against
/// it before drawing.
/// Largest challenge message, in squeezed bytes; a Poseidon element counts as
/// 32 bytes. Larger requests are rejected before touching the sponge, so a
/// length taken from the input cannot make the verifier allocate without
/// bound. Callers can compare a length against it before drawing.
pub const MAX_CHALLENGE_BYTES: usize = 1 << 24;

#[cfg(any(feature = "solinas", feature = "bn254"))]
Expand Down Expand Up @@ -350,17 +390,6 @@ macro_rules! binary_codec {
#[cfg(feature = "binary")]
binary_codec!(F8, F64, F128, F192);

/// Bytes squeezed for a message of `len` elements of `F`.
///
/// # Errors
/// Returns `VerificationError` when `F` does not support `len` or the message
/// would exceed [`MAX_CHALLENGE_BYTES`].
pub(crate) fn message_bytes<F: ChallengeCodec>(len: usize) -> Result<usize, VerificationError> {
F::challenge_bytes(len)
.filter(|&bytes| bytes <= MAX_CHALLENGE_BYTES)
.ok_or(VerificationError)
}

#[cfg(all(test, feature = "solinas"))]
mod tests {
use super::{Prime64Offset59, Sealed, UniformBytes};
Expand Down
91 changes: 89 additions & 2 deletions crates/jolt-transcript/src/codec.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,97 @@
//! Message encodings for byte-oriented spongefish transcripts.
//! Message encodings, and how a sponge absorbs them.
//!
//! [`BytesMsg`] is a byte string with an eight-byte little-endian length,
//! followed by the body. [`Label`], [`LabelWithCount`] and [`U64Word`] are the
//! 32-byte words that name what the next messages are.
//!
//! Every message is a byte [`Encoding`]. [`SpongeUnit`] maps it to the units
//! the sponge absorbs: a byte sponge absorbs the bytes as they are, and
//! `PoseidonSponge` packs them into BN254 `Fr` elements.

use spongefish::{Encoding, FromNarg, NargReader, Unit, VerificationError};

use sealed::Sealed;

pub(crate) mod sealed {
pub trait Sealed {}
}

/// The alphabet of a transcript's sponge: how the sponge absorbs a message,
/// and how it reads a proof-of-work check from squeezed units.
///
/// The trait is sealed.
pub trait SpongeUnit: Unit + Copy + Eq + Sealed {
/// The units the sponge absorbs for `message`. The map is injective and
/// prefix-free on byte strings, so messages absorbed back to back decode
/// uniquely whenever their byte encodings do.
fn encode_message<M: Encoding + ?Sized>(message: &M) -> impl AsRef<[Self]>;

/// Units squeezed for a proof-of-work check of `bits` bits, for
/// `1 <= bits <= MAX_POW_BITS`; at most 4.
///
/// [`MAX_POW_BITS`]: crate::MAX_POW_BITS
fn check_len(bits: u32) -> usize;

use spongefish::{Encoding, FromNarg, NargReader, VerificationError};
/// The low 32 bits of a check, or `None` unless `check` has the length of
/// one check.
fn low_word(check: &[Self]) -> Option<u32>;

/// Units squeezed for a short challenge of `bits` bits, for
/// `1 <= bits <= 253`.
fn short_len(bits: u32) -> usize;

/// A 256-bit little-endian integer whose low `bits` bits are within
/// 2^-160 of uniform, read from the `short_len(bits)` units of a short
/// challenge, or `None` if they do not fit.
fn short_word(units: &[Self]) -> Option<[u8; 32]>;
}

impl Sealed for u8 {}

/// A byte sponge absorbs a message's bytes as they are, and reads a check or
/// a short challenge of `bits` bits from `ceil(bits / 8)` little-endian
/// bytes.
impl SpongeUnit for u8 {
fn encode_message<M: Encoding + ?Sized>(message: &M) -> impl AsRef<[u8]> {
message.encode()
}

fn check_len(bits: u32) -> usize {
(bits as usize).div_ceil(8)
}

fn low_word(check: &[u8]) -> Option<u32> {
let mut word = [0u8; 4];
word.get_mut(..check.len())?.copy_from_slice(check);
Some(u32::from_le_bytes(word))
}

fn short_len(bits: u32) -> usize {
(bits as usize).div_ceil(8)
}

fn short_word(units: &[u8]) -> Option<[u8; 32]> {
let mut word = [0u8; 32];
word.get_mut(..units.len())?.copy_from_slice(units);
Some(word)
}
}

/// A message as its sponge absorbs it: [`SpongeUnit::encode_message`] of its
/// byte encoding. The proof carries the byte encoding itself.
pub(crate) struct Units<M>(pub(crate) M);

impl<U: SpongeUnit, M: Encoding> Encoding<U> for Units<M> {
fn encode(&self) -> impl AsRef<[U]> {
U::encode_message(&self.0)
}
}

impl<M: FromNarg> FromNarg for Units<M> {
fn from_narg(reader: &mut NargReader<'_>) -> Result<Self, VerificationError> {
M::from_narg(reader).map(Self)
}
}

/// Maximum label length in bytes accepted by [`Label`].
pub const MAX_LABEL_LEN: usize = 32;
Expand Down
Loading
Loading