Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
a29bff2
spec: port jolt-transcript to spongefish
shreyas-londhe Apr 21, 2026
848ebb1
spec: narrow scope to crates/jolt-transcript internal port
shreyas-londhe Apr 22, 2026
d7cf298
spec: narrow jolt-transcript port to crate-only with compat layer
Vishalkulkarni45 May 5, 2026
d90cda5
spec: resolve implementation ambiguities found in analyze-spec review
shreyas-londhe May 5, 2026
4654db8
feat(jolt-transcript): port internals to spongefish 0.7
shreyas-londhe May 12, 2026
042f00c
feat(jolt-eval): add transcript_prover_verifier_consistency invariant
shreyas-londhe May 12, 2026
d387b06
spec: mark jolt-transcript-spongefish status implemented
shreyas-londhe May 12, 2026
5590acc
Merge upstream/main into spec/jolt-transcript-spongefish
shreyas-londhe May 12, 2026
2125d8f
fix(ci): rustfmt, taplo, and pin generic-array 0.14.7
shreyas-londhe May 12, 2026
a68ed76
fix(jolt-transcript): generic FieldEl and guard BytesMsg length overflow
shreyas-londhe May 13, 2026
f824065
perf(jolt-transcript): drop per-op peek_state cache in SpongeTranscript
shreyas-londhe May 13, 2026
ddb62ed
docs(jolt-transcript): note compat challenge() vs OptimizedChallenge …
shreyas-londhe May 13, 2026
40b940a
fix(jolt-transcript): drop Encoding impl on FieldElOptimized to block…
shreyas-londhe May 13, 2026
334c78f
refactor(jolt-transcript): drop Clone bound from compat::Transcript
shreyas-londhe May 19, 2026
4833a93
refactor(jolt-transcript): drop FieldElOptimized, use spongefish u128…
shreyas-londhe May 19, 2026
eec24ad
refactor(jolt-transcript): rename `compat` module to `legacy`
shreyas-londhe May 19, 2026
dfb7ce9
feat(jolt-transcript): native construction API — prover_transcript / …
shreyas-londhe May 19, 2026
b82947c
docs(jolt-transcript): check_eof contract + regression tests
shreyas-londhe May 19, 2026
1e623e6
chore(jolt-transcript): rename PROTOCOL_ID to drop "spongefish"
shreyas-londhe May 19, 2026
2f1215f
refactor(jolt-transcript): drop FieldEl, use spongefish ark-ff codec
shreyas-londhe May 19, 2026
981aa57
Merge remote-tracking branch 'upstream/main' into spec/jolt-transcrip…
shreyas-londhe May 19, 2026
67a1a3c
chore: apply rustfmt and taplo after merge
shreyas-londhe May 20, 2026
b8f4b23
refactor(jolt-transcript): use dot-call syntax in tests per mmaker fe…
shreyas-londhe May 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
852 changes: 641 additions & 211 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -248,11 +248,12 @@ ark-secp256r1 = { git = "https://github.com/a16z/arkworks-algebra", branch = "de
ark-serialize-derive = { version = "0.5.0", default-features = false }
ark-std = { version = "0.5.0", default-features = false }
sha2 = "0.11"
sha3 = "0.11"
sha3 = "=0.11.0-rc.9"
blake2 = "0.11.0-rc.6"
blake3 = { version = "1.5.0" }
light-poseidon = "0.4"
digest = "0.11"
spongefish = { version = "0.7", default-features = false, features = ["sha3"] }
jolt-optimizations = { git = "https://github.com/a16z/arkworks-algebra", branch = "dev/twist-shout" }
dory = { package = "dory-pcs", version = "0.3.0", features = [
"backends",
Expand Down
29 changes: 11 additions & 18 deletions crates/jolt-transcript/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,21 @@ categories = ["cryptography", "no-std"]
[lints]
workspace = true

[features]
default = ["transcript-blake2b", "transcript-keccak", "transcript-poseidon"]
transcript-blake2b = ["spongefish/blake2"]
transcript-keccak = ["spongefish/keccak"]
transcript-poseidon = ["dep:light-poseidon"]

[dependencies]
blake2.workspace = true
digest.workspace = true
sha3.workspace = true
ark-bn254 = { workspace = true, optional = true }
ark-ff = { workspace = true, optional = true }
ark-serialize = { workspace = true, optional = true }
ark-bn254.workspace = true
ark-ff.workspace = true
light-poseidon = { workspace = true, optional = true }
jolt-field = { path = "../jolt-field", default-features = false }

[features]
default = ["poseidon"]
poseidon = [
"dep:ark-bn254",
"dep:ark-ff",
"dep:ark-serialize",
"dep:light-poseidon",
"jolt-field/bn254",
]
spongefish = { workspace = true }
jolt-field = { path = "../jolt-field", features = ["bn254"] }
rand.workspace = true

[dev-dependencies]
jolt-field = { path = "../jolt-field", features = ["bn254"] }
num-traits = { workspace = true }
criterion = { workspace = true }

Expand Down
13 changes: 0 additions & 13 deletions crates/jolt-transcript/src/blake2b.rs

This file was deleted.

16 changes: 0 additions & 16 deletions crates/jolt-transcript/src/blanket.rs

This file was deleted.

188 changes: 188 additions & 0 deletions crates/jolt-transcript/src/codec.rs
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
//! Local codecs for absorbing / decoding Jolt-native messages over a
//! byte-oriented spongefish sponge.
//!
//! Spongefish ships optional arkworks codec features; we don't enable them
//! because Jolt patches `ark-ff` / `ark-serialize` to a fork. These local
//! codecs are injective and prefix-free.

use ark_bn254::Fr;
use ark_ff::{BigInteger, PrimeField};
use spongefish::{Decoding, Encoding, NargDeserialize, VerificationError, VerificationResult};

const FR_LE_BYTES: usize = 32;
const FR_TRUNCATED_BYTES: usize = 16;
/// Bytes drawn per full-field challenge. 64 bytes mod the BN254 modulus
/// is within `2^{-130}` statistical distance of uniform.
const FR_UNIFORM_BYTES: usize = 64;

fn fr_to_le_bytes(f: &Fr) -> [u8; FR_LE_BYTES] {
let bytes = f.into_bigint().to_bytes_le();
debug_assert_eq!(
bytes.len(),
FR_LE_BYTES,
"BN254 Fr LE serialization is fixed-width 32 bytes"
);
let mut out = [0u8; FR_LE_BYTES];
out.copy_from_slice(&bytes);
out
}

/// Wraps a BN254 `Fr` for absorption / decoding as 32 little-endian bytes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FieldEl(pub Fr);

impl From<Fr> for FieldEl {
fn from(f: Fr) -> Self {
Self(f)
}
}

impl Encoding<[u8]> for FieldEl {
fn encode(&self) -> impl AsRef<[u8]> {
fr_to_le_bytes(&self.0)
}
}

/// 64-byte squeeze buffer used as the [`Decoding::Repr`] for full-field
/// challenges. See `FR_UNIFORM_BYTES`.
#[derive(Clone, Copy)]
pub struct UniformFrBytes(pub [u8; FR_UNIFORM_BYTES]);

impl Default for UniformFrBytes {
fn default() -> Self {
Self([0u8; FR_UNIFORM_BYTES])
}
}

impl AsMut<[u8]> for UniformFrBytes {
fn as_mut(&mut self) -> &mut [u8] {
&mut self.0
}
}

impl Decoding<[u8]> for FieldEl {
type Repr = UniformFrBytes;
fn decode(buf: Self::Repr) -> Self {
FieldEl(Fr::from_le_bytes_mod_order(&buf.0))
}
}

impl NargDeserialize for FieldEl {
fn deserialize_from_narg(buf: &mut &[u8]) -> VerificationResult<Self> {
if buf.len() < FR_LE_BYTES {
return Err(VerificationError);
}
let (head, tail) = buf.split_at(FR_LE_BYTES);
*buf = tail;
Ok(FieldEl(Fr::from_le_bytes_mod_order(head)))
}
}

/// 128-bit-truncating challenge wrapper. Decodes 16 squeezed bytes via
/// `Fr::from(u128)`. Used only as a verifier message; the `Encoding` impl
/// is the same 32-byte LE form as [`FieldEl`] so that absorbing one of
/// these symmetrically with the other type stays a code error rather than
/// a wire-format hazard.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FieldElOptimized(pub Fr);

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

Choose a reason for hiding this comment

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

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


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

fn encode(&self) -> impl AsRef<[u8]> {
fr_to_le_bytes(&self.0)
}
}

impl Decoding<[u8]> for FieldElOptimized {
type Repr = [u8; FR_TRUNCATED_BYTES];
fn decode(buf: Self::Repr) -> Self {
FieldElOptimized(Fr::from(u128::from_le_bytes(buf)))
}
}

/// Length-prefixed byte string. 8-byte LE length keeps `BytesMsg(a) ; BytesMsg(b)`
/// distinguishable from `BytesMsg(a||b)`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BytesMsg(pub Vec<u8>);
Comment on lines +15 to +18
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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


impl BytesMsg {
/// Returns the inner bytes.
pub fn as_slice(&self) -> &[u8] {
&self.0
}
}

impl From<Vec<u8>> for BytesMsg {
fn from(v: Vec<u8>) -> Self {
Self(v)
}
}

impl Encoding<[u8]> for BytesMsg {
fn encode(&self) -> impl AsRef<[u8]> {
let mut out = Vec::with_capacity(8 + self.0.len());
out.extend_from_slice(&(self.0.len() as u64).to_le_bytes());
out.extend_from_slice(&self.0);
out
}
}

impl NargDeserialize for BytesMsg {
fn deserialize_from_narg(buf: &mut &[u8]) -> VerificationResult<Self> {
if buf.len() < 8 {
return Err(VerificationError);
}
let mut len_bytes = [0u8; 8];
len_bytes.copy_from_slice(&buf[..8]);
let len = u64::from_le_bytes(len_bytes) as usize;
if buf.len() < 8 + len {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

Suggested change
if buf.len() < 8 + len {
let total = 8usize.checked_add(len).ok_or(VerificationError)?;
if buf.len() < total {
return Err(VerificationError);
}
let body = buf[8..total].to_vec();
*buf = &buf[total..];

Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

return Err(VerificationError);
}
let body = buf[8..8 + len].to_vec();
*buf = &buf[8 + len..];
Ok(BytesMsg(body))
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn fr_le_bytes_round_trip() {
for i in 0u64..32 {
let f = Fr::from(i.wrapping_mul(0x9E37_79B9_7F4A_7C15));
let bytes = fr_to_le_bytes(&f);
assert_eq!(Fr::from_le_bytes_mod_order(&bytes), f);
}
}

#[test]
fn bytes_msg_is_length_prefixed() {
let m = BytesMsg(vec![1, 2, 3, 4]);
let enc = m.encode();
let bytes = enc.as_ref();
assert_eq!(bytes.len(), 8 + 4);
assert_eq!(&bytes[..8], &4u64.to_le_bytes());
assert_eq!(&bytes[8..], &[1, 2, 3, 4]);
}

#[test]
fn bytes_msg_narg_rejects_truncation() {
let m = BytesMsg(vec![9, 8, 7]);
let mut narg: Vec<u8> = Vec::new();
narg.extend_from_slice(m.encode().as_ref());
let _ = narg.pop();
let mut cursor: &[u8] = &narg;
let before = cursor.len();
let result = BytesMsg::deserialize_from_narg(&mut cursor);
assert!(result.is_err());
assert_eq!(cursor.len(), before, "cursor must not advance on error");
}

#[test]
fn field_el_optimized_decodes_u128() {
let buf = 12345u128.to_le_bytes();
let FieldElOptimized(f) = FieldElOptimized::decode(buf);
assert_eq!(f, Fr::from(12345u128));
}
}
Loading
Loading