Repository navigation
feat(transcript): price proof-of-work difficulty from the challenge-set size - #2106
Conversation
📏 PR diffNote Actual code changed: 180 lines🟢 +176 added 🔴 −4 removed Tests, docs, fixtures, and helper scripts excluded.
Every changed line is classified once. |
|
@pika review |
There was a problem hiding this comment.
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_mulandwidening_mul. The(wide, narrow)split is correct. The middle sum stays below3 · 2^64, and the high sum fits because the true high half is below2^128. A saturated factor times 0 gives 0, times 1 givesSATURATED, and times 2 or more overflows intoSATURATED.powsaturation. Ifbasesaturates and a later step uses it,resultalready saturates. The extra squaring after the last multiply is never read.target. Whenb ≤ λ, the value isL · 2^(λ−b) ≤ (2^64−1) · 2^192 < 2^256 − 1, so the saturating product is exact. Whenb > λ,div_ceilgives the integer-exact form of|C| · 2^b ≥ L · 2^λ.- Codec constants. For the pseudo-Mersenne fields,
(u128::MAX >> (128 − MODULUS_BITS)) − (OFFSET − 1)equals2^MODULUS_BITS − OFFSET, including at 128 bits. The extension constantp^DEGREEmatchesdecode_pool, which drawsDEGREEindependent, accepted base coordinates. BN254 uses the arkworksMODULUSlimbs, 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 = 0is a no-op in bothgrindandcheck_grind, so a returned 0 is valid. - Known answers. I recomputed fp128,
Ext2overp = 2^64 − 59, masked2^125, BN254 at λ = 192 withL = 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, andpow.rsimports it from the crate root. - CI. All 51 checks pass. I did not run anything locally.
* 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
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 inakita-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
Lbounds that set: a challenge drawn uniformly from the setCis bad with probability at mostL / |C|. For example, a degree-dunivariate sumcheck round hasL = d, because two different polynomials of degree at mostdagree on at mostdpoints.Under Fiat-Shamir the prover can retry, at one hash call per new challenge. A proof of work of
bbits passes each attempt with probability2^-b. So, in the random-oracle model, each hash call gives a bad challenge with probability at mostL / |C| · 2^-b. The leastbwith|C| · 2^b ≥ L · 2^λbrings that down to2^-λ, 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 leastbin0..=MAX_POW_BITSwith|C| · 2^b ≥ L · 2^λ. It returnsu32becausegrindandcheck_grindtakebits: 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:Ext2,FpExt4andFpExt8over them;FrandFq;F8,F64,F128andF192.ChallengeSetSize::field_order(modulus: u128, degree: u32)for an explicitmodulus^degree.ChallengeSetSize::power_of_two(bits)for masked challenges, such as Jolt's legacy 125-bit challenges.GrindBitsErrorhas 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 frompow.rsinto the new module so that it is available without thespongefishfeature. Its value and meaning are unchanged.How the arithmetic stays exact
2^256 − 1are stored as2^256 − 1. The loss factor is au64and λ is at most 192, so every target is at most2^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 pass2^256.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.2^MODULUS_BITS − OFFSET, BN254 uses the arkworks modulus limbs, and the binary fields use2^(8 · NUM_BYTES).Known answers (λ = 128 unless noted)
p = 2^128 − 275Ext2overp = 2^64 − 592^125(masked)Fr,FqF128A masked
2^125set needs3 + ⌈log₂ L⌉bits. A field just below a power of two needs one more bit whenLis a power of two. BN254 needs nothing even atL = 2^64 − 1. At λ = 192 withL = 2^64 − 1it needs 3 bits.Cases at the 25-bit limit:
2^25 − 12^25Unattainable2^1032^103UnattainableTests
num-bigint(a new dev-dependency), below and above the 256-bit maximum. For example,3^161fits and3^162saturates.grind_bits_for_lossis compared against a direct big-integer search over:2^0up to2^1000;2^24and2^25, andu64::MAX;b > λ.randqin decimal.Audit
An independent read-only reviewer checked:
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 --checkpython3 scripts/check_style_invariants.py --base origin/transcript/j3-sponge-powcargo clippy -p jolt-transcript --all-targets -- -D warningswith:--all-features;--features bn254,solinas,binary,prover-grinding;--no-default-features --features transcript-keccak.cargo nextest run -p jolt-transcriptwith 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-verifierfs_obligationsinventory is unaffected.🤖 Generated with Claude Code