Skip to content

feat(transcript): price proof-of-work difficulty from the challenge-set size - #2106

Merged
0xAndoroid merged 1 commit into
transcript/j3-sponge-powfrom
transcript/j5-grind-bits
Oct 21, 2026
Merged

0xAndoroid merged 1 commit into
transcript/j3-sponge-powfrom
transcript/j5-grind-bits

Conversation

@quangvdao

Copy link
Copy Markdown
Collaborator

Stacked on #2099 (transcript/j3-sponge-pow).

What this adds

#2099 lets the prover do proof of work before a challenge (grind / check_grind), but nothing decides how many bits that should be. This PR adds the function that decides it. Akita has the same arithmetic in akita-params; it will call this one instead, so Jolt and Akita share a single copy. Jolt itself does not call it yet. Wiring it into Jolt's protocol needs a stated Jolt security level and is left for a follow-up.

The bound

A cheating prover often wins a step only if a verifier challenge lands in a small "bad" set. The step's loss factor L bounds that set: a challenge drawn uniformly from the set C is bad with probability at most L / |C|. For example, a degree-d univariate sumcheck round has L = d, because two different polynomials of degree at most d agree on at most d points.

Under Fiat-Shamir the prover can retry, at one hash call per new challenge. A proof of work of b bits passes each attempt with probability 2^-b. So, in the random-oracle model, each hash call gives a bad challenge with probability at most L / |C| · 2^-b. The least b with |C| · 2^b ≥ L · 2^λ brings that down to 2^-λ, that is, λ bits of security per protected challenge.

|C| is the size of an ideal uniform draw. A codec's small distance from uniform (#2097) is charged separately.

API (jolt_transcript)

  • grind_bits_for_loss(loss_factor: u64, challenge_set: ChallengeSetSize, security_bits: u32) -> Result<u32, GrindBitsError> returns the least b in 0..=MAX_POW_BITS with |C| · 2^b ≥ L · 2^λ. It returns u32 because grind and check_grind take bits: u32.

  • ChallengeSetSize, the exact size of a challenge set. There are three ways to build one:

    • ChallengeCodec::CHALLENGE_SET_SIZE, a new associated constant on every challenge codec:
      • the pseudo-Mersenne prime fields;
      • Ext2, FpExt4 and FpExt8 over them;
      • BN254 Fr and Fq;
      • the binary fields F8, F64, F128 and F192.
    • ChallengeSetSize::field_order(modulus: u128, degree: u32) for an explicit modulus^degree.
    • ChallengeSetSize::power_of_two(bits) for masked challenges, such as Jolt's legacy 125-bit challenges.
  • GrindBitsError has four variants:

    • ZeroLoss;
    • SecurityBitsTooLarge, for a level above 192;
    • InvalidFieldOrder, for a modulus below 2 or degree 0;
    • Unattainable, when even 25 bits are not enough.

    None of these inputs can cause a panic.

  • MAX_POW_BITS (25) moves from pow.rs into the new module so that it is available without the spongefish feature. Its value and meaning are unchanged.

How the arithmetic stays exact

  • Only integers are used; there are no logarithms or floats. Sizes are 256-bit values with checked or saturating arithmetic.
  • Sizes at or above 2^256 − 1 are stored as 2^256 − 1. The loss factor is a u64 and λ is at most 192, so every target is at most 2^256 − 2^192. That is below the stored maximum, so the answer is exact for any set size. The 192 limit is tight: at 193 a large loss factor could pass 2^256.
  • For each b, the code compares |C| ≥ ⌈L · 2^λ / 2^b⌉. For whole numbers this is the same as |C| · 2^b ≥ L · 2^λ, and it avoids computing |C| · 2^b, which could exceed 256 bits.
  • The codec constants are computed at compile time. Pseudo-Mersenne bases use 2^MODULUS_BITS − OFFSET, BN254 uses the arkworks modulus limbs, and the binary fields use 2^(8 · NUM_BYTES).

Known answers (λ = 128 unless noted)

Challenge set L = 1 L = 2 L = 3 L = 8
fp128, p = 2^128 − 275 1 2 2 4
Ext2 over p = 2^64 − 59 1 2 2 4
2^125 (masked) 3 4 5 6
BN254 Fr, Fq 0 0 0 0
F128 – 1 – –

A masked 2^125 set needs 3 + ⌈log₂ L⌉ bits. A field just below a power of two needs one more bit when L is a power of two. BN254 needs nothing even at L = 2^64 − 1. At λ = 192 with L = 2^64 − 1 it needs 3 bits.

Cases at the 25-bit limit:

Challenge set L Result
fp128 2^25 − 1 25
fp128 2^25 Unattainable
2^103 1 25 (exact equality is accepted)
2^103 2 Unattainable

Tests

  • Exact sizes are compared with num-bigint (a new dev-dependency), below and above the 256-bit maximum. For example, 3^161 fits and 3^162 saturates.
  • grind_bits_for_loss is compared against a direct big-integer search over:
    • 21 challenge sets, from 2^0 up to 2^1000;
    • 11 loss factors, including both sides of 2^24 and 2^25, and u64::MAX;
    • λ ∈ {0, 5, 64, 100, 128, 192}.
  • Known answers above, the 25-bit limit cases, and rounding when b > λ.
  • Each codec constant is checked against independently written moduli, including the BN254 r and q in decimal.

Audit

An independent read-only reviewer checked:

  • exactness, including both branches and the derived ordering;
  • that saturation is preserved through square-and-multiply;
  • that no input can overflow or panic;
  • the off-by-one at the 25-bit limit;
  • that the documented bound matches the code.

They reran the logic in Python against big integers on about 1.2 million cases and reproduced every known answer. They found no bugs. Their three doc corrections are applied: "below" became "at most" for the largest target, saturation is now mentioned on power_of_two, and an overstated comment was removed.

Validation

All runs were on a remote sandbox, at the PR head:

  • cargo fmt -p jolt-transcript --check
  • python3 scripts/check_style_invariants.py --base origin/transcript/j3-sponge-pow
  • cargo clippy -p jolt-transcript --all-targets -- -D warnings with:
    • --all-features;
    • --features bn254,solinas,binary,prover-grinding;
    • --no-default-features --features transcript-keccak.
  • cargo nextest run -p jolt-transcript with the same three feature sets: 111, 111 and 15 tests, all passing.

No absorb sites were added and no transcript bytes changed, so the jolt-verifier fs_obligations inventory is unaffected.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📏 PR diff

Note

Actual code changed: 180 lines

🟢 +176 added    🔴 −4 removed

Tests, docs, fixtures, and helper scripts excluded.

Category 🟢 Added 🔴 Removed
⌨️ Actual code +176 −4
🧪 Tests +228 0
📚 Docs +69 −2
📦 Fixtures +1 0
🔧 Helper scripts 0 0
Total diff +474 −6

Every changed line is classified once.

@quangvdao
quangvdao added this pull request to stack #2112 October 21, 2026 15:50
@quangvdao
quangvdao marked this pull request as ready for review October 21, 2026 15:50
@0xAndoroid

Copy link
Copy Markdown
Collaborator

@pika review

@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.

Reviewed at 7e8ce196. I found no correctness bugs. One documentation suggestion follows; it does not block the PR.

Suggestion: say how to price a grind that protects several challenges. According to the pow.rs module docs, one proof-of-work step "protects the verifier messages that follow it, up to the next prover message". grind_bits_for_loss prices the bad event of a single challenge drawn from C. When one grind precedes k challenges, for example a vector of batching coefficients, the step's bad event is the union of their bad events. The caller must then pass the summed loss (k · L against the same |C|). The alternative is the joint loss against |C|^k, but ChallengeSetSize can build |C|^k only through field_order with a u128 modulus. One sentence on grind_bits_for_loss would keep the follow-up Jolt wiring from pricing a multi-challenge step as a single challenge.

What I checked:

  • saturating_mul and widening_mul. The (wide, narrow) split is correct. The middle sum stays below 3 · 2^64, and the high sum fits because the true high half is below 2^128. A saturated factor times 0 gives 0, times 1 gives SATURATED, and times 2 or more overflows into SATURATED.
  • pow saturation. If base saturates and a later step uses it, result already saturates. The extra squaring after the last multiply is never read.
  • target. When b ≤ λ, the value is L · 2^(λ−b) ≤ (2^64−1) · 2^192 < 2^256 − 1, so the saturating product is exact. When b > λ, div_ceil gives the integer-exact form of |C| · 2^b ≥ L · 2^λ.
  • Codec constants. For the pseudo-Mersenne fields, (u128::MAX >> (128 − MODULUS_BITS)) − (OFFSET − 1) equals 2^MODULUS_BITS − OFFSET, including at 128 bits. The extension constant p^DEGREE matches decode_pool, which draws DEGREE independent, accepted base coordinates. BN254 uses the arkworks MODULUS limbs, matching the reduced draw. For the binary fields, 2^(8·NUM_BYTES) matches the bijective decode.
  • Proof-of-work bound. In the random-oracle model, the check bytes and the challenge bytes are disjoint parts of the squeeze output. So each nonce probe passes and lands in the bad set with probability at most 2^−b · L/|C|, as the module docs state. bits = 0 is a no-op in both grind and check_grind, so a returned 0 is valid.
  • Known answers. I recomputed fp128, Ext2 over p = 2^64 − 59, masked 2^125, BN254 at λ = 192 with L = 2^64 − 1 (3 bits), and the 25-bit boundary rows. All match.
  • Moving MAX_POW_BITS. The value is unchanged. The export is now unconditional, and pow.rs imports it from the crate root.
  • CI. All 51 checks pass. I did not run anything locally.

@0xAndoroid
0xAndoroid merged commit bd0a2fc into transcript/j3-sponge-pow Oct 21, 2026
53 checks passed
0xAndoroid pushed a commit that referenced this pull request Oct 24, 2026
* Add fixed-tape field challenge codecs

* Declare legacy transcript test feature requirements

* fix(transcript): bound, poison and seal field challenge messages

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.

* style(transcript): import Sealed, Transcript and sponge traits by name

* feat(transcript): proof of work on the transcript sponge

Add grind / check_grind: a standalone proof-of-work step on the transcript
sponge that protects the challenges drawn after it, up to the next prover
message. The nonce is canonical LEB128 below 2^(bits+7), one byte per prover
message; the check squeezes ceil(bits/8) bytes and needs the low bits zero.
Difficulty is at most 25 bits. MessageSearch probes byte by byte so sponges
that absorb a length per call (Poseidon) replay the real transcript. Failed
checks poison the verifier. CI lints and tests the sequential search.

* fix(transcript): field challenges only on uniform sponges; poison rejected 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.

* test(verifier): list the proof-of-work probe absorb in the FS inventory

* feat(transcript): price proof-of-work difficulty from the challenge-set size (#2106)

* docs(transcript): document the external challenge-codec contract; trim 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.

* docs(transcript): drop the unmerged Akita consumer from the ChallengeCodec contract

* test(transcript): drop round_trips, already covered by the layout and sponge tests

* test(transcript): drop known_answers, covered by the unbounded-oracle test
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