diff --git a/crates/jolt-prover/src/blindfold.rs b/crates/jolt-prover/src/blindfold.rs index 689ca06099..6e3d817210 100644 --- a/crates/jolt-prover/src/blindfold.rs +++ b/crates/jolt-prover/src/blindfold.rs @@ -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::{ @@ -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 = [::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", }); diff --git a/crates/jolt-transcript/benches/transcript_ops.rs b/crates/jolt-transcript/benches/transcript_ops.rs index e1391c3fa3..71c9b87435 100644 --- a/crates/jolt-transcript/benches/transcript_ops.rs +++ b/crates/jolt-transcript/benches/transcript_ops.rs @@ -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 + DuplexSpongeInit>() -> ProverState { +fn fresh>() -> ProverState { prover_transcript::(b"bench", b"bench") } @@ -22,7 +23,7 @@ fn bench_append_bytes_with( label: &str, data: &[u8], ) where - H: DuplexSpongeInterface + DuplexSpongeInit, + H: DuplexSpongeInit, { group.bench_with_input(BenchmarkId::new(name, label), data, |bench, data| { bench.iter_batched( @@ -76,6 +77,18 @@ fn bench_challenge(c: &mut Criterion) { ); }); + group.bench_function("Poseidon", |bench| { + bench.iter_batched( + || { + let mut t = fresh::(); + t.append_bytes(&[42u8; 32]); + t + }, + |mut t| FieldTranscript::::challenge(&mut t), + BatchSize::SmallInput, + ); + }); + group.finish(); } diff --git a/crates/jolt-transcript/fuzz/fuzz_targets/encoding_injectivity.rs b/crates/jolt-transcript/fuzz/fuzz_targets/encoding_injectivity.rs index 17600e1fd6..7bec8ec74d 100644 --- a/crates/jolt-transcript/fuzz/fuzz_targets/encoding_injectivity.rs +++ b/crates/jolt-transcript/fuzz/fuzz_targets/encoding_injectivity.rs @@ -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; @@ -32,12 +36,12 @@ fn parse_chunks(data: &[u8]) -> Vec<&[u8]> { chunks } -fn framed_state>(chunks: &[Vec]) -> [u8; 32] { +fn framed_state>(chunks: &[Vec]) -> Vec { let mut transcript = prover_transcript::(b"fuzz-injectivity", b""); for chunk in chunks { transcript.append_bytes(chunk); } - transcript.verifier_messages::() + transcript.verifier_message_as(32, <[H::U]>::to_vec) } fuzz_target!(|data: &[u8]| { diff --git a/crates/jolt-transcript/fuzz/fuzz_targets/transcript_determinism.rs b/crates/jolt-transcript/fuzz/fuzz_targets/transcript_determinism.rs index b580ba53d2..09e2b5fc99 100644 --- a/crates/jolt-transcript/fuzz/fuzz_targets/transcript_determinism.rs +++ b/crates/jolt-transcript/fuzz/fuzz_targets/transcript_determinism.rs @@ -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; @@ -41,10 +45,10 @@ fn parse_ops(data: &[u8]) -> Vec> { /// Replays `ops`, optionally XOR-ing `mask` into the absorbed byte at global /// offset `position`. Returns the challenge stream and the final state. -fn run>( +fn run>( ops: &[Op<'_>], flip: Option<(usize, u8)>, -) -> (Vec<[u8; 16]>, [u8; 32]) { +) -> (Vec>, Vec) { let mut transcript = prover_transcript::(b"fuzz-determinism", b""); let mut challenges = Vec::new(); let mut absorbed = 0usize; @@ -63,13 +67,13 @@ fn run>( } absorbed += chunk.len(); } - Op::Challenge => challenges.push(transcript.verifier_messages::()), + Op::Challenge => challenges.push(transcript.verifier_message_as(16, <[H::U]>::to_vec)), } } - (challenges, transcript.verifier_messages::()) + (challenges, transcript.verifier_message_as(32, <[H::U]>::to_vec)) } -fn check>( +fn check>( ops: &[Op<'_>], total_absorbed: usize, position: usize, diff --git a/crates/jolt-transcript/src/challenge.rs b/crates/jolt-transcript/src/challenge.rs index fe7ac801b7..331e9cb719 100644 --- a/crates/jolt-transcript/src/challenge.rs +++ b/crates/jolt-transcript/src/challenge.rs @@ -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 {} } @@ -93,9 +93,10 @@ mod sealed { /// /// # Use outside this crate /// -/// `ChallengeCodec` bounds the field of every `FieldTranscript` -/// 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` 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. @@ -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` 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` implementations. +/// Callers name it only as that bound and cannot implement it. pub trait UniformBytes: DuplexSpongeInterface + 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: DuplexSpongeInterface + 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; + + /// 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>; +} + +impl ChallengeSponge for H { + const CHALLENGE_SET: ChallengeSetSize = F::CHALLENGE_SET_SIZE; + + fn challenge_units(len: usize) -> Option { + F::challenge_bytes(len).filter(|&bytes| bytes <= MAX_CHALLENGE_BYTES) + } + + fn decode_challenges(units: &[u8], len: usize) -> Option> { + F::decode_challenges(units, len) + } +} + #[cfg(feature = "transcript-keccak")] impl Sealed for Keccak {} #[cfg(feature = "transcript-keccak")] @@ -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"))] @@ -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(len: usize) -> Result { - 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}; diff --git a/crates/jolt-transcript/src/codec.rs b/crates/jolt-transcript/src/codec.rs index f9d74253ab..03fb1ddb0f 100644 --- a/crates/jolt-transcript/src/codec.rs +++ b/crates/jolt-transcript/src/codec.rs @@ -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(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; + + /// 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(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 { + 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(pub(crate) M); + +impl Encoding for Units { + fn encode(&self) -> impl AsRef<[U]> { + U::encode_message(&self.0) + } +} + +impl FromNarg for Units { + fn from_narg(reader: &mut NargReader<'_>) -> Result { + M::from_narg(reader).map(Self) + } +} /// Maximum label length in bytes accepted by [`Label`]. pub const MAX_LABEL_LEN: usize = 32; diff --git a/crates/jolt-transcript/src/lib.rs b/crates/jolt-transcript/src/lib.rs index cc77a2ebea..14cbb76b10 100644 --- a/crates/jolt-transcript/src/lib.rs +++ b/crates/jolt-transcript/src/lib.rs @@ -2,8 +2,9 @@ //! //! The prover runs spongefish's `ProverState` and the verifier its //! `VerifierState`. [`Transcript`] and [`FieldTranscript`] are the operations -//! both sides share: public messages, field challenges through each field's -//! [`ChallengeCodec`], and proof-of-work steps whose nonce the proof carries. +//! both sides share: public messages, field challenges through the sponge's +//! [`ChallengeSponge`] map, and proof-of-work steps whose nonce the proof +//! carries. //! [`ProverTranscript`] and [`VerifierTranscript`] add the NARG string. //! [`prover_transcript`] and [`verifier_transcript`] bind the session and the //! public statement before the first message. @@ -11,8 +12,11 @@ //! Three sponges are feature-gated: `transcript-keccak` (spongefish `Keccak`, //! the default), `transcript-blake2b` (spongefish `Blake2b512`) and //! `transcript-poseidon` (a Circom-compatible BN254 [`PoseidonSponge`]). -//! Field challenges need uniform squeezed bytes ([`UniformBytes`]), which -//! Poseidon does not give, so a Poseidon transcript cannot draw them. +//! Keccak and Blake2b absorb messages as bytes and decode field challenges +//! from uniform squeezed bytes ([`UniformBytes`]). Poseidon works over BN254 +//! `Fr`: it packs each message's bytes into field elements and draws `Fr` +//! challenges as squeezed elements, with no decoding; it draws challenges in +//! no other field ([`ChallengeSponge`]). #![deny(missing_docs)] // In the jolt-verifier runtime closure: stricter panic and unsafe discipline @@ -44,21 +48,21 @@ mod setup; mod transcript; mod verifier; -pub use challenge::{ChallengeCodec, UniformBytes, MAX_CHALLENGE_BYTES}; -pub use codec::{BytesMsg, Label, LabelWithCount, U64Word, MAX_LABEL_LEN}; +pub use challenge::{ChallengeCodec, ChallengeSponge, UniformBytes, MAX_CHALLENGE_BYTES}; +pub use codec::{BytesMsg, Label, LabelWithCount, SpongeUnit, U64Word, MAX_LABEL_LEN}; pub use grind_bits::{ challenge_bits_for_loss, grind_bits_for_loss, ChallengeSetSize, GrindBitsError, MAX_POW_BITS, SECURITY_BITS, }; #[cfg(feature = "transcript-poseidon")] -pub use poseidon::PoseidonSponge; +pub use poseidon::{FrUnit, PoseidonPermutation, PoseidonSponge}; #[cfg(feature = "prover-grinding")] pub use pow::{MessageSearch, Probe}; pub use prover::{ProverInit, ProverTranscript}; #[cfg(feature = "test-utils")] pub use scripted::{Script, ScriptedTranscript, TranscriptEvent}; pub use setup::{prover_transcript, session_id, verifier_transcript, PROTOCOL_ID}; -pub use spongefish::{Encoding, ProverState, VerificationError, VerifierState}; +pub use spongefish::{Encoding, ProverState, Unit, VerificationError, VerifierState}; pub use transcript::{FieldTranscript, Transcript}; pub use verifier::{VerifierInit, VerifierTranscript}; diff --git a/crates/jolt-transcript/src/poseidon.rs b/crates/jolt-transcript/src/poseidon.rs index ddb6673485..0fbcf112db 100644 --- a/crates/jolt-transcript/src/poseidon.rs +++ b/crates/jolt-transcript/src/poseidon.rs @@ -1,168 +1,246 @@ -//! `PoseidonSponge` — Circom-compatible BN254 Poseidon adapter exposed -//! through `spongefish::DuplexSpongeInterface`. +//! `PoseidonSponge` — a duplex sponge over BN254 `Fr`, on the +//! Circom-compatible Poseidon permutation. //! -//! Sponge layout: one `Fr` capacity element (`self.state`) plus two `Fr` -//! rate inputs per `permute` call, fed through light-poseidon's width-4 -//! compression function (`Poseidon::new_circom(3)` — width minus one -//! inputs). Each call replaces capacity with the compression output. +//! # Sponge //! -//! 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. +//! The sponge is spongefish's overwrite-mode [`DuplexSponge`] over the unit +//! [`FrUnit`], with the width-5 Poseidon permutation of circomlib (x^5 S-box, +//! 8 full and 60 partial rounds, the constants light-poseidon ships for five +//! state elements). Rate is 3 units (state slots 0–2) and capacity is 2 units +//! (slots 3–4). The generic bound is a birthday collision on the capacity, +//! about `2^253` permutation calls, so security rests on the permutation's +//! own 128-bit design target; one unit of capacity would give only about +//! `2^127`. +//! Absorb and squeeze are associative: absorbing in several calls equals one +//! absorb of the concatenation. //! -//! 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). +//! Challenges are squeezed natively, one unit each. A squeezed unit is a +//! uniform `Fr` up to the sponge's security, so `Fr` challenges need no +//! decoding and have no reduction bias. A short challenge of `b` bits takes +//! two units `x0` and `x1` and keeps the low `b` bits of `x0 + p * x1` +//! ([`SpongeUnit::short_word`]). Challenges in any other field are not +//! offered on this sponge. +//! +//! # Byte packing +//! +//! Every message, the session identifier and the transcript instance enter +//! the sponge as byte strings, each packed on its own: a byte string of +//! length `n` becomes the unit `n`, then `ceil(n / 31)` units, each the +//! little-endian integer of the next 31 bytes (the last chunk may be +//! shorter). Every chunk is below `2^248 < p`, so it is a canonical element, +//! and the leading length fixes the number of chunks and the size of the last +//! one: the map is injective and prefix-free, so the messages absorbed +//! between two squeezes decode uniquely from their concatenation. +//! +//! A message is packed from its byte [`Encoding`]. A one-byte message, such +//! as each byte of a proof-of-work nonce, is two units; a 32-byte field +//! element is three. -use ark_bn254::Fr; -use ark_ff::{BigInteger, PrimeField, Zero}; -use light_poseidon::{Poseidon, PoseidonHasher}; -use spongefish::{DuplexSpongeInit, DuplexSpongeInterface}; +use std::sync::OnceLock; -const SQUEEZE_BYTES: usize = 32; -const ABSORB_CHUNK_BYTES: usize = 31; +use ark_bn254::Fr as InnerFr; +use ark_ff::{AdditiveGroup, BigInteger, Field, PrimeField}; +use jolt_field::Fr; +use light_poseidon::parameters::bn254_x5::get_poseidon_parameters; +use spongefish::{DuplexSponge, EncodedSessionId, Encoding, Permutation, Unit}; -#[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") +use crate::challenge::sealed::Sealed as SpongeSealed; +use crate::codec::sealed::Sealed as UnitSealed; +use crate::{ChallengeCodec, ChallengeSetSize, ChallengeSponge, SpongeUnit, MAX_CHALLENGE_BYTES}; + +/// State width of the permutation, in field elements. +const WIDTH: usize = 5; +/// Units absorbed or squeezed per permutation call. +const RATE: usize = 3; +/// Bytes per packed chunk: `2^248 < p`, so every chunk is canonical. +const CHUNK_BYTES: usize = 31; + +/// Duplex sponge over BN254 `Fr` with the Circom-compatible width-5 Poseidon +/// permutation: rate 3 elements, capacity 2. Each message is packed on its +/// own as its byte length, then its bytes in 31-byte little-endian chunks. +pub type PoseidonSponge = DuplexSponge; + +/// One BN254 `Fr` element: the alphabet of [`PoseidonSponge`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FrUnit(InnerFr); + +impl Unit for FrUnit { + const ZERO: Self = Self(InnerFr::ZERO); } -/// 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 From for FrUnit { + fn from(value: Fr) -> Self { + Self(value.into()) + } } -impl PoseidonSponge { - /// Construct a fresh sponge with zero state. - pub fn new() -> Self { - Self { - hasher: fresh_hasher(), - state: Fr::zero(), - pending_squeeze: [0u8; SQUEEZE_BYTES], - squeeze_offset: SQUEEZE_BYTES, - } +impl From for Fr { + fn from(unit: FrUnit) -> Self { + unit.0.into() } +} + +/// Packs a byte string as its length, then 31-byte little-endian chunks. +fn pack_bytes(bytes: &[u8]) -> Vec { + let mut units = Vec::with_capacity(1 + bytes.len().div_ceil(CHUNK_BYTES)); + units.push(FrUnit(InnerFr::from(bytes.len() as u64))); + units.extend( + bytes + .chunks(CHUNK_BYTES) + .map(|chunk| FrUnit(InnerFr::from_le_bytes_mod_order(chunk))), + ); + units +} - /// Advance the state and discard any pending squeezed bytes. - pub fn ratchet(&mut self) -> &mut Self { - self.permute(Fr::zero(), Fr::zero()); - self.squeeze_offset = SQUEEZE_BYTES; - self +impl EncodedSessionId for FrUnit { + fn encode_bytes(bytes: &[u8]) -> impl AsRef<[Self]> { + pack_bytes(bytes) } +} - /// 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; +impl UnitSealed for FrUnit {} + +/// A message is its packed byte encoding. The proof-of-work check is one +/// squeezed element, tested on the low bits of its canonical integer: for a +/// uniform element and `bits <= MAX_POW_BITS`, those bits are within +/// `2^bits / p < 2^-228` of uniform. +/// +/// [`MAX_POW_BITS`]: crate::MAX_POW_BITS +impl SpongeUnit for FrUnit { + fn encode_message(message: &M) -> impl AsRef<[Self]> { + pack_bytes(message.encode().as_ref()) } - 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); - let (dst, _) = self.pending_squeeze.split_at_mut(n); - let (src, _) = bytes.split_at(n); - dst.copy_from_slice(src); - self.squeeze_offset = 0; + fn check_len(_bits: u32) -> usize { + 1 } - 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); + fn low_word(check: &[Self]) -> Option { + let [unit] = check else { + return None; + }; + let low_limb = unit.0.into_bigint().0.first().copied()?; + Some(low_limb as u32) + } + + fn short_len(_bits: u32) -> usize { + 2 } -} -impl Default for PoseidonSponge { - fn default() -> Self { - Self::new() + /// `x0 + p * x1 mod 2^256`. For uniform elements `x0` and `x1`, the integer + /// `x0 + p * x1` is uniform on `[0, p^2)`, so its low `bits` bits are + /// within `2^bits / p^2 <= 2^-253` of uniform for `bits <= 253`. + fn short_word(units: &[Self]) -> Option<[u8; 32]> { + let [low, high] = units else { + return None; + }; + let mut word = high.0.into_bigint().mul_low(&InnerFr::MODULUS); + // The carry out of bit 256 is dropped: the word is the sum mod 2^256. + let _carry = word.add_with_carry(&low.0.into_bigint()); + word.to_bytes_le().try_into().ok() } } -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, +/// Round constants and MDS matrix of the width-5 Circom permutation. +struct Constants { + /// One row of `WIDTH` constants per round. + ark: Vec<[InnerFr; WIDTH]>, + mds: [[InnerFr; WIDTH]; WIDTH], + full_rounds: usize, + partial_rounds: usize, +} + +#[expect( + clippy::expect_used, + reason = "light-poseidon ships width-5 Circom parameters of this shape; a mismatch is a build defect caught by the first test" +)] +fn constants() -> &'static Constants { + static CONSTANTS: OnceLock = OnceLock::new(); + CONSTANTS.get_or_init(|| { + let params = get_poseidon_parameters::(WIDTH as u8) + .expect("light-poseidon: width-5 Circom parameters"); + assert_eq!(params.width, WIDTH, "Poseidon width"); + assert_eq!(params.alpha, 5, "Poseidon S-box exponent"); + let rounds = params.full_rounds + params.partial_rounds; + assert_eq!(params.ark.len(), rounds * WIDTH, "Poseidon round constants"); + let ark = params + .ark + .chunks_exact(WIDTH) + .map(|row| <[InnerFr; WIDTH]>::try_from(row).expect("row of WIDTH constants")) + .collect(); + let rows: Vec<[InnerFr; WIDTH]> = params + .mds + .iter() + .map(|row| <[InnerFr; WIDTH]>::try_from(row.as_slice()).expect("MDS row")) + .collect(); + let mds = <[[InnerFr; WIDTH]; WIDTH]>::try_from(rows).expect("WIDTH MDS rows"); + Constants { + ark, + mds, + full_rounds: params.full_rounds, + partial_rounds: params.partial_rounds, } - } + }) } -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() - } +fn sbox(x: InnerFr) -> InnerFr { + x.square().square() * x } -impl DuplexSpongeInit for PoseidonSponge { - fn init(session_id: &[u8; 32]) -> Self { - let mut sponge = Self::new(); - let _ = sponge.absorb(session_id); - sponge +/// The width-5 Poseidon permutation with circomlib's constants: the same +/// rounds as light-poseidon's `Poseidon::new_circom(4)`, which applies it to +/// `[0, a, b, c, d]` and returns slot 0, and as circomlib's `PoseidonEx` with +/// four inputs, an initial state and all five outputs. +#[derive(Clone, Copy, Debug, Default)] +pub struct PoseidonPermutation; + +impl Permutation for PoseidonPermutation { + type U = FrUnit; + + fn permute_mut(&self, state: &mut [FrUnit; WIDTH]) { + let constants = constants(); + let half_full = constants.full_rounds / 2; + let partial = half_full..half_full + constants.partial_rounds; + let mut s = state.map(|unit| unit.0); + for (round, ark) in constants.ark.iter().enumerate() { + for (x, c) in s.iter_mut().zip(ark) { + *x += c; + } + if partial.contains(&round) { + if let Some(first) = s.first_mut() { + *first = sbox(*first); + } + } else { + for x in &mut s { + *x = sbox(*x); + } + } + let mut mixed = [InnerFr::ZERO; WIDTH]; + for (out, row) in mixed.iter_mut().zip(&constants.mds) { + *out = row + .iter() + .zip(&s) + .fold(InnerFr::ZERO, |acc, (m, x)| acc + *m * x); + } + s = mixed; + } + *state = s.map(FrUnit); } } -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 +impl SpongeSealed for PoseidonSponge {} + +/// `Fr` challenges are squeezed units, one per element, from the whole field. +/// Messages are capped at [`MAX_CHALLENGE_BYTES`] of 32-byte elements. +impl ChallengeSponge for PoseidonSponge { + const CHALLENGE_SET: ChallengeSetSize = ::CHALLENGE_SET_SIZE; + + fn challenge_units(len: usize) -> Option { + (len <= MAX_CHALLENGE_BYTES / 32).then_some(len) } - fn squeeze(&mut self, output: &mut [u8]) -> &mut Self { - let mut remaining = &mut *output; - while !remaining.is_empty() { - if self.squeeze_offset >= SQUEEZE_BYTES { - self.refill_squeeze(); - } - let avail = SQUEEZE_BYTES - self.squeeze_offset; - let take = avail.min(remaining.len()); - let (head, tail) = std::mem::take(&mut remaining).split_at_mut(take); - let (_, pending) = self.pending_squeeze.split_at(self.squeeze_offset); - let (src, _) = pending.split_at(take); - head.copy_from_slice(src); - self.squeeze_offset += take; - remaining = tail; - } - self + fn decode_challenges(units: &[FrUnit], len: usize) -> Option> { + (units.len() == len).then(|| units.iter().copied().map(Fr::from).collect()) } } @@ -171,86 +249,157 @@ impl DuplexSpongeInterface for PoseidonSponge { unused_results, reason = "DuplexSpongeInterface methods return &mut Self for chaining" )] +#[expect(clippy::unwrap_used, clippy::indexing_slicing, reason = "tests")] mod tests { use super::*; + use std::str::FromStr; + + use light_poseidon::{Poseidon, PoseidonHasher}; + use spongefish::{DuplexSpongeInit, DuplexSpongeInterface}; + fn unit(value: u64) -> FrUnit { + FrUnit(InnerFr::from(value)) + } + + /// The permutation agrees with light-poseidon's Circom hash, which puts a + /// zero domain tag in slot 0 and returns slot 0. #[test] - 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); + fn permutation_matches_the_circom_hash() { + let mut hasher = Poseidon::::new_circom(4).unwrap(); + for inputs in [[0u64, 0, 0, 0], [1, 2, 3, 4], [u64::MAX, 7, 1 << 40, 9]] { + let [a, b, c, d] = inputs.map(InnerFr::from); + let mut state = [FrUnit::ZERO, FrUnit(a), FrUnit(b), FrUnit(c), FrUnit(d)]; + PoseidonPermutation.permute_mut(&mut state); + assert_eq!(state[0].0, hasher.hash(&[a, b, c, d]).unwrap()); + } } - /// Clone rebuilds the (non-Clone) hasher but must preserve the duplex - /// state, including a partially consumed squeeze block. + /// Full input and output states from circomlibjs 0.1.7: `poseidon(inputs, + /// init, 5)` permutes `[init, inputs..]` and returns all five slots. This + /// pins the slots the sponge writes and reads, not only slot 0. #[test] - fn clone_resumes_a_partially_consumed_squeeze_block() { - let mut original = PoseidonSponge::new(); - original.absorb(b"clone me"); - let mut prefix = [0u8; 11]; - original.squeeze(&mut prefix); + fn permutation_matches_circomlibjs_on_the_full_state() { + let vectors: [([&str; WIDTH], [&str; WIDTH]); 3] = [ + ( + ["0", "1", "2", "3", "4"], + [ + "18821383157269793795438455681495246036402687001665670618754263018637548127333", + "7817711165059374331357136443537800893307845083525445872661165200086166013245", + "16733335996448830230979566039396561240864200624113062088822991822580465420551", + "6644334865470350789317807668685953492649391266180911382577082600917830417726", + "3372108894677221197912083238087960099443657816445944159266857514496320565191", + ], + ), + ( + ["5", "6", "7", "8", "9"], + [ + "18579283463335299535026317327209621916637121867533110620302664491185669653790", + "4839293927659934187583387626954013301637664044190093833281890908670902790478", + "3533800487131377481301584166615267513872620666740963507842462725216737178402", + "5940255035692576900005793592585174844567995327116752015751005272509148573319", + "20404954242403156198400108044020687119358875604606616587752779986720487318498", + ], + ), + ( + [ + "1606938044258990275541962092341162602522202993782792835301387", + "515377520732011331036461129765621272702107522001", + "0", + "1", + "1809251394333065553493296640760748560207343510400633813116524750123642650624", + ], + [ + "10283392003478364169555601049796176772009100738157568382521096879459620708057", + "20080835164471701059339308672369966090281333042336046927504392591494914030429", + "16354268304952869921319451770559219642228841318443983767906418135619628518778", + "10967621804834771612841019768306877278929285037226631454369680010856400098637", + "9874277098345336612192428837053126420843492328588816763190316741189242710163", + ], + ), + ]; + let parse = |digits: [&str; WIDTH]| digits.map(|d| FrUnit(InnerFr::from_str(d).unwrap())); + for (input, output) in vectors { + let mut state = parse(input); + PoseidonPermutation.permute_mut(&mut state); + assert_eq!(state, parse(output)); + } + } - let mut cloned = original.clone(); - let mut rest_original = [0u8; 40]; - let mut rest_cloned = [0u8; 40]; - original.squeeze(&mut rest_original); - cloned.squeeze(&mut rest_cloned); - assert_eq!(rest_original, rest_cloned); + #[test] + fn byte_packing_is_length_then_31_byte_chunks() { + assert_eq!(pack_bytes(&[]), [unit(0)]); + assert_eq!(pack_bytes(&[0]), [unit(1), unit(0)]); + let bytes: Vec = (1..=32).collect(); + let packed = pack_bytes(&bytes); + assert_eq!(packed.len(), 3); + assert_eq!(packed[0], unit(32)); + assert_eq!(packed[1].0, InnerFr::from_le_bytes_mod_order(&bytes[..31])); + assert_eq!(packed[2], unit(32)); + // A full chunk of 0xff is 2^248 - 1, below the modulus. + let top = pack_bytes(&[0xff; 31]); + assert_eq!(top[1].0.into_bigint().to_bytes_le()[..31], [0xff; 31]); } + /// Byte strings that alias at the chunk level stay distinct through the + /// length, and the packing never reads one string as a prefix of another. #[test] - fn clones_diverge_after_independent_absorbs() { - let mut left = PoseidonSponge::new(); - left.absorb(b"shared"); - let mut right = left.clone(); - left.absorb(b"left"); - right.absorb(b"right"); - let mut x = [0u8; 32]; - let mut y = [0u8; 32]; - left.squeeze(&mut x); - right.squeeze(&mut y); - assert_ne!(x, y); + fn byte_packing_separates_lengths() { + let strings: [&[u8]; 6] = [&[], &[0], &[0, 0], &[0; 31], &[0; 32], &[0; 62]]; + for (i, a) in strings.iter().enumerate() { + for b in &strings[i + 1..] { + let (a, b) = (pack_bytes(a), pack_bytes(b)); + assert_ne!(a, b); + assert!(!b.starts_with(&a) && !a.starts_with(&b)); + } + } + } + + fn squeeze_after(input: &[FrUnit]) -> [FrUnit; 4] { + let mut sponge = PoseidonSponge::init(&[7u8; 32]); + sponge.absorb(input); + sponge.squeeze_array::<4>() + } + + #[test] + fn absorb_and_squeeze_are_associative() { + let input: Vec = (0..8).map(unit).collect(); + let mut split = PoseidonSponge::init(&[7u8; 32]); + split + .absorb(&input[..1]) + .absorb(&input[1..5]) + .absorb(&input[5..]); + let mut first = [FrUnit::ZERO; 1]; + let mut rest = [FrUnit::ZERO; 3]; + split.squeeze(&mut first).squeeze(&mut rest); + assert_eq!([first.as_slice(), &rest].concat(), squeeze_after(&input)); + } + + #[test] + fn distinct_inputs_and_sessions_diverge() { + assert_ne!(squeeze_after(&[unit(0)]), squeeze_after(&[unit(1)])); + assert_ne!(squeeze_after(&[unit(0)]), squeeze_after(&[unit(0); 2])); + let mut other = PoseidonSponge::init(&[8u8; 32]); + other.absorb(&[unit(0)]); + assert_ne!(other.squeeze_array::<4>(), squeeze_after(&[unit(0)])); + } + + #[test] + fn clones_resume_a_partially_consumed_rate_block() { + let mut original = PoseidonSponge::init(&[1u8; 32]); + original.absorb(&[unit(5)]); + let mut prefix = [FrUnit::ZERO; 2]; + original.squeeze(&mut prefix); + let mut cloned = original.clone(); + assert_eq!(original.squeeze_array::<5>(), cloned.squeeze_array::<5>()); } #[test] - fn ratchet_advances_the_state_and_discards_pending_output() { - let mut baseline_sponge = PoseidonSponge::new(); - baseline_sponge.absorb(b"m"); - let mut baseline = [0u8; 32]; - baseline_sponge.squeeze(&mut baseline); - - let mut a = PoseidonSponge::new(); - let mut b = PoseidonSponge::new(); - a.absorb(b"m").ratchet(); - b.absorb(b"m").ratchet(); - let mut x = [0u8; 32]; - let mut y = [0u8; 32]; - a.squeeze(&mut x); - b.squeeze(&mut y); - assert_eq!(x, y, "ratchet must be deterministic"); - assert_ne!(x, baseline, "ratchet must advance the state"); - - // A ratchet after a partial squeeze must not resume the pending - // block: the remaining 27 bytes of the pre-ratchet block would be - // `baseline[5..]`. - let mut partial = PoseidonSponge::new(); - partial.absorb(b"m"); - let mut consumed = [0u8; 5]; - partial.squeeze(&mut consumed); - assert_eq!(consumed, baseline[..5], "fixture must share the block"); - partial.ratchet(); - let mut post_ratchet = [0u8; 27]; - partial.squeeze(&mut post_ratchet); - assert_ne!( - post_ratchet[..], - baseline[5..], - "pending squeeze bytes must be discarded by ratchet" + fn pow_check_reads_the_low_word_of_one_element() { + assert_eq!( + FrUnit::low_word(&[unit(0x1234_5678_9abc)]), + Some(0x5678_9abc) ); + assert_eq!(FrUnit::low_word(&[]), None); + assert_eq!(FrUnit::low_word(&[unit(0); 2]), None); } } diff --git a/crates/jolt-transcript/src/pow.rs b/crates/jolt-transcript/src/pow.rs index cd24cc22b7..ed3dd062e3 100644 --- a/crates/jolt-transcript/src/pow.rs +++ b/crates/jolt-transcript/src/pow.rs @@ -2,11 +2,15 @@ //! //! The prover //! sends a nonce below `2^(bits + 7)` as canonical LEB128, one byte per prover -//! message, and both parties squeeze `ceil(bits / 8)` bytes and accept iff -//! their low `bits` bits are zero. The step protects the verifier messages +//! message, and both parties squeeze a check and accept iff its low `bits` +//! bits are zero. On a byte sponge the check is `ceil(bits / 8)` +//! little-endian bytes; on `PoseidonSponge` it is one element, read as its +//! canonical integer ([`SpongeUnit`]). The step protects the verifier messages //! that follow it, up to the next prover message. The check is squeezed in one -//! call; on every sponge here that gives the same bytes as squeezing it one -//! byte at a time, because each buffers its squeeze output. +//! call; on every sponge here that gives the same units as squeezing it one +//! unit at a time, because each buffers its squeeze output. +//! +//! [`SpongeUnit`]: crate::SpongeUnit //! //! The same layout, search primitive and probe are proposed for spongefish in //! arkworks-rs/spongefish#301 (`PowTranscriptExt::sponge_pow`, @@ -32,27 +36,28 @@ use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(feature = "prover-grinding")] use spongefish::DuplexSpongeInterface; -use crate::MAX_POW_BITS; +use crate::{SpongeUnit, MAX_POW_BITS}; #[cfg(feature = "prover-grinding")] /// Evaluates the transcript on a candidate prover message without sending it. /// /// A probe is [`Sync`], so a search may run on /// several threads at once. -pub trait Probe: Sync { - /// Writes into `out` the bytes the transcript would squeeze right after +pub trait Probe: Sync { + /// Writes into `out` the units the transcript would squeeze right after /// absorbing `candidate`. The transcript itself is unchanged. - fn probe(&self, candidate: &[u8], out: &mut [u8]); + fn probe(&self, candidate: &[u8], out: &mut [U]); } /// A copy of the prover's public sponge, for searching over a prover message: /// a proof-of-work nonce, or a retry counter whose following challenges must /// satisfy a prover-side condition. /// -/// A probe absorbs the candidate one byte per absorb call, exactly as sending -/// it as single-byte prover messages does. For the Keccak and Blake2b sponges -/// this equals one absorb of the whole candidate; [`PoseidonSponge`] absorbs a -/// length with every call, so for it only the byte-by-byte form matches. +/// A probe absorbs the candidate one byte at a time, each byte as a one-byte +/// message, exactly as sending it as single-byte prover messages does. For the +/// Keccak and Blake2b sponges this equals one absorb of the whole candidate; +/// [`PoseidonSponge`] packs a length with every message, so for it only the +/// byte-by-byte form matches. /// /// It holds no private randomness, writes no proof bytes and cannot restore /// or advance the live prover state. @@ -72,11 +77,14 @@ impl MessageSearch { } #[cfg(feature = "prover-grinding")] -impl + Sync> Probe for MessageSearch { - fn probe(&self, candidate: &[u8], out: &mut [u8]) { +impl Probe for MessageSearch +where + H: DuplexSpongeInterface + Sync, +{ + fn probe(&self, candidate: &[u8], out: &mut [H::U]) { let mut sponge = self.sponge.clone(); for byte in candidate { - let _ = sponge.absorb(core::slice::from_ref(byte)); + let _ = sponge.absorb(H::U::encode_message(byte).as_ref()); } let _ = sponge.squeeze(out); } @@ -117,20 +125,11 @@ impl SpongePow { (self.bits as usize + 13) / 7 } - /// `ceil(bits / 8)` squeezed bytes carry the checked bits, at most 4. - pub(crate) const fn word_len(self) -> usize { - (self.bits as usize).div_ceil(8) - } - - /// The low `bits` bits of the little-endian check bytes are zero. - /// Rejects check bytes of the wrong length. - pub(crate) fn accepts(self, check: &[u8]) -> bool { - let mut word = [0u8; 4]; - match word.get_mut(..self.word_len()) { - Some(prefix) if prefix.len() == check.len() => prefix.copy_from_slice(check), - _ => return false, - } - u32::from_le_bytes(word) & ((1u32 << self.bits) - 1) == 0 + /// The low `bits` bits of the check are zero ([`SpongeUnit::low_word`]). + /// Rejects a check of the wrong length. + pub(crate) fn accepts(self, check: &[U]) -> bool { + check.len() == U::check_len(self.bits) + && U::low_word(check).is_some_and(|word| word & ((1u32 << self.bits) - 1) == 0) } /// Reads a nonce byte by byte through `next`. Returns `None` unless its @@ -155,11 +154,11 @@ impl SpongePow { Ok(None) } - /// Whether sending `nonce` makes the next squeezed bytes pass the check. + /// Whether sending `nonce` makes the next squeezed check pass. #[cfg(feature = "prover-grinding")] - fn accepts_nonce(self, probe: &impl Probe, nonce: u32) -> bool { - let mut check = [0u8; 4]; - let Some(out) = check.get_mut(..self.word_len()) else { + fn accepts_nonce(self, probe: &impl Probe, nonce: u32) -> bool { + let mut check = [U::ZERO; 4]; + let Some(out) = check.get_mut(..U::check_len(self.bits)) else { return false; }; probe.probe(Leb128::new(nonce).as_bytes(), out); @@ -172,7 +171,7 @@ impl SpongePow { /// calling thread. Either way the result is the least accepted nonce, so /// the proof does not depend on the thread count. #[cfg(feature = "prover-grinding")] - pub(crate) fn search(self, probe: &impl Probe) -> Option { + pub(crate) fn search(self, probe: &impl Probe) -> Option { let last = self.last_nonce(); // `u64::MAX` is no nonce, so it marks "not found". let least = AtomicU64::new(u64::MAX); @@ -197,7 +196,14 @@ impl SpongePow { /// increasing order and lowers `least` to the first one accepted. Stops /// once it cannot beat `least`, which other scans may lower meanwhile. #[cfg(feature = "prover-grinding")] - fn scan(self, probe: &impl Probe, first: u64, stride: usize, last: u32, least: &AtomicU64) { + fn scan( + self, + probe: &impl Probe, + first: u64, + stride: usize, + last: u32, + least: &AtomicU64, + ) { let nonces = (first..=u64::from(last)) .step_by(stride) .filter_map(|nonce| u32::try_from(nonce).ok()); @@ -245,7 +251,7 @@ impl Leb128 { #[cfg(test)] #[expect(clippy::unwrap_used, reason = "tests")] mod tests { - use super::{SpongePow, MAX_POW_BITS}; + use super::{SpongePow, SpongeUnit, MAX_POW_BITS}; #[test] fn difficulty_bounds() { @@ -258,7 +264,7 @@ mod tests { fn layout() { let layout = |bits| { let pow = SpongePow::new(bits).unwrap(); - (pow.last_nonce(), pow.max_nonce_len(), pow.word_len()) + (pow.last_nonce(), pow.max_nonce_len(), u8::check_len(bits)) }; assert_eq!(layout(1), (255, 2, 1)); assert_eq!(layout(8), ((1 << 15) - 1, 3, 1)); diff --git a/crates/jolt-transcript/src/prover.rs b/crates/jolt-transcript/src/prover.rs index 991bce7aeb..9ec27381ea 100644 --- a/crates/jolt-transcript/src/prover.rs +++ b/crates/jolt-transcript/src/prover.rs @@ -1,8 +1,9 @@ //! Prover-only operations on `spongefish::ProverState`. +use crate::codec::Units; #[cfg(feature = "prover-grinding")] use crate::pow::{Leb128, MessageSearch, SpongePow}; -use crate::{prover_transcript, Transcript}; +use crate::{prover_transcript, SpongeUnit, Transcript}; #[cfg(feature = "prover-grinding")] use spongefish::VerificationError; use spongefish::{DuplexSpongeInit, DuplexSpongeInterface, Encoding, ProverState}; @@ -11,9 +12,10 @@ use spongefish::{DuplexSpongeInit, DuplexSpongeInterface, Encoding, ProverState} /// and the proof-of-work nonce searches. pub trait ProverTranscript: Transcript { /// The duplex sponge behind the transcript. - type Sponge: DuplexSpongeInterface + Sync; + type Sponge: DuplexSpongeInterface + Sync; - /// Absorbs `msg` and appends its NARG-serialized form for verifier replay. + /// Absorbs `msg` and appends its byte encoding to the NARG string for + /// verifier replay. fn prover_message(&mut self, msg: &M); /// Bytes accumulated in the NARG so far. @@ -33,9 +35,11 @@ pub trait ProverTranscript: Transcript { /// /// For `bits == 0` this does nothing. For `1 <= bits <= MAX_POW_BITS`, the /// prover sends the least nonce below `2^(bits + 7)` for which the next - /// `ceil(bits / 8)` squeezed bytes have their low `bits` bits zero, as - /// canonical LEB128 with one byte per prover message, and squeezes those - /// bytes. The layout is the one proposed in arkworks-rs/spongefish#301. + /// squeezed check has its low `bits` bits zero, as canonical LEB128 with + /// one byte per prover message, and squeezes the check. On a byte sponge + /// the check is `ceil(bits / 8)` little-endian bytes, the layout proposed + /// in arkworks-rs/spongefish#301; on `PoseidonSponge` it is one element's + /// canonical integer. /// /// The step protects the verifier messages drawn after it, up to the next /// prover message. The protocol must draw the challenges it protects @@ -85,7 +89,7 @@ pub trait ProverInit: ProverTranscript + Sized { impl ProverInit for ProverState where - H: DuplexSpongeInit + Sync, + H: DuplexSpongeInit + Sync, { fn init(session: &[u8], statement: &(impl Encoding + ?Sized)) -> Self { prover_transcript::(session, statement) @@ -94,13 +98,13 @@ where impl ProverTranscript for ProverState where - H: DuplexSpongeInterface + Sync, + H: DuplexSpongeInterface + Sync, R: DuplexSpongeInit, { type Sponge = H; fn prover_message(&mut self, msg: &M) { - ProverState::prover_message(self, msg); + ProverState::prover_message(self, &Units(msg)); } fn narg_string(&self) -> &[u8] { @@ -122,9 +126,10 @@ where .search(&self.message_search()) .ok_or(VerificationError)?; for byte in Leb128::new(nonce).as_bytes() { - ProverState::prover_message(self, byte); + ProverTranscript::prover_message(self, byte); } - if ProverState::verifier_message_as(self, pow.word_len(), |check| pow.accepts(check)) { + if ProverState::verifier_message_as(self, H::U::check_len(bits), |check| pow.accepts(check)) + { Ok(()) } else { Err(VerificationError) diff --git a/crates/jolt-transcript/src/scripted.rs b/crates/jolt-transcript/src/scripted.rs index 9b50976223..1186ec0412 100644 --- a/crates/jolt-transcript/src/scripted.rs +++ b/crates/jolt-transcript/src/scripted.rs @@ -83,7 +83,8 @@ impl Script for VecDeque { /// otherwise fail at its first proof-of-work step. It can overstate `T`'s /// challenge set and lets the caller pick challenges, so it gives no soundness /// and must never back a real proof; the `test-utils` feature that exports it -/// is for dev-dependencies. +/// is for dev-dependencies. It wraps byte-sponge transcripts only, since its +/// script sees check bytes. pub struct ScriptedTranscript> { inner: T, script: S, @@ -136,7 +137,9 @@ impl ScriptedTranscript { } } -impl> Sealed for ScriptedTranscript { +impl, F, S: Script> Sealed for ScriptedTranscript { + type Unit = u8; + fn absorb(&mut self, message: &M) { self.events .push(TranscriptEvent::Absorb(message.encode().as_ref().to_vec())); @@ -160,7 +163,7 @@ impl> Sealed for ScriptedTranscript { impl FieldTranscript for ScriptedTranscript where - T: FieldTranscript, + T: FieldTranscript, F: Clone, S: Script, { @@ -193,7 +196,9 @@ where } } -impl> ProverTranscript for ScriptedTranscript { +impl, F, S: Script> ProverTranscript + for ScriptedTranscript +{ type Sponge = T::Sponge; fn prover_message(&mut self, msg: &M) { @@ -222,7 +227,9 @@ impl> ProverTranscript for ScriptedTranscri } } -impl> VerifierTranscript for ScriptedTranscript { +impl, F, S: Script> VerifierTranscript + for ScriptedTranscript +{ fn prover_message(&mut self) -> Result { let message = self.inner.prover_message::()?; self.events @@ -239,7 +246,7 @@ impl> VerifierTranscript for ScriptedTran } } -impl<'a, T: VerifierInit<'a>, F, S: Script + Default> VerifierInit<'a> +impl<'a, T: VerifierInit<'a, Unit = u8>, F, S: Script + Default> VerifierInit<'a> for ScriptedTranscript { fn init(session: &[u8], statement: &(impl Encoding + ?Sized), narg: &'a [u8]) -> Self { @@ -247,7 +254,9 @@ impl<'a, T: VerifierInit<'a>, F, S: Script + Default> VerifierInit<'a> } } -impl + Default> ProverInit for ScriptedTranscript { +impl, F, S: Script + Default> ProverInit + for ScriptedTranscript +{ fn init(session: &[u8], statement: &(impl Encoding + ?Sized)) -> Self { Self::scripted_by(T::init(session, statement), S::default()) } diff --git a/crates/jolt-transcript/src/setup.rs b/crates/jolt-transcript/src/setup.rs index d9b9ddc440..4319eb3338 100644 --- a/crates/jolt-transcript/src/setup.rs +++ b/crates/jolt-transcript/src/setup.rs @@ -7,6 +7,8 @@ use spongefish::instantiations::TurboShake128; use spongefish::{DuplexSpongeInit, Encoding, ProverState, SessionId, VerifierState}; +use crate::codec::{SpongeUnit, Units}; + /// Crate-wide protocol identifier (ASCII left, zero-padded to 64 bytes). /// [`session_id`] derives the spongefish session identifier from it. pub const PROTOCOL_ID: [u8; 64] = pad_id(b"a16z/jolt-transcript/v1"); @@ -31,7 +33,7 @@ const fn pad_id(src: &[u8]) -> [u8; 64] { /// The spongefish instance: the session and the statement, each with an /// eight-byte little-endian length. Never empty, so spongefish's constructors /// accept it, and self-delimiting, so the statement cannot run into the first -/// message. +/// message. The sponge absorbs it as one message ([`SpongeUnit`]). struct Instance<'a, S: ?Sized> { session: &'a [u8], statement: &'a S, @@ -80,9 +82,9 @@ impl Encoding for Instance<'_, S> { #[must_use] pub fn prover_transcript(session: &[u8], statement: &(impl Encoding + ?Sized)) -> ProverState where - H: DuplexSpongeInit, + H: DuplexSpongeInit, { - ProverState::new(&session_id(), &Instance { session, statement }) + ProverState::new(&session_id(), &Units(Instance { session, statement })) } /// Build a verifier transcript bound to `session` and `statement` over @@ -96,9 +98,9 @@ pub fn verifier_transcript<'a, H>( narg: &'a [u8], ) -> VerifierState<'a, H> where - H: DuplexSpongeInit, + H: DuplexSpongeInit, { - VerifierState::new(&session_id(), &Instance { session, statement }, narg) + VerifierState::new(&session_id(), &Units(Instance { session, statement }), narg) } /// Derive the spongefish session identifier from [`PROTOCOL_ID`] using diff --git a/crates/jolt-transcript/src/transcript.rs b/crates/jolt-transcript/src/transcript.rs index d88aed0dd8..80dc394909 100644 --- a/crates/jolt-transcript/src/transcript.rs +++ b/crates/jolt-transcript/src/transcript.rs @@ -13,36 +13,39 @@ //! for a public message and for a prover message, so the challenges are those //! of the same protocol with the field sent through the NARG string. //! -//! The sponge absorbs a message's bytes with no length or separator, so the -//! messages absorbed between two squeezes must decode uniquely from their +//! A byte sponge absorbs a message's bytes with no length or separator, so +//! the messages absorbed between two squeezes must decode uniquely from their //! concatenation. Every [`Encoding`] a protocol absorbs is therefore of fixed //! width, or of a length fixed by bound data, or carries its own count, as //! [`append_values`](Transcript::append_values) and [`BytesMsg`] do. +//! `PoseidonSponge` frames each message itself ([`SpongeUnit`]). use jolt_field::{CanonicalEncoding, Ring}; use spongefish::{ - DuplexSpongeInit, DuplexSpongeInterface, Encoding, ProverState, Transcript as _, + DuplexSpongeInit, DuplexSpongeInterface, Encoding, FromNarg, NargReader, ProverState, Unit, VerificationError, VerifierState, }; -use crate::challenge::message_bytes; -use crate::codec::{BytesMsg, Label, LabelWithCount}; +use crate::codec::{BytesMsg, Label, LabelWithCount, SpongeUnit, Units}; use crate::pow::{Leb128, SpongePow}; -use crate::{ - challenge_bits_for_loss, ChallengeCodec, ChallengeSetSize, UniformBytes, SECURITY_BITS, -}; +use crate::{challenge_bits_for_loss, ChallengeSetSize, ChallengeSponge, SECURITY_BITS}; use sealed::Sealed; pub(crate) mod sealed { use spongefish::{Encoding, VerificationError}; + use crate::SpongeUnit; + /// The three sponge operations every transcript method is built from. pub trait Sealed { + /// The sponge's alphabet. + type Unit: SpongeUnit; + /// Absorbs `message` as a public message. fn absorb(&mut self, message: &M); - /// Squeezes `len` bytes and decodes them with `decode`. - fn squeeze(&mut self, len: usize, decode: impl FnOnce(&[u8]) -> T) -> T; + /// Squeezes `len` units and decodes them with `decode`. + fn squeeze(&mut self, len: usize, decode: impl FnOnce(&[Self::Unit]) -> T) -> T; /// `Ok` when `ok` holds. On failure the verifier also poisons its /// state, so later reads and `check_eof` fail. @@ -86,8 +89,9 @@ pub trait Transcript: Sealed { /// For `bits == 0` the proof carries no nonce and the transcript is /// unchanged. For `1 <= bits <= MAX_POW_BITS` the nonce must be at most /// `2^(bits + 7) - 1`; its canonical LEB128 encoding is absorbed one byte - /// per public message, then `ceil(bits / 8)` bytes are squeezed and their - /// low `bits` bits must be zero. This is the layout of + /// per public message, then the check is squeezed and its low `bits` bits + /// must be zero: `ceil(bits / 8)` little-endian bytes on a byte sponge, one + /// element's canonical integer on `PoseidonSponge`. This is the layout of /// [`VerifierTranscript::check_grind`](crate::VerifierTranscript::check_grind) /// with the nonce absorbed as a public message instead of read from the /// NARG string; the sponge absorbs the same bytes. @@ -114,7 +118,7 @@ pub trait Transcript: Sealed { for byte in Leb128::new(nonce).as_bytes() { self.absorb(byte); } - let passes = self.squeeze(pow.word_len(), |check| pow.accepts(check)); + let passes = self.squeeze(Self::Unit::check_len(bits), |check| pow.accepts(check)); self.require(passes) } } @@ -123,23 +127,27 @@ impl Transcript for T {} /// A transcript that draws challenges in `F`. pub trait FieldTranscript: Transcript { - /// Size of the set every challenge is drawn from: the codec's - /// [`CHALLENGE_SET_SIZE`](ChallengeCodec::CHALLENGE_SET_SIZE). Prices the - /// proof of work before a challenge ([`grind_bits_for_loss`]). + /// Size of the set every challenge is drawn from + /// ([`ChallengeSponge::CHALLENGE_SET`]). Prices the proof of work before a + /// challenge ([`grind_bits_for_loss`]). /// /// [`grind_bits_for_loss`]: crate::grind_bits_for_loss const CHALLENGE_SET: ChallengeSetSize; - /// Draws `len` challenges as one message of `F`'s [`ChallengeCodec`]. + /// Draws `len` challenges as one message of the sponge's + /// [`ChallengeSponge`] map: `F`'s [`ChallengeCodec`] on a byte sponge, + /// squeezed elements on `PoseidonSponge`. /// /// `len == 0` returns an empty vector without touching the sponge. /// /// # Errors - /// `VerificationError`, before any sponge operation, when the codec does + /// `VerificationError`, before any sponge operation, when the map does /// not support `len` or the message would exceed /// [`MAX_CHALLENGE_BYTES`](crate::MAX_CHALLENGE_BYTES) bytes; otherwise on /// rejection-pool exhaustion (probability at most 2^-161). The verifier's /// state is poisoned then, so `check_eof` fails too. + /// + /// [`ChallengeCodec`]: crate::ChallengeCodec fn challenge_vector(&mut self, len: usize) -> Result, VerificationError>; /// Draws one challenge, as a message of length one. @@ -156,16 +164,22 @@ pub trait FieldTranscript: Transcript { /// that needs no proof of work. A group scalar this short costs about /// half as much as a full-width BN254 element. /// - /// It squeezes `ceil(b / 8)` bytes, clears the bits above `b`, and - /// decodes the little-endian integer. As `b` is below the bit length of - /// the field order, distinct integers decode to distinct elements. The - /// sponge's bytes are uniform ([`UniformBytes`]), so the integer is too. + /// It reads an integer from the sponge ([`SpongeUnit::short_word`]), + /// clears the bits above `b`, and decodes the little-endian integer. A + /// byte sponge squeezes `ceil(b / 8)` bytes, which are uniform + /// ([`UniformBytes`]). `PoseidonSponge` squeezes two elements `x0` and + /// `x1` and keeps the low `b` bits of `x0 + p * x1`, within 2^-253 of + /// uniform. As `b` is below the bit length of the field order, distinct + /// integers decode to distinct elements. /// /// # Errors /// `VerificationError`, before any sponge operation, when /// [`challenge_bits_for_loss`] rejects `loss_factor` or `b` is not below - /// the bit length of the field order. The verifier's state is poisoned - /// then, so `check_eof` fails too. + /// the bit length of the field order, or if the squeezed units do not fit + /// [`SpongeUnit::short_word`], which no sponge allows. The verifier's state + /// is poisoned then, so `check_eof` fails too. + /// + /// [`UniformBytes`]: crate::UniformBytes fn challenge_short_scalar(&mut self, loss_factor: u64) -> Result where F: CanonicalEncoding; @@ -188,25 +202,22 @@ pub trait FieldTranscript: Transcript { } } -/// One codec message of `len` challenges: the single implementation behind -/// every [`FieldTranscript`]. -pub(crate) fn draw_challenges( - transcript: &mut T, - len: usize, -) -> Result, VerificationError> +/// One message of `len` challenges from sponge `H`: the single +/// implementation behind every [`FieldTranscript`]. +fn draw_challenges(transcript: &mut T, len: usize) -> Result, VerificationError> where - T: Sealed + ?Sized, - F: ChallengeCodec, + T: Sealed + ?Sized, + H: ChallengeSponge, { - // A length the codec rejects poisons the verifier too, so a caller that + // A length the map rejects poisons the verifier too, so a caller that // drops the error still fails `check_eof`. - let bytes = message_bytes::(len); - transcript.require(bytes.is_ok())?; - let bytes = bytes?; + let units = H::challenge_units(len); + transcript.require(units.is_some())?; + let units = units.ok_or(VerificationError)?; if len == 0 { return Ok(Vec::new()); } - let challenges = transcript.squeeze(bytes, |tape| F::decode_challenges(tape, len)); + let challenges = transcript.squeeze(units, |tape| H::decode_challenges(tape, len)); transcript.require(challenges.is_some())?; challenges.ok_or(VerificationError) } @@ -233,28 +244,30 @@ where // `bits < MODULUS_BITS <= 256`, so the bytes fit one 32-byte word. let len = bits.div_ceil(8) as usize; let excess = 8 * len as u32 - bits; - Ok(transcript.squeeze(len, |tape| { - let mut word = [0u8; 32]; - if let Some(bytes) = word.get_mut(..tape.len()) { - bytes.copy_from_slice(tape); - } - if let Some(top) = word.get_mut(len.saturating_sub(1)) { + let scalar = transcript.squeeze(T::Unit::short_len(bits), |tape| { + let mut word = T::Unit::short_word(tape)?; + let bytes = word.get_mut(..len)?; + if let Some(top) = bytes.last_mut() { *top &= u8::MAX >> excess; } - F::from_bytes_le_reduced(word.get(..len).unwrap_or(&word)) - })) + Some(F::from_bytes_le_reduced(bytes)) + }); + transcript.require(scalar.is_some())?; + scalar.ok_or(VerificationError) } impl Sealed for ProverState where - H: DuplexSpongeInterface, + H: DuplexSpongeInterface, R: DuplexSpongeInit, { + type Unit = H::U; + fn absorb(&mut self, message: &M) { - ProverState::public_message(self, message); + ProverState::public_message(self, &Units(message)); } - fn squeeze(&mut self, len: usize, decode: impl FnOnce(&[u8]) -> T) -> T { + fn squeeze(&mut self, len: usize, decode: impl FnOnce(&[H::U]) -> T) -> T { ProverState::verifier_message_as(self, len, decode) } @@ -267,33 +280,58 @@ where } } +/// A prover message that never parses and absorbs nothing: reading it is how +/// [`Sealed::require`] poisons a verifier over any alphabet. Spongefish's +/// `Transcript::check` does the same, but only over bytes. +struct Rejection; + +impl Encoding for Rejection { + fn encode(&self) -> impl AsRef<[U]> { + let empty: [U; 0] = []; + empty + } +} + +impl FromNarg for Rejection { + fn from_narg(_: &mut NargReader<'_>) -> Result { + Err(VerificationError) + } +} + impl Sealed for VerifierState<'_, H> where - H: DuplexSpongeInterface, + H: DuplexSpongeInterface, { + type Unit = H::U; + fn absorb(&mut self, message: &M) { - VerifierState::public_message(self, message); + VerifierState::public_message(self, &Units(message)); } - fn squeeze(&mut self, len: usize, decode: impl FnOnce(&[u8]) -> T) -> T { + fn squeeze(&mut self, len: usize, decode: impl FnOnce(&[H::U]) -> T) -> T { VerifierState::verifier_message_as(self, len, decode) } + /// Neither branch absorbs or squeezes. Reading no messages fails only on a + /// poisoned state; reading a [`Rejection`] always fails and poisons it. fn require(&mut self, ok: bool) -> Result<(), VerificationError> { - self.check(|| ok) + if ok { + self.prover_messages_vec::(0).map(drop) + } else { + self.prover_message::().map(drop) + } } } impl FieldTranscript for ProverState where - H: UniformBytes, + H: ChallengeSponge, R: DuplexSpongeInit, - F: ChallengeCodec, { - const CHALLENGE_SET: ChallengeSetSize = F::CHALLENGE_SET_SIZE; + const CHALLENGE_SET: ChallengeSetSize = H::CHALLENGE_SET; fn challenge_vector(&mut self, len: usize) -> Result, VerificationError> { - draw_challenges(self, len) + draw_challenges::<_, H, F>(self, len) } fn challenge_short_scalar(&mut self, loss_factor: u64) -> Result @@ -306,13 +344,12 @@ where impl FieldTranscript for VerifierState<'_, H> where - H: UniformBytes, - F: ChallengeCodec, + H: ChallengeSponge, { - const CHALLENGE_SET: ChallengeSetSize = F::CHALLENGE_SET_SIZE; + const CHALLENGE_SET: ChallengeSetSize = H::CHALLENGE_SET; fn challenge_vector(&mut self, len: usize) -> Result, VerificationError> { - draw_challenges(self, len) + draw_challenges::<_, H, F>(self, len) } fn challenge_short_scalar(&mut self, loss_factor: u64) -> Result diff --git a/crates/jolt-transcript/src/verifier.rs b/crates/jolt-transcript/src/verifier.rs index c5700acca3..9281f83c0c 100644 --- a/crates/jolt-transcript/src/verifier.rs +++ b/crates/jolt-transcript/src/verifier.rs @@ -1,9 +1,10 @@ //! Verifier-only operations on `spongefish::VerifierState`. -use crate::{pow::SpongePow, verifier_transcript, Transcript}; +use crate::codec::Units; +use crate::transcript::sealed::Sealed as _; +use crate::{pow::SpongePow, verifier_transcript, SpongeUnit, Transcript}; use spongefish::{ - DuplexSpongeInit, DuplexSpongeInterface, Encoding, FromNarg, Transcript as _, - VerificationError, VerifierState, + DuplexSpongeInit, DuplexSpongeInterface, Encoding, FromNarg, VerificationError, VerifierState, }; /// The verifier's side of a [`Transcript`]: messages read from the NARG @@ -20,9 +21,11 @@ pub trait VerifierTranscript: Transcript { /// /// For `bits == 0` this does nothing. For `1 <= bits <= MAX_POW_BITS`, it /// reads a nonce below `2^(bits + 7)` as canonical LEB128, one byte per - /// prover message, then squeezes `ceil(bits / 8)` bytes and requires their - /// low `bits` bits to be zero. The layout is the one proposed in - /// arkworks-rs/spongefish#301. + /// prover message, then squeezes the check and requires its low `bits` + /// bits to be zero. On a byte sponge the check is `ceil(bits / 8)` + /// little-endian bytes, the layout proposed in + /// arkworks-rs/spongefish#301; on `PoseidonSponge` it is one element's + /// canonical integer. /// /// The step protects the verifier messages drawn after it, up to the next /// prover message. The protocol must draw the challenges it protects @@ -74,7 +77,7 @@ pub trait VerifierInit<'a>: VerifierTranscript + Sized { fn init(session: &[u8], statement: &(impl Encoding + ?Sized), narg: &'a [u8]) -> Self; } -impl<'a, H: DuplexSpongeInit> VerifierInit<'a> for VerifierState<'a, H> { +impl<'a, H: DuplexSpongeInit> VerifierInit<'a> for VerifierState<'a, H> { fn init(session: &[u8], statement: &(impl Encoding + ?Sized), narg: &'a [u8]) -> Self { verifier_transcript::(session, statement, narg) } @@ -82,10 +85,10 @@ impl<'a, H: DuplexSpongeInit> VerifierInit<'a> for VerifierState<'a, H> impl VerifierTranscript for VerifierState<'_, H> where - H: DuplexSpongeInterface, + H: DuplexSpongeInterface, { fn prover_message(&mut self) -> Result { - VerifierState::prover_message::(self) + VerifierState::prover_message::>(self).map(|units| units.0) } fn check_grind(&mut self, bits: u32) -> Result<(), VerificationError> { @@ -93,15 +96,16 @@ where return Ok(()); } let Some(pow) = SpongePow::new(bits) else { - return self.check(|| false); + return self.require(false); }; // Failed reads poison the state themselves; failed checks poison it - // through `check`, so `check_eof` fails afterwards too. - let nonce = pow.read_nonce(|| VerifierState::prover_message::(self))?; - self.check(|| nonce.is_some())?; - let passes = - VerifierState::verifier_message_as(self, pow.word_len(), |check| pow.accepts(check)); - self.check(|| passes) + // through `require`, so `check_eof` fails afterwards too. + let nonce = pow.read_nonce(|| VerifierTranscript::prover_message::(self))?; + self.require(nonce.is_some())?; + let passes = VerifierState::verifier_message_as(self, H::U::check_len(bits), |check| { + pow.accepts(check) + }); + self.require(passes) } fn check_eof(self) -> Result<(), VerificationError> { diff --git a/crates/jolt-transcript/tests/poseidon_tests.rs b/crates/jolt-transcript/tests/poseidon_tests.rs index fb591915c8..ae0d60d734 100644 --- a/crates/jolt-transcript/tests/poseidon_tests.rs +++ b/crates/jolt-transcript/tests/poseidon_tests.rs @@ -1,66 +1,285 @@ -//! Tests for the Poseidon sponge. -//! -//! Poseidon's squeezed bytes are not uniform, so it draws no field -//! challenges; these tests exercise it as a byte sponge. +//! Tests for the Poseidon transcript, which packs each message's bytes into +//! BN254 `Fr` elements and draws `Fr` challenges as squeezed elements. +#![expect(clippy::unwrap_used, reason = "tests")] + +use ark_bn254::Fr as BnFr; +use ark_ff::PrimeField; +use jolt_field::{CanonicalBytes, Fr}; use jolt_transcript::{ - prover_transcript, verifier_transcript, PoseidonSponge, ProverState, Transcript, + prover_transcript, session_id, verifier_transcript, BytesMsg, ChallengeSponge, FieldTranscript, + FrUnit, PoseidonSponge, ProverTranscript, Transcript, VerifierTranscript, MAX_CHALLENGE_BYTES, }; +use spongefish::{DuplexSpongeInit, DuplexSpongeInterface, ProverState}; + +const SESSION: &[u8] = b"jolt/poseidon/v1"; +const INSTANCE: [u8; 32] = [0x5a; 32]; -const SESSION: &[u8] = b"jolt-transcript/tests"; +const NATIVE_VECTOR: [[u8; 32]; 2] = [ + [ + 0x3f, 0x85, 0x29, 0x7b, 0xe8, 0x4d, 0x5f, 0xe5, 0xc0, 0x9d, 0x7a, 0xc0, 0x12, 0x57, 0xd6, + 0x90, 0xe4, 0x59, 0xfd, 0xf6, 0x1b, 0x92, 0x1e, 0x94, 0x7f, 0x03, 0x1d, 0xe0, 0xc5, 0xdf, + 0x29, 0x08, + ], + [ + 0xfa, 0x74, 0x0d, 0x13, 0x9c, 0x87, 0xb9, 0xf1, 0xd8, 0x42, 0xe8, 0xc5, 0xa0, 0x6a, 0x63, + 0xcc, 0xd7, 0xc5, 0xb4, 0xeb, 0x85, 0xf0, 0x54, 0xf7, 0x4e, 0x24, 0x6d, 0xed, 0x8c, 0x6e, + 0xeb, 0x27, + ], +]; -fn squeeze_after)>(f: F) -> [u8; 32] { - let mut transcript = prover_transcript::(SESSION, b"Jolt"); - f(&mut transcript); - transcript.verifier_message::<[u8; 32]>() +fn le_bytes(value: Fr) -> [u8; 32] { + let mut out = [0u8; 32]; + value.to_bytes_le(&mut out); + out } -#[test] -fn test_poseidon_determinism_and_order() { - let a = squeeze_after(|t| { - t.append(&1u64); - t.append(&2u64); - }); - let b = squeeze_after(|t| { - t.append(&1u64); - t.append(&2u64); - }); - let c = squeeze_after(|t| { - t.append(&2u64); - t.append(&1u64); - }); - assert_eq!(a, b); - assert_ne!(a, c); +fn fr(value: u64) -> Fr { + Fr::from(value) } +fn challenges(transcript: &mut impl FieldTranscript, len: usize) -> Vec { + transcript.challenge_vector(len).unwrap() +} + +/// A public message and a prover message. +fn sample_prover() -> ProverState { + let mut prover = prover_transcript::(SESSION, &INSTANCE); + prover.append(&BytesMsg(b"public".to_vec())); + ProverTranscript::prover_message(&mut prover, &BytesMsg(b"jolt".to_vec())); + prover +} + +/// Pins the session and instance packing, the message packing, the NARG +/// bytes and two native challenges. #[test] -fn test_poseidon_prover_and_verifier_agree() { - let mut prover = prover_transcript::(SESSION, b"Jolt"); - let mut verifier = verifier_transcript::(SESSION, b"Jolt", &[]); - prover.append_bytes(b"msg"); - verifier.append_bytes(b"msg"); +fn native_known_vector() { + let mut prover = sample_prover(); + let drawn = challenges(&mut prover, 2); + let mut narg = 4u64.to_le_bytes().to_vec(); + narg.extend_from_slice(b"jolt"); + assert_eq!(prover.narg_string(), narg); assert_eq!( - prover.verifier_message::<[u8; 32]>(), - verifier.verifier_message::<[u8; 32]>() + drawn.iter().copied().map(le_bytes).collect::>(), + NATIVE_VECTOR ); } +/// A byte string packed as the module documents: the length, then 31-byte +/// little-endian chunks. +fn packed(bytes: &[u8]) -> Vec { + let mut units = vec![FrUnit::from(fr(bytes.len() as u64))]; + for chunk in bytes.chunks(31) { + units.push(FrUnit::from(Fr::from(BnFr::from_le_bytes_mod_order(chunk)))); + } + units +} + +/// `bytes` with an eight-byte little-endian length in front. +fn framed(bytes: &[u8]) -> Vec { + [&(bytes.len() as u64).to_le_bytes()[..], bytes].concat() +} + +/// Replays the sample transcript on a bare sponge from the documented +/// packing: the instance is one packed message, each message is packed on its +/// own, and challenges are the squeezed elements themselves. +#[test] +fn challenges_are_squeezed_elements_after_the_documented_packing() { + let instance = [framed(SESSION), framed(&INSTANCE)].concat(); + let mut sponge = PoseidonSponge::init(session_id().as_bytes()); + let _ = sponge + .absorb(&packed(&instance)) + .absorb(&packed(&framed(b"public"))) + .absorb(&packed(&framed(b"jolt"))); + let expected: Vec = sponge.squeeze_array::<3>().map(Fr::from).to_vec(); + + let mut prover = sample_prover(); + assert_eq!(challenges(&mut prover, 3), expected); +} + +#[test] +fn round_trips_byte_and_scalar_messages() { + let bodies: [&[u8]; 5] = [b"", b"x", &[0xab; 23], &[0xcd; 24], &[0xef; 100]]; + let scalars = [fr(0), fr(1), -fr(1), fr(u64::MAX)]; + + let mut prover = prover_transcript::(SESSION, &INSTANCE); + prover.append(&le_bytes(fr(3))); + let mut sent = Vec::new(); + for body in bodies { + ProverTranscript::prover_message(&mut prover, &BytesMsg(body.to_vec())); + sent.push(challenges(&mut prover, 1)); + } + for scalar in scalars { + ProverTranscript::prover_message(&mut prover, &le_bytes(scalar)); + } + sent.push(challenges(&mut prover, 5)); + assert!(challenges(&mut prover, 0).is_empty()); + + let mut verifier = + verifier_transcript::(SESSION, &INSTANCE, prover.narg_string()); + verifier.append(&le_bytes(fr(3))); + let mut read = Vec::new(); + for body in bodies { + let message = VerifierTranscript::prover_message::(&mut verifier).unwrap(); + assert_eq!(message.0, body); + read.push(challenges(&mut verifier, 1)); + } + for scalar in scalars { + let message = VerifierTranscript::prover_message::<[u8; 32]>(&mut verifier).unwrap(); + assert_eq!(message, le_bytes(scalar)); + } + read.push(challenges(&mut verifier, 5)); + assert_eq!(read, sent); + verifier.check_eof().unwrap(); +} + +fn draw_after( + session: &[u8], + instance: [u8; 32], + absorb: impl FnOnce(&mut ProverState), +) -> Vec { + let mut prover = prover_transcript::(session, &instance); + absorb(&mut prover); + challenges(&mut prover, 1) +} + +/// A different message, session or instance changes the challenges. Each +/// message is packed on its own, so splitting raw bytes across messages, or +/// appending a zero byte, does too, even without a length in the encoding. +/// The same messages give the same challenges, and swapping two messages +/// changes them. #[test] -fn test_poseidon_known_vector() { - let got = squeeze_after(|t| t.append(&12345u64)); - // Pins the session derived from PROTOCOL_ID, the instance absorb, one - // `u64` public message and a 32-byte squeeze. +fn challenges_bind_messages_session_and_instance() { + let base = draw_after(SESSION, INSTANCE, |t| t.append(&[1u8, 2])); + let others = [ + draw_after(SESSION, INSTANCE, |t| { + t.append(&[1u8]); + t.append(&[2u8]); + }), + draw_after(SESSION, INSTANCE, |t| t.append(&[1u8, 2, 0])), + draw_after(SESSION, INSTANCE, |t| t.append(&[1u8, 3])), + draw_after(b"jolt/poseidon/v2", INSTANCE, |t| t.append(&[1u8, 2])), + draw_after(SESSION, [0x5b; 32], |t| t.append(&[1u8, 2])), + ]; + for other in others { + assert_ne!(base, other); + } + + let in_order = |first: u8, second: u8| { + draw_after(SESSION, INSTANCE, |t| { + t.append(&[first]); + t.append(&[second]); + }) + }; + assert_eq!(in_order(1, 2), in_order(1, 2)); + assert_ne!(in_order(1, 2), in_order(2, 1)); +} + +/// A truncated message is rejected and poisons the verifier, so later draws +/// and `check_eof` fail. Trailing bytes fail `check_eof`. +#[test] +fn rejects_truncated_and_trailing_input() { + let mut prover = prover_transcript::(SESSION, &INSTANCE); + ProverTranscript::prover_message(&mut prover, &BytesMsg(b"payload".to_vec())); + let narg = prover.narg_string().to_vec(); + + for cut in 1..=narg.len() { + let truncated = &narg[..narg.len() - cut]; + let mut verifier = verifier_transcript::(SESSION, &INSTANCE, truncated); + assert!(VerifierTranscript::prover_message::(&mut verifier).is_err()); + assert!(FieldTranscript::::challenge_vector(&mut verifier, 1).is_err()); + assert!(verifier.check_eof().is_err()); + } + + let trailing = [narg.as_slice(), &[0]].concat(); + let mut verifier = verifier_transcript::(SESSION, &INSTANCE, &trailing); + let message = VerifierTranscript::prover_message::(&mut verifier).unwrap(); + assert_eq!(message.0, b"payload"); + assert!(verifier.check_eof().is_err()); +} + +/// The cap counts 32 bytes per element; a larger request fails before +/// touching the sponge and poisons the verifier. +#[test] +fn oversized_challenge_messages_fail_and_poison() { + let cap = MAX_CHALLENGE_BYTES / 32; assert_eq!( - hex(&got), - "788647c07eed8851fb17d6eeb2682d72ccca468c7ebd9f49a071712305eb8817", - "Poseidon known-vector regression" + >::challenge_units(cap), + Some(cap) ); + for len in [cap + 1, usize::MAX] { + assert_eq!( + >::challenge_units(len), + None + ); + + let mut prover = prover_transcript::(SESSION, &INSTANCE); + assert!(FieldTranscript::::challenge_vector(&mut prover, len).is_err()); + let mut fresh = prover_transcript::(SESSION, &INSTANCE); + assert_eq!(challenges(&mut prover, 1), challenges(&mut fresh, 1)); + + let mut verifier = verifier_transcript::(SESSION, &INSTANCE, &[]); + assert!(FieldTranscript::::challenge_vector(&mut verifier, len).is_err()); + assert!(verifier.check_eof().is_err()); + } } -fn hex(bytes: &[u8]) -> String { - use std::fmt::Write; - bytes.iter().fold(String::new(), |mut hex, byte| { - let _ = write!(hex, "{byte:02x}"); - hex - }) +/// Native challenges are full-width: across many draws the top half takes +/// values a 128-bit challenge never would. +#[test] +fn challenges_are_full_width() { + let mut prover = prover_transcript::(SESSION, &INSTANCE); + assert!(challenges(&mut prover, 64) + .iter() + .any(|&c| le_bytes(c)[16..].iter().any(|&b| b != 0))); +} + +/// A short challenge squeezes two elements `x0` and `x1`, keeps the low `b` +/// bits of `x0 + p * x1`, and squeezes nothing more. The verifier draws the +/// same value. The expected value is computed on unbounded integers. +#[test] +fn short_challenge_keeps_the_low_bits_of_two_elements() { + use jolt_field::CanonicalEncoding; + use num_bigint::BigUint; + + let int = |x: Fr| BigUint::from_bytes_le(&le_bytes(x)); + let modulus = int(-fr(1)) + 1u32; + for (loss, bits) in [(1u64, 128u32), (4, 130), (u64::MAX, 192)] { + let mut widest = 0; + for round in 0..64u32 { + let statement = round.to_be_bytes(); + let mut short = prover_transcript::(SESSION, &statement); + let mut manual = prover_transcript::(SESSION, &statement); + let value: Fr = short.challenge_short_scalar(loss).unwrap(); + + let drawn = challenges(&mut manual, 3); + let expected = + (int(drawn[0]) + &modulus * int(drawn[1])) % (BigUint::from(1u32) << bits); + let mut bytes = expected.to_bytes_le(); + bytes.resize(bits.div_ceil(8) as usize, 0); + assert_eq!(value, Fr::from_bytes_le_reduced(&bytes)); + assert_eq!(challenges(&mut short, 1), [drawn[2]]); + + let mut verifier = verifier_transcript::(SESSION, &statement, &[]); + let verified: Fr = verifier.challenge_short_scalar(loss).unwrap(); + assert_eq!(verified, value); + widest = widest.max(value.num_bits()); + } + // The top bit is set with probability 1/2 per draw. + assert_eq!(widest, bits, "loss={loss}"); + } +} + +/// A zero loss factor is refused before anything is squeezed, and poisons the +/// verifier. +#[test] +fn short_challenge_refuses_zero_loss_and_poisons() { + let mut prover = prover_transcript::(SESSION, &INSTANCE); + assert!(FieldTranscript::::challenge_short_scalar(&mut prover, 0).is_err()); + let mut fresh = prover_transcript::(SESSION, &INSTANCE); + assert_eq!(challenges(&mut prover, 1), challenges(&mut fresh, 1)); + + let mut verifier = verifier_transcript::(SESSION, &INSTANCE, &[]); + assert!(FieldTranscript::::challenge_short_scalar(&mut verifier, 0).is_err()); + assert!(verifier.check_eof().is_err()); } diff --git a/crates/jolt-transcript/tests/pow_tests.rs b/crates/jolt-transcript/tests/pow_tests.rs index f9e2248c31..c6eaa6d8f5 100644 --- a/crates/jolt-transcript/tests/pow_tests.rs +++ b/crates/jolt-transcript/tests/pow_tests.rs @@ -6,11 +6,13 @@ use jolt_field::{CanonicalBytes, F8}; use jolt_transcript::FieldTranscript; use jolt_transcript::{ prover_transcript, verifier_transcript, Blake2b512, BytesMsg, PoseidonSponge, Probe, - ProverTranscript, VerifierTranscript, MAX_POW_BITS, + ProverTranscript, SpongeUnit, VerifierTranscript, MAX_POW_BITS, }; use spongefish::{ - instantiations::Keccak, DuplexSpongeInit, DuplexSpongeInterface, ProverState, VerifierState, + instantiations::Keccak, DuplexSpongeInit, DuplexSpongeInterface, ProverState, Unit, + VerifierState, }; +use std::fmt::Debug; const SESSION: &[u8] = b"jolt/pow/v1"; const INSTANCE: [u8; 32] = [0x24; 32]; @@ -127,53 +129,63 @@ fn layout_matches_the_manual_transcript() { } } -fn sponge_prover>() -> ProverState { +fn sponge_prover + Sync>() -> ProverState { let mut prover = prover_transcript::(SESSION, &INSTANCE); - prover.prover_message(&BytesMsg(b"before".to_vec())); + ProverTranscript::prover_message(&mut prover, &BytesMsg(b"before".to_vec())); prover } -fn sponge_verifier>(narg: &[u8]) -> VerifierState<'_, H> { +fn sponge_verifier>(narg: &[u8]) -> VerifierState<'_, H> { let mut verifier = verifier_transcript::(SESSION, &INSTANCE, narg); - assert_eq!(verifier.prover_message::().unwrap().0, b"before"); + assert_eq!( + VerifierTranscript::prover_message::(&mut verifier) + .unwrap() + .0, + b"before" + ); verifier } +/// Whether `check` is one check whose low `bits` bits are zero. +fn check_passes(bits: u32, check: &[U]) -> bool { + check.len() == U::check_len(bits) && U::low_word(check).unwrap() & ((1u32 << bits) - 1) == 0 +} + /// Runs `check_grind` on `before ‖ nonce ‖ after` and returns the verifier's -/// result, the bytes it squeezes next and whether the rest still verifies. -fn verify_nonce>( +/// result, the units it squeezes next and whether the rest still verifies. +fn verify_nonce + Sync>( prefix: &[u8], nonce: &[u8], bits: u32, -) -> (bool, [u8; 16], bool) { +) -> (bool, Vec, bool) { let mut after = sponge_prover::(); - after.prover_message(&BytesMsg(b"after".to_vec())); + ProverTranscript::prover_message(&mut after, &BytesMsg(b"after".to_vec())); let suffix = &after.narg_string()[prefix.len()..]; let narg = [prefix, nonce, suffix].concat(); let mut verifier = sponge_verifier::(&narg); let passed = VerifierTranscript::check_grind(&mut verifier, bits).is_ok(); - let next = verifier.verifier_message::<[u8; 16]>(); - let rest = verifier - .prover_message::() + let next = verifier.verifier_message_as(16, <[H::U]>::to_vec); + let rest = VerifierTranscript::prover_message::(&mut verifier) .is_ok_and(|message| message.0 == b"after") && verifier.check_eof().is_ok(); (passed, next, rest) } -/// For each sponge, including Poseidon, which absorbs a length with every -/// call: the search replays the real sends, the honest nonce round-trips and -/// is the least one, a larger passing nonce in range is accepted (proof bytes -/// are not unique), and a passing nonce out of range is rejected and poisons. -fn sponge_checks + Sync>() { +/// For each sponge, including Poseidon, which packs a length with every +/// message and checks the low bits of one squeezed element: the search +/// replays the real sends, the honest nonce round-trips and is the least one, +/// a larger passing nonce in range is accepted (proof bytes are not unique), +/// and a passing nonce out of range is rejected and poisons. +fn sponge_checks + Sync>() { for bits in [1, 2, 9] { - let word_len = (bits as usize).div_ceil(8); + let check_len = H::U::check_len(bits); let base = sponge_prover::(); let prefix = base.narg_string().to_vec(); let search = ProverTranscript::message_search(&base); let probe_passes = |value: u32| { - let mut check = vec![0u8; word_len]; + let mut check = vec![H::U::ZERO; check_len]; search.probe(&leb128(value), &mut check); - passes(bits, &check) + check_passes(bits, &check) }; let mut prover = sponge_prover::(); @@ -181,15 +193,17 @@ fn sponge_checks + Sync>() { let nonce = prover.narg_string()[prefix.len()..].to_vec(); let value = decode(&nonce); assert_eq!(nonce, leb128(value)); - let challenge = prover.verifier_message::<[u8; 16]>(); + let challenge = prover.verifier_message_as(16, <[H::U]>::to_vec); let mut sent = sponge_prover::(); for byte in &nonce { - sent.prover_message(byte); + ProverTranscript::prover_message(&mut sent, byte); } - let mut probed = [0u8; 40]; + let mut probed = vec![H::U::ZERO; 40]; search.probe(&nonce, &mut probed); - assert_eq!(sent.verifier_message::<[u8; 40]>(), probed); + assert_eq!(sent.verifier_message_as(40, <[H::U]>::to_vec), probed); + assert!(check_passes(bits, &probed[..check_len])); + assert_eq!(probed[check_len..check_len + 16], challenge[..]); assert_eq!( verify_nonce::(&prefix, &nonce, bits), diff --git a/crates/jolt-verifier/tests/fs_inventory/absorb-sites.inventory b/crates/jolt-verifier/tests/fs_inventory/absorb-sites.inventory index 9fc33fc51f..003d45bd89 100644 --- a/crates/jolt-verifier/tests/fs_inventory/absorb-sites.inventory +++ b/crates/jolt-verifier/tests/fs_inventory/absorb-sites.inventory @@ -116,10 +116,7 @@ jolt-sumcheck::verifier.rs::SumcheckVerifier::verify::append_to_transcript#0::ro jolt-sumcheck::verifier.rs::SumcheckVerifier::verify_committed_round_consistency::append_to_transcript#0::round_proof.append_to_transcript(transcript) jolt-sumcheck::verifier.rs::SumcheckVerifier::verify_compressed::append#0::transcript.append(&LabelWithCount(round_label,coeffs.len()asu64)) jolt-sumcheck::verifier.rs::SumcheckVerifier::verify_compressed::append#1::transcript.append(coeff) -jolt-transcript::poseidon.rs::PoseidonSponge::absorb::absorb_fr_pair#0::self.absorb_fr_pair(len_fr,Fr::zero()) -jolt-transcript::poseidon.rs::PoseidonSponge::absorb::absorb_fr_pair#1::self.absorb_fr_pair(a,b) -jolt-transcript::poseidon.rs::PoseidonSponge::init::absorb#0::sponge.absorb(session_id) -jolt-transcript::pow.rs::MessageSearch::probe::absorb#0::sponge.absorb(core::slice::from_ref(byte)) +jolt-transcript::pow.rs::MessageSearch::probe::absorb#0::sponge.absorb(H::U::encode_message(byte).as_ref()) jolt-transcript::scripted.rs::ScriptedTranscript::absorb::absorb#0::self.inner.absorb(message) jolt-transcript::scripted.rs::ScriptedTranscript::init::init#0::T::init(session,statement,narg) jolt-transcript::scripted.rs::ScriptedTranscript::init::init#1::T::init(session,statement) diff --git a/examples/fibonacci/Cargo.toml b/examples/fibonacci/Cargo.toml index e1d57f0824..5ba746b983 100644 --- a/examples/fibonacci/Cargo.toml +++ b/examples/fibonacci/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [features] transcript-blake2b = ["jolt-sdk/transcript-blake2b"] +transcript-poseidon = ["jolt-sdk/transcript-poseidon"] [dependencies] jolt-sdk = { workspace = true, features = ["host"] } diff --git a/examples/merkle-tree-save/Cargo.toml b/examples/merkle-tree-save/Cargo.toml index bf87593b8d..5764d6860d 100644 --- a/examples/merkle-tree-save/Cargo.toml +++ b/examples/merkle-tree-save/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [features] transcript-blake2b = ["jolt-sdk/transcript-blake2b"] +transcript-poseidon = ["jolt-sdk/transcript-poseidon"] [dependencies] jolt-sdk = { workspace = true, features = ["host"] } diff --git a/jolt-eval/src/invariant/transcript_symmetry.rs b/jolt-eval/src/invariant/transcript_symmetry.rs index ba2717d0e5..a2aca3cf2b 100644 --- a/jolt-eval/src/invariant/transcript_symmetry.rs +++ b/jolt-eval/src/invariant/transcript_symmetry.rs @@ -9,7 +9,8 @@ use spongefish::instantiations::Keccak; use spongefish::{DuplexSpongeInit, VerificationError}; use jolt_transcript::{ - prover_transcript, verifier_transcript, Blake2b512, BytesMsg, PoseidonSponge, + prover_transcript, verifier_transcript, Blake2b512, BytesMsg, ChallengeSponge, FieldTranscript, + PoseidonSponge, ProverTranscript, Transcript, VerifierTranscript, }; use crate::invariant::{CheckError, Invariant, InvariantViolation}; @@ -69,19 +70,22 @@ fn arb_scalar(u: &mut Unstructured<'_>) -> ArbitraryResult { fn run_check(input: &Input) -> Result<(), CheckError> where - H: DuplexSpongeInit, + H: DuplexSpongeInit + ChallengeSponge + Sync, { let mut prover = prover_transcript::(SESSION, &INSTANCE_DIGEST); - let mut prover_challenges: Vec<[u8; 32]> = Vec::new(); + let mut prover_challenges: Vec = Vec::new(); - for op in &input.ops { + for (op_idx, op) in input.ops.iter().enumerate() { match op { - Op::PublicBytes(b) => prover.public_message(&BytesMsg(b.clone())), - Op::PublicScalar(f) => prover.public_message(&scalar_bytes(*f)), - Op::ProverBytes(b) => prover.prover_message(&BytesMsg(b.clone())), - Op::ProverScalar(f) => prover.prover_message(&scalar_bytes(*f)), + Op::PublicBytes(b) => prover.append(&BytesMsg(b.clone())), + Op::PublicScalar(f) => prover.append(&scalar_bytes(*f)), + Op::ProverBytes(b) => { + ProverTranscript::prover_message(&mut prover, &BytesMsg(b.clone())) + } + Op::ProverScalar(f) => ProverTranscript::prover_message(&mut prover, &scalar_bytes(*f)), Op::Challenge => { - let c: [u8; 32] = prover.verifier_message(); + let c = FieldTranscript::::challenge(&mut prover) + .map_err(|e| violation("prover challenge", op_idx, e))?; prover_challenges.push(c); } } @@ -93,26 +97,25 @@ 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(&scalar_bytes(*f)), + Op::PublicBytes(b) => verifier.append(&BytesMsg(b.clone())), + Op::PublicScalar(f) => verifier.append(&scalar_bytes(*f)), Op::ProverBytes(expected) => { - let got: BytesMsg = verifier - .prover_message() + let got: BytesMsg = VerifierTranscript::prover_message(&mut verifier) .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: [u8; 32] = verifier - .prover_message() + let got: [u8; 32] = VerifierTranscript::prover_message(&mut verifier) .map_err(|e| violation("prover_message<[u8; 32]>", op_idx, e))?; if got != scalar_bytes(*expected) { return Err(mismatch("ProverScalar round-trip", op_idx)); } } Op::Challenge => { - let verifier_c: [u8; 32] = verifier.verifier_message(); + let verifier_c = FieldTranscript::::challenge(&mut verifier) + .map_err(|e| violation("verifier challenge", op_idx, e))?; if verifier_c != prover_challenges[challenge_idx] { return Err(mismatch("Challenge", op_idx)); } @@ -121,8 +124,7 @@ where } } - verifier - .check_eof() + VerifierTranscript::check_eof(verifier) .map_err(|e| violation("check_eof", input.ops.len(), e))?; Ok(()) } diff --git a/jolt-sdk/Cargo.toml b/jolt-sdk/Cargo.toml index 1e1c3414f8..0b390def03 100644 --- a/jolt-sdk/Cargo.toml +++ b/jolt-sdk/Cargo.toml @@ -70,6 +70,7 @@ guest-verifier = [ malloc-shim = ["jolt-platform/malloc-shim"] transcript-blake2b = ["jolt-transcript/transcript-blake2b"] +transcript-poseidon = ["jolt-transcript/transcript-poseidon"] # Jolt-SDK native features (optional capabilities) # diff --git a/jolt-sdk/src/host_utils.rs b/jolt-sdk/src/host_utils.rs index 7bac463967..897d662244 100644 --- a/jolt-sdk/src/host_utils.rs +++ b/jolt-sdk/src/host_utils.rs @@ -21,14 +21,20 @@ use jolt_prover::dory::stages::stage0::TrustedAdviceCommitment; use jolt_prover::{JoltProverPreprocessing as GenericJoltProverPreprocessing, ProverConfig}; #[cfg(all( any(feature = "host", feature = "guest-verifier"), - feature = "transcript-blake2b" + feature = "transcript-blake2b", + not(feature = "transcript-poseidon") ))] use jolt_transcript::Blake2b512; #[cfg(all( any(feature = "host", feature = "guest-verifier"), - not(feature = "transcript-blake2b") + not(any(feature = "transcript-blake2b", feature = "transcript-poseidon")) ))] use jolt_transcript::Keccak; +#[cfg(all( + any(feature = "host", feature = "guest-verifier"), + feature = "transcript-poseidon" +))] +use jolt_transcript::PoseidonSponge; #[cfg(feature = "host")] use jolt_transcript::ProverState; #[cfg(any(feature = "host", feature = "guest-verifier"))] @@ -42,18 +48,28 @@ use tracer::TracerInlineExpansionProvider; #[cfg(feature = "host")] pub use jolt_host as host; -/// The duplex sponge of the Fiat-Shamir transcript: Keccak, or Blake2b512 with -/// the `transcript-blake2b` feature. +#[cfg(all(feature = "transcript-blake2b", feature = "transcript-poseidon"))] +compile_error!("enable at most one of `transcript-blake2b` and `transcript-poseidon`"); +/// The duplex sponge of the Fiat-Shamir transcript: Keccak by default, +/// Blake2b512 with the `transcript-blake2b` feature, or the BN254 Poseidon +/// sponge with the `transcript-poseidon` feature. #[cfg(all( any(feature = "host", feature = "guest-verifier"), - not(feature = "transcript-blake2b") + not(any(feature = "transcript-blake2b", feature = "transcript-poseidon")) ))] pub type ProtocolSponge = Keccak; #[cfg(all( any(feature = "host", feature = "guest-verifier"), - feature = "transcript-blake2b" + feature = "transcript-blake2b", + not(feature = "transcript-poseidon") ))] pub type ProtocolSponge = Blake2b512; +#[cfg(all( + any(feature = "host", feature = "guest-verifier"), + feature = "transcript-poseidon", + not(feature = "transcript-blake2b") +))] +pub type ProtocolSponge = PoseidonSponge; #[cfg(feature = "host")] pub type ProofTranscript = ProverState; #[cfg(feature = "host")]