Skip to content

feat(transcript): field challenge codecs with a 2^-160 per-message bias budget (J2) - #2097

Merged
0xAndoroid merged 8 commits into
mainfrom
transcript/j2-challenge-codecs
Oct 23, 2026
Merged

0xAndoroid merged 8 commits into
mainfrom
transcript/j2-challenge-codecs

Conversation

@quangvdao

@quangvdao quangvdao commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #2095 (J1). Second step of moving Jolt, then Akita, onto one Keccak duplex-sponge transcript.

What this adds

jolt_transcript::ChallengeCodec and field_challenges::<F>(len) on both ProverTranscript and VerifierTranscript. One call squeezes a fixed number of bytes as one verifier message (verifier_message_as), then decodes len field elements from them.

Security budget. The whole message has total variation at most 2^-161 from uniform. The duplex-sponge theorem charges twice that, so 2^-160 per message. The target is that an attacker needs about 2^128 hash calls to break the proof system. That target is priced per hash call.

Codec per field

Field Rule Bytes for 1 / 17 / 28 elements
Fp128 (2^128 − 275 and others) pooled rejection 32 / 288 / 464
Fp64 pooled rejection 24 / 160 / 248
Fp32 (2^32 − 99) pooled rejection 32 / 100 / 144
Ext2<Fp64> pooled rejection on base coordinates 32 / 296 / 472
FpExt4<Fp32> pooled rejection on base coordinates 44 / 304 / 480
Fr, Fq (BN254) per-element wide reduction 52 / 901 / 1484
F8, F64, F128, F192 raw bytes (bijection) exact
  • Pooled rejection (fields 2^b − c). Each candidate is NUM_BYTES bytes masked to b bits, and is kept if it is below p. The codec squeezes len + k candidates. k is the least value with (k+1)(R − L) ≥ 161, where R = b − ⌈log₂ c⌉ and L = ⌈log₂(len + k)⌉. Kept values are exactly uniform. If the pool runs out (probability ≤ 2^-161), the call returns VerificationError.
  • Extensions. One pool of len · DEGREE base coordinates, assembled with ExtField::from_base_fn. The codec is implemented only for Ext2 with TwoNr, FpExt4 and FpExt8, over the nine registered bases. All nine are ≡ 5 (mod 8), so 2 is a non-residue. Prime128OffsetA7F7 is left out, because its default quadratic tower is not a field.
  • BN254. Each element reduces its own w-byte chunk, with w = ⌈(MODULUS_BITS + 159 + ⌈log₂ len⌉)/8⌉. That is 52 bytes for len 1–8, 53 for len 9–2048, and 54 above, up to the byte cap.

All bounds use integer arithmetic. They are a little looser than the exact minimum, which costs at most a few bytes per message.

Verifier behaviour

  • Only sponges with uniform output. field_challenges requires H: UniformBytes, a sealed marker implemented for Keccak and Blake2b512. PoseidonSponge is excluded: each block it squeezes is the little-endian encoding of a BN254 scalar, so its top bits are far from uniform and the codecs' TV bound would not hold. Poseidon's bytes are unchanged; it simply cannot draw field challenges this way.
  • Length checks come first. A length the codec does not support, or a message above MAX_CHALLENGE_BYTES (16 MiB, which caps BN254 at 310,689 elements), returns VerificationError before touching the sponge. A length taken from the input therefore cannot make the verifier allocate without bound. The verifier also poisons, so a caller that drops the error still fails check_eof.
  • Empty messages are free. field_challenges(0) returns an empty vector without squeezing. An empty squeeze is not a no-op for every sponge (it changes the Blake2b bridge state), so skipping it keeps len == 0 neutral everywhere.
  • Exhaustion poisons. If the rejection pool runs out, the verifier fails the call through spongefish::Transcript::check, so later reads and check_eof fail too.
  • The codec trait is sealed. Every implementation lives next to the argument for its bound, so a downstream crate cannot add a codec that skips it.

Compatibility

  • Existing verifier_message::<T>() call sites are unchanged. Moving callers over comes in later PRs.
  • New features solinas and binary forward to jolt-field.
  • legacy_digest_tests now declares required-features = ["bn254", "transcript-blake2b"], so the Keccak-only build skips it.

Tests

tests/challenge_tests.rs covers the following:

  • byte counts;
  • known-answer vectors under session jolt/challenge-codecs/v1, including messages that cross the 136-byte Keccak rate;
  • prover and verifier agreement;
  • that a run-out pool is rejected, and that accepted values keep their order;
  • that unused high bits are masked, and that for each of the nine bases the candidate p is rejected and p − 1 accepted;
  • that messages above the byte cap are rejected on both sides with the sponge untouched (the verifier poisoned), and that a message of exactly the cap works;
  • that empty messages leave the Keccak and Blake2b sponges untouched;
  • that pool exhaustion fails on both sides and poisons the verifier, using a test sponge that outputs only ones;
  • that extension coordinates are read from the single base pool.

CI now lints and tests jolt-transcript with --all-features. The per-crate test loop used default features only, so the codecs and their tests never ran there.

cargo nextest run -p jolt-transcript --all-features: 90 passed.

Next

J3 (#2099) adds a proof-of-work step on the transcript sponge, with the same layout as the spongefish PR.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

📏 PR diff

Note

Actual code changed: 317 lines

🟢 +315 added    🔴 −2 removed

Tests, docs, fixtures, and helper scripts excluded.

Category 🟢 Added 🔴 Removed
⌨️ Actual code +315 −2
🧪 Tests +463 0
📚 Docs +116 0
📦 Fixtures 0 0
🔧 Helper scripts +9 0
Total diff +903 −2

Every changed line is classified once.

@quangvdao
quangvdao added this pull request to stack #2098 October 20, 2026 18:15
Base automatically changed from transcript/j1-spongefish-0.8 to main October 20, 2026 18:35
@quangvdao
quangvdao force-pushed the transcript/j2-challenge-codecs branch from eef0a00 to 6af666c Compare October 20, 2026 18:35
@quangvdao
quangvdao marked this pull request as ready for review October 20, 2026 18:41
Reject unsupported lengths and messages above MAX_CHALLENGE_BYTES before
touching the sponge; return early on empty messages; poison the verifier on
pool exhaustion; seal ChallengeCodec; correct the BN254 width table; lint and
test jolt-transcript with all features in CI.
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark comparison (crates)

Same-runner A/B: base_run = 2678215 (merge target) vs pr_run = ed4f5ab (PR head merged onto it). Bold = faster run; Δ colored when the difference exceeds the ±10% noise threshold.

No differences above the ±10% noise threshold.

All 81 benchmarks
Benchmark base_run (merge target) pr_run (this PR) Δ
EqPolynomial::evals/17 1.9±0.02ms 1.9±0.02ms +0.2%
EqPolynomial::evals/19 7.3±0.10ms 7.5±0.26ms +2.5%
EqPolynomial::evals/20 14.9±0.11ms 15.0±0.14ms +0.5%
EqPolynomial::evals/22 56.6±0.35ms 56.5±0.35ms -0.2%
EqPolynomial::evaluations/14 772.5±27.08µs 777.3±24.69µs +0.6%
EqPolynomial::evaluations/18 6.8±0.09ms 6.9±0.11ms +1.8%
EqPolynomial::evaluations/20 29.0±0.27ms 28.9±0.31ms -0.2%
F128/accumulator/deferred 2.6±0.02µs 2.6±0.04µs +0.2%
F128/accumulator/naive 4.4±0.04µs 4.4±0.03µs +0.3%
F128/mul 3.8±0.05ns 3.8±0.04ns -0.2%
F128/mul_slice 3.5±0.02µs 3.5±0.04µs +0.1%
F128/square 3.4±0.03ns 3.4±0.02ns -0.1%
F192/accumulator/deferred 5.4±0.02µs 5.5±0.19µs +0.7%
F192/accumulator/naive 8.6±0.04µs 8.7±0.14µs +0.2%
F192/mul 8.4±0.08ns 8.4±0.11ns +0.1%
F192/mul_slice 7.7±0.08µs 7.7±0.08µs +0.0%
F192/square 5.6±0.07ns 5.6±0.08ns +0.0%
F64/accumulator/deferred 969.3±6.19ns 970.9±15.31ns +0.2%
F64/accumulator/naive 1.9±0.05µs 1.9±0.01µs -0.3%
F64/mul 1.9±0.03ns 1.9±0.02ns +0.0%
F64/mul_slice 1.9±0.05µs 1.9±0.02µs -0.3%
F64/square 1.9±0.01ns 1.9±0.02ns +0.1%
Polynomial::bind/14 129.5±7.38µs 130.0±10.77µs +0.4%
Polynomial::bind/18 1.9±0.02ms 1.9±0.02ms +0.3%
Polynomial::bind/20 7.6±0.06ms 7.5±0.07ms -0.2%
Polynomial::evaluate/20 40.3±0.27ms 40.4±0.26ms +0.1%
UnivariatePoly::from_evals/16 52.3±0.86µs 52.3±0.87µs +0.0%
UnivariatePoly::from_evals/3 6.4±0.16µs 6.4±0.13µs -0.1%
UnivariatePoly::from_evals/4 9.6±0.20µs 9.6±0.48µs +0.6%
UnivariatePoly::from_evals/8 23.4±0.49µs 23.4±0.40µs +0.0%
append_bytes/Blake2b/256B 319.0±6.84ns 314.5±2.99ns -1.4%
append_bytes/Blake2b/32B 185.3±5.00ns 181.6±2.31ns -2.0%
append_bytes/Keccak/256B 771.7±5.89ns 769.1±8.24ns -0.3%
append_bytes/Keccak/32B 41.6±2.30ns 41.7±1.87ns +0.0%
append_bytes/Poseidon/256B 224.5±13.53µs 221.5±5.09µs -1.4%
append_bytes/Poseidon/32B 70.0±2.01µs 70.4±0.97µs +0.6%
challenge/Blake2b 603.8±13.18ns 606.0±20.76ns +0.4%
challenge/Keccak 371.5±4.04ns 369.1±7.32ns -0.6%
challenge/Poseidon 35.0±0.68µs 35.1±0.87µs +0.4%
field arithmetic/add/fp128_a7f7 4.0±0.07µs 4.0±0.09µs +0.3%
field arithmetic/add/fp32_c99 2.9±0.04µs 2.9±0.05µs +0.1%
field arithmetic/add/fp64_c59 3.1±0.03µs 3.1±0.03µs +0.1%
field arithmetic/mul/fp128_a7f7 15.3±0.06µs 15.4±0.17µs +0.1%
field arithmetic/mul/fp32_c99 5.7±0.04µs 5.7±0.17µs +0.1%
field arithmetic/mul/fp64_c59 8.1±0.06µs 8.1±0.08µs -0.1%
field arithmetic/square/fp128_a7f7 17.8±0.08µs 17.8±0.18µs -0.1%
field arithmetic/square/fp32_c99 5.8±0.07µs 5.8±0.03µs -0.1%
field arithmetic/square/fp64_c59 6.7±0.05µs 6.7±0.03µs -0.1%
field arithmetic/sub/fp128_a7f7 3.9±0.02µs 3.9±0.03µs +0.0%
field arithmetic/sub/fp32_c99 2.3±0.02µs 2.3±0.04µs +0.3%
field arithmetic/sub/fp64_c59 2.3±0.01µs 2.3±0.02µs +0.0%
fp64_prime63_offset259/ext2_packed_mul 15.6±0.10ns 15.6±0.14ns +0.1%
fp64_prime63_offset259/ext2_scalar_mul 11.2±0.05ns 11.2±0.15ns +0.1%
fp64_prime63_offset259/packed_mul 7.0±0.09ns 6.9±0.13ns -1.8%
fp64_prime63_offset259/scalar_mul 2.6±0.03ns 2.6±0.03ns -0.1%
g1_add 416.4±6.77ns 411.7±3.08ns -1.1%
g1_deserialize_bincode 8.7±0.15µs 8.7±0.07µs +0.0%
g1_double 203.6±1.09ns 203.9±1.79ns +0.1%
g1_msm/1024 12.8±0.04ms 12.7±0.06ms -0.1%
g1_msm/16 542.6±2.70µs 541.9±4.03µs -0.1%
g1_msm/256 4.3±0.02ms 4.3±0.03ms +0.4%
g1_msm/4 252.3±1.29µs 256.7±4.61µs +1.7%
g1_msm/64 1.6±0.03ms 1.6±0.02ms +0.7%
g1_scalar_mul 63.9±1.58µs 64.0±1.08µs +0.2%
g1_serialize_bincode 105.2±5.53ns 104.2±0.65ns -0.9%
g2_msm/16 1.9±0.03ms 1.9±0.02ms +0.0%
g2_msm/256 15.3±0.32ms 15.3±0.05ms -0.2%
g2_msm/4 768.4±22.33µs 770.2±6.93µs +0.2%
g2_msm/64 5.5±0.04ms 5.5±0.03ms +0.1%
g2_scalar_mul 318.9±3.52µs 320.5±2.97µs +0.5%
gt_scalar_mul 877.1±17.87µs 880.6±6.74µs +0.4%
multi_pairing/16 5.7±0.03ms 5.7±0.03ms -0.1%
multi_pairing/2 1.2±0.02ms 1.2±0.02ms -0.2%
multi_pairing/4 1.8±0.02ms 1.8±0.02ms -0.2%
multi_pairing/8 3.1±0.02ms 3.1±0.01ms -0.3%
pairing 903.3±7.97µs 908.8±8.56µs +0.6%
pedersen_commit/1024 12.8±0.09ms 12.8±0.12ms -0.1%
pedersen_commit/16 607.7±7.17µs 610.2±6.05µs +0.4%
pedersen_commit/256 4.4±0.15ms 4.3±0.01ms -0.5%
pedersen_commit/4 316.5±6.30µs 318.6±2.83µs +0.7%
pedersen_commit/64 1.7±0.01ms 1.7±0.02ms -0.2%

…ected lengths

PoseidonSponge squeezes the bytes of a BN254 scalar, so its output is far
from uniform and the codecs' TV bound fails there. Restrict field_challenges
to sponges with uniform output (sealed UniformBytes: Keccak, Blake2b512).
The verifier now also poisons on a rejected length.

@0xandoroid-ai-agent 0xandoroid-ai-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Two policy issues remain: the public API precedes its first production caller without a documented external-use contract, and several added comments are inaccurate or repeat the code. No sampling or verifier-consistency defect was found. The codec-local bias bound is not a whole-proof 128-bit Fiat–Shamir security claim.

Comment thread crates/jolt-transcript/src/lib.rs
Comment thread .github/workflows/rust.yml Outdated
Comment thread crates/jolt-transcript/src/challenge.rs Outdated
Comment thread crates/jolt-transcript/tests/challenge_tests.rs Outdated
…enge-codecs

# Conflicts:
#	.github/workflows/rust.yml
…m comments

- ChallengeCodec, UniformBytes and MAX_CHALLENGE_BYTES stay public because
  they bound the public field_challenges methods; their docs now state that
  contract and name Akita's transcript as the consumer.
- Remove the doc block on the crate-private message_bytes helper.
- Remove the test-file heading and a comment that restated its assertions.
- Correct the CI comment: default features already cover BN254 and every
  sponge; the all-features run adds the Solinas and binary codecs.

@0xandoroid-ai-agent 0xandoroid-ai-agent Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The four earlier threads are addressed at d9c20cd. One new problem, inline: the external-contract doc names a consumer that does not exist yet. I found no sampling or prover/verifier consistency defect. The 2^-160 per-message bias bound is local to each codec and is not a whole-proof 128-bit Fiat–Shamir claim; this PR switches no existing challenge call site.

Comment thread crates/jolt-transcript/src/challenge.rs Outdated
@0xAndoroid
0xAndoroid merged commit 7931b22 into main Oct 23, 2026
52 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants