Repository navigation
Conversation
Contributor
📏 PR diffNote Actual code changed: 1,976 lines🟢 +1,617 added 🔴 −359 removed Tests, docs, fixtures, and helper scripts excluded.
Every changed line is classified once. |
quangvdao
added this pull request to stack #2112
October 21, 2026 15:50
Base automatically changed from
transcript/j5-grind-bits
to
transcript/j3-sponge-pow
October 21, 2026 17:23
…llenge Adds Transcript::grind/check_grind on the legacy facade, the fields' challenge-set sizes, one proof-of-work nonce per sumcheck round priced at the verifier's degree bound, and nonces before BlindFold's fold, outer point and inner combination draws. Fields that cannot reach 128 bits fail closed with PowDifficulty.
Every challenge now squeezes ceil((MODULUS_BITS + 178) / 8) bytes and reduces them mod p, so it is within 2^-180 of uniform on the whole field. BN254 drops the 125-bit mask and the separate 128-bit scalar decoder; every BN254 site now prices to 0 bits of proof of work. The 128-bit Solinas primes, including Akita's Prime128OffsetA7F7, are priced at their full order. Messages and their nonces share one header (2*len + has_nonces), so a proof with no nonces keeps its size. BlindFold's three nonce fields become one list that its sites read in order.
… sizes - The facade's challenge width now grows with the number of draws since the last absorb: draw k reduces reduction_width(MODULUS_BITS, 1 + 2⌈log₂ k⌉) bytes, so any number of draws after one message stay within 2^-161 of uniform. This removes the 2^19 draws-per-message assumption. BN254's first draw is 52 bytes, matching J2's codec. - jolt_field::reduction_width is the one formula for J2's BN254 codec and the facade. J2's CHALLENGE_SET_SIZE now reads jolt-field's CHALLENGE_SET_LIMBS (raised to the degree for extensions). - Delete OptimizedChallenge::challenge_128 and zklean's dead Truncate128 nodes.
- One proof-of-work nonce per prover message outside the sumcheck rounds: stage-1 tau, the stage-1 batch, stage-2 tau_high, the stage 2-7 draws and the Akita stage-8 selector points (`PowSite`). Its loss sums every draw up to the next prover message, from protocol parameters only. - Every `ConcreteSumcheck` states `challenge_loss`; the derive macro adds the members and one for the batching coefficients. Stage 6a bytecode and stage 7 read theirs from the input expression (`challenge_degree`). Stage 6b prices its pre-batch draws in `Stage6bDraws::challenge_loss`. - `StagePowNonces` rides in `JoltStageProofs` on the Akita build only: a 16-bit mask plus the nonces, 2 bytes when empty. Missing, extra and failing nonces are rejected with `ProofOfWorkRejected`. - Non-Akita builds carry no stage nonces and refuse a challenge set that needs proof of work (`require_no_pow`), so BN254 proofs are unchanged.
- Gate the verifier-only nonce helpers (`max_nonce_len`, `read_nonce`) to `spongefish`, the only feature that uses them. - The FS audit transcript records proof-of-work check bytes and replays them with the challenges, so a frozen replay reaches the binding under test instead of failing the proof of work first. - Re-bless the FS inventories: the grind-seam absorbs, the unpriced transcript's forwarding draws, `JoltStageProofs.pow_nonces`, and the single stage-6b draw site. - Stage 6b prices its burst from the same draw sequence it squeezes, so the difficulty cannot drift from the draws. - Grinds of at most 6 bits scan on the calling thread instead of waking the Rayon pool; both paths run one scan and return the least passing nonce, pinned across thread counts by a test.
0xAndoroid
force-pushed
the
transcript/j6-protocol-grinding
branch
from
October 21, 2026 17:23
0fb32ac to
e78ab97
Compare
This was referenced Oct 21, 2026
quangvdao
added a commit
to LayerZero-Labs/akita
that referenced
this pull request
Oct 21, 2026
J6 runs proof-of-work difficulties of 6 bits or less on the calling thread instead of broadcasting to the whole Rayon pool, which removes the per-step pool wakeup the profile benchmark measured. It is stacked on J5, so nothing else changes for Akita: challenge-set sizes, the nonce encoding and the check are the same, and no known-answer pin moves. akita-params now checks at compile time that TRANSCRIPT_SECURITY_BITS equals Jolt's new SECURITY_BITS.
Replace the 6-bit cutoff with an adaptive search. The calling thread scans nonces 0..1024; only if none passes does the Rayon pool scan the rest, each thread on its residue class. Measured on 8 threads, waking the pool costs 200-450 us, about 700-1500 probes, so grinds up to 8 bits now cost what a sequential scan costs. Both phases share one scan and return the least passing nonce, so proofs are unchanged and independent of the thread count; the thread-count test now covers nonces inside, at both sides of the edge of, and past the caller's stretch.
quangvdao
added a commit
to LayerZero-Labs/akita
that referenced
this pull request
Oct 21, 2026
Dory's verifier multiplies group elements by beta, alpha and d. Full-width BN254 challenges made those scalars about 254 bits, so Dory verification became about 20% slower than on main. These three challenges are now drawn as short integers. Each width is the narrowest that keeps the challenge's bad set below 2^-128 with no proof of work: beta 129 bits (loss 2), alpha 130 bits (loss 4, because the last alpha shares its burst with gamma), and d 130 bits (loss 3). The width comes from Transcript::challenge_short_scalar through challenge_bits_for_loss, which uses the same target as the proof-of-work pricing. Gamma, the zero-knowledge Sigma challenges and every other challenge stay full width. dory-pcs is pinned to exactly 0.4.2, because the loss table depends on how it draws and uses each label. tests/challenge_bursts.rs records real transparent and zero-knowledge proofs and checks the draw order the table assumes. The FS challenge-site inventory is re-blessed.
Main's F8 domain test built a clear proof without pow_nonces. It now grinds the round nonce as the prover does, and runs F64 over an unpriced transcript because no supported proof of work lifts 64-bit challenges to the 128-bit target.
quangvdao
added a commit
that referenced
this pull request
Oct 23, 2026
…pt/j6-protocol-grinding
quangvdao
added a commit
to LayerZero-Labs/akita
that referenced
this pull request
Oct 23, 2026
…erge Jolt's changes since 1f543ca45 are additive for Akita: challenge_short_scalar and challenge_bits_for_loss in jolt-transcript, and a binary feature on jolt-poly. Jolt's own jolt-akita now pins Akita at dfdbd76, which moves that copy in the profile lockfile; it is still outside the resolved build graph.
…pt/j6-protocol-grinding # Conflicts: # crates/jolt-transcript/src/grind_bits.rs
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this does
Jolt's Fiat-Shamir checks need 128-bit security. A challenge drawn from a set C gives a check with loss factor L (the most challenge values that let a false claim pass) only log₂(|C|/L) bits. This PR closes the gap two ways:
challenge()andchallenge_scalar()alike, squeezes enough bytes to reduce mod r within 2^-161 of uniform. The set is all of Fr (about 2^254), so every BN254 site reaches 128 bits with no proof of work. BN254 proofs carry no nonce bytes. The exception is Dory's β, α and d, which the verifier multiplies into group elements. They are drawn as short integers, each just wide enough for its loss (see Short Dory challenges).Prime128OffsetA7F7and any other field whose challenge set is too small. The difficulty comes from feat(transcript): price proof-of-work difficulty from the challenge-set size #2106 (J5):grind_bits_for_loss(L, T::CHALLENGE_SET, 128).It is stacked on #2106. It covers the transcript seam, every sumcheck round, BlindFold, and every other
Prime128OffsetA7F7challenge in the Jolt protocol: stage-1 τ, stage-2 τ_high, the draws of stages 1–7, and the Akita selector points of stage 8.Challenge decoding
One decoder for every challenge.
CanonicalEncoding::from_challenge_bytesreduces squeezed bytes mod p. The width depends on how many draws came since the transcript last absorbed: draw k squeezesreduction_width(MODULUS_BITS, 1 + 2⌈log₂ k⌉)bytes, so draw k is within 2^-162/k² of uniform. Any number of draws after one message are then jointly within 2^-162·π²/6 < 2^-161 of uniform. There is no cap on the number of draws.Fp128<P>, including Akita'sPrime128OffsetA7F7(p = 2^128 − 0xFFFF_A7F7): 36 bytes for the first draw, set = p. Before, it was 16 bytes reduced mod p. That has min-entropy 127, because values below 0xFFFF_A7F7 come up twice as often.NUM_BYTESper draw, a bijection onto the field. F192 grows from 2^128 to 2^192.Removed:
from_scalar_challenge_bytes);SCALAR_CHALLENGE_SET;JoltProtocolConfig::scalar_challenge_endianness(ScalarChallengeEndianness);OptimizedChallenge::challenge_128, which turned 16 bytes into an Fr and was used only in tests;Truncate128nodes, which nothing created.challenge_scalarstays as a provided method that decodes exactly aschallengedoes. It is kept because about 55 call sites and the FS audit'sChallengeKind::Scalaruse it.One source for the set size and the width. Each field states its set in
CHALLENGE_SET_LIMBS. The facade reads it throughChallengeSetSize::from_limbs, and so does J2'sChallengeCodec::CHALLENGE_SET_SIZE.jolt_field::reduction_widthis the one width formula for both J2's BN254 codec and the facade.Short Dory challenges
Full-width challenges made Dory's group scalars about 254 bits, where main's were 125 bits, and Dory verification became about 20% slower. Dory's β, α and d are now drawn as short integers, each the narrowest width that keeps its burst below 2^-128 with no proof of work.
Transcript::challenge_short_scalar(loss)takes the width fromchallenge_bits_for_loss(loss, 128): the least k with 2^k ≥ L·2^128, using the same target as the proof-of-work pricing. It squeezes ⌈k/8⌉ bytes, clears the bits above k, and decodes the integer. It refuses a field whose order is not above 2^k (FieldTooSmall), so distinct integers always decode to distinct elements. The Dory adapter (jolt-dory/src/transcript.rs) only maps each dory-pcs label to its loss.The verifier also inverts α and β (α⁻¹, β⁻¹, α⁻¹β⁻¹) and uses d⁻¹ and d². Those scalars stay full width; only the direct multiplications by α, β, d, αβ and d·s₂ get shorter.
Soundness model. The widths hold each burst to 2^-128, the per-burst budget the rest of this PR uses. For Dory this relies on round-by-round soundness: a check that passes at a challenge outside its bad set yields a break of Dory's binding (an algebraic-group-model argument under SXDH). A bound summed over all of Dory's challenges would need about 5 more bits per challenge. Even under that summed bound, these widths beat main's unpriced 125-bit challenges.
Pinned to dory-pcs. The losses depend on how dory-pcs draws and uses each label, so the workspace pins
dory-pcs = "=0.4.2".jolt-dory/tests/challenge_bursts.rsrecords real transparent and zero-knowledge proofs and checks:A label the table does not know draws full width.
Poseidon. The short integer is the low k bits of a field element, so each value has probability at most 2^-k(1 + 2^k/p) when the draw follows an absorb. Every Dory short draw does follow one.
Changes to J5 code (#2106)
ChallengeCodec::CHALLENGE_SET_SIZEnow comes fromCHALLENGE_SET_LIMBSfor every field (raised to the degree for extensions).pseudo_mersenne_poweris removed.reduction_widthis replaced byjolt_field::reduction_width.grind_bits.rs.Grinding
Seam. The verifier drives the legacy
jolt_transcript::Transcriptfacade, so the facade gains two provided methods:grind(bits)for the prover;check_grind(bits, nonce)for the verifier.They reuse J3's nonce search and its LEB128 nonce encoding (
MAX_POW_BITS = 25).Difficulty is fixed by protocol parameters. It is never read from the proof. Sumcheck rounds use the verifier's degree bound. Stage sites use losses computed from the relation shapes and dimensions the verifier already checked.
One nonce per prover message. It is ground right before the first draw that follows the message. Its loss is the sum of the losses of every draw up to the next prover message. Absorbs of values the verifier computes itself (the input claims a batch absorbs, prefix-pack evaluations) are not prover messages, so they do not split a burst.
Errors, not panics. A missing, extra or failing nonce gives
WrongNumberOfPowNoncesorProofOfWorkRejected.Grinds start on the calling thread. The search tries nonces 0 to 1023 on the calling thread. Only if none passes does it wake the Rayon pool for the rest, each thread taking its own residue class. Both phases run the same scan and return the least passing nonce, so proof bytes do not depend on the thread count. Builds without
parallelscan everything on the calling thread. The measurements behind the 1024 are under Cost.Fail closed. Some fields cannot reach 128 bits within
MAX_POW_BITS(F64 binary, Mersenne-61). For these, the difficulty functions returnPowDifficulty, and the verifier rejects.Stage sites
crates/jolt-verifier/src/stages/pow.rsholds the stage sites:PowSitenames the 11 bursts in transcript order.pow_bits,check_powandgrind_powgive the difficulty, the verifier check and the prover search.StagePowNoncesholds one optional nonce per site. It rides inJoltStageProofs::pow_nonces, on the Akita build only.ConcreteSumchecknow stateschallenge_loss(): the loss of the challenges it draws before the batch. The derive macro sums the members' losses plusBATCH_COEFFICIENT_LOSS = 1for the batching coefficients. These combine the member claims linearly, so all of them together add a loss of 1.challenge_degree(expr)counts the most challenge factors in any term of a symbolic expression. Stage 6a bytecode and stage 7 Hamming weight read their loss from their input expression with it, so the loss follows the expression.Stage6bDraws::draw.drawandStage6bDraws::challenge_lossrun one privatesequence, which lists each draw with its loss:drawsqueezes, andchallenge_lossadds up the losses. So the price cannot drift from the draws.Non-Akita builds carry no stage nonces. The Dory prover and the non-Akita verifier call
require_no_pow::<T>()first. It rejects unless the transcript's challenge set needs 0 bits even at lossu64::MAX. So a BN254 proof is unchanged on the wire, and a field that needs grinding cannot be used without the Akita proof layout. The stage checks still run on every build; on BN254 they need 0 bits and check that no nonce is present.There is no stage-8 RLC on Akita. Akita opens each object separately, so the only stage-8 challenges are the selector points. The Dory RLC is BN254-only, so the gate covers it.
Wire format
Sumcheck messages and their nonces are encoded together by
jolt_sumcheck::{Nonced, NoncedRef}:2·len + has_nonces;lenmessages;has_noncesis set, a non-emptyVec<u32>of nonces.Under bincode varint the header takes 1 byte for up to 125 messages, the same as the plain length prefix it replaces. So a proof with no nonces is byte-for-byte the size it was before, and a test pins this. The one exception is a list of 126–250 messages, where the header takes 3 bytes instead of 1. In practice this can only happen for BlindFold's cross-term commitments. Decoding rejects the flag set with an empty list, and truncated input.
StagePowNoncesis encoded as a 16-bit mask of the filled sites, then their nonces in site order. It costs 2 bytes when empty. Decoding rejects unknown mask bits and a nonce count that does not match the mask.Sites
b(L)forPrime128OffsetA7F7is ⌈log₂ L⌉, plus 1 when L is a power of two, because p is just below 2^128. BN254 needs 0 bits at every site. The bits are computed by J5's function, not hard-coded.ℓT = log₂ T, ℓR = log₂ of the RAM size, nv = the number of virtual instruction RA polynomials, N = the number of booleanity openings, FI = field-inline.
Rounds and BlindFold
Prime128OffsetA7F7bitsBlindFold only runs over BN254 today, so its nonce list is empty.
Stage sites (
PowSite)Stage1TauStage1Stage2TauHighStage2Stage3Stage4Stage5Stage6aStage6bNUM_BYTECODE_VAL_STAGES− 1 = 5 on Akita), coefficients (1)Stage7Stage8Soundness audit
An independent, read-only, high-effort audit was run on each part: the grinding commit, the full-width and wire-format changes, and the stage sites.
Verdict on the stage sites: SOUND WITH CAVEATS. No blocker. The audit checked:
akitaandzkcannot be enabled together.u64::MAXand thenPowDifficulty.pow_noncesexists only on the Akita build, andrequire_no_powruns in both the Dory prover and the non-Akita verifier.Its caveats, left as follow-ups:
challenge_degree.Verdict on the short Dory challenges: SOUND, on a re-check after its first verdict of CONDITIONS. Its conditions, all met in this PR:
The audit derived each loss from dory-pcs 0.4.2's verifier and agreed with each one: β 2, α 2 (4 with γ's burst), γ 2, d 3. It also checked:
Notes, not blockers:
The two earlier audits (grinding; full width and wire format) were both SOUND WITH NITS. Their nits on
challenge_128,Truncate128and the 2^19 draw cap are fixed in this PR. The Poseidon nit remains below.Cost
BN254: one or two extra hashes per challenge (52 bytes instead of 16), one 416-bit reduction, and no proof bytes.
Prime128OffsetA7F7:Dory (BN254). Measured on the Boat sandbox (8 threads). Each workload ran 6 interleaved passes with the build order rotated. Medians are in ms. The ratio is the median of the per-pass ratios against main.
Proof size is 1 byte larger than main on both workloads (86,559 and 91,439 bytes). End-to-end proving is unchanged within noise: 7.01 / 7.05 / 7.05 s on fibonacci and 17.2 / 17.0 / 17.2 s on sha2-chain.
Prover field multiply. Main's BN254 challenges were 125-bit masked Montgomery limbs. No prover crate on main multiplied by them with a narrow multiply: main never calls
mul_by_hi_2limbsormul_hi_bigint_u128from the arkworks fork, and nothing callsmul_u128on a challenge. The generic 4-limb multiply costs the same for any challenge.A sumcheck binding step
a + r·(b − a)over 2^22 BN254 elements, single thread, 3 rounds, in ns per pair:mul_by_hi_2limbs(unused on main)So full width disabled no prover fast path. A sparse multiply could save about a third of the binding cost, but main never used one.
Search: calling thread first, then the pool
Measured on the Boat sandbox (8 threads), with a Keccak
MessageSearchprobe and a fresh transcript state per call. "Parked" sleeps 3 ms before each call so the pool's workers go idle; "warm" calls back to back. Each cell is median / mean in µs, over 300 calls (100 at 10 and 12 bits).What the table shows:
Each probe clones the transcript, which is not where the time goes: a Keccak probe costs about 280 ns, of which the clone is 2 ns; a Blake2b digest-transcript probe costs about 290 ns, of which the clone is 12 ns. Neither clone allocates.
Tests
from_challenge_bytesequals the input reduced mod p, for Fr and Fq, at the draw-indexed widths.grind_bits.rs);bn254_facades_need_no_proof_of_work;fp128_facades_use_the_full_order, for both 128-bit primes.Prime128OffsetA7F7, where rounds grind);bn254_round_proofs_carry_no_nonce_bytes;nonced_round_trips_and_rejects_malformed_input;PowDifficulty.Prime128OffsetA7F7, the sites read one ground nonce each, in order, and a missing one is rejected at its site;search_is_independent_of_the_thread_countplaces the least passing nonce at 0, inside the calling thread's 1024 nonces, at 1023 and 1024 (either side of the edge), past it, and at the end of the range. On pools of 1, 2, 3 and 8 threads, the search always returns that nonce.challenge_bits_are_the_least_free_power_of_twocheckschallenge_bits_for_lossagainst a big-integer oracle, with known answers (L = 1, 2, 3, 4, 5 → 128, 129, 130, 130, 131);test_challenge_short_scalar, on every transcript, checks the draw equals a manual squeeze-and-mask, squeezes nothing extra, and never exceeds k bits;short_scalar_refuses_a_field_without_roomchecks a 128-bit prime returnsFieldTooSmalland leaves the transcript unchanged;group_scalar_challenges_are_shortpins the widths 130, 129 and 130 and checks that γ and the Σ labels stay full width;challenge_bursts.rschecks the draw order over real transparent and zero-knowledge proofs (2 to 6 variables).StagePowNoncesround-trips, costs 2 bytes when empty, and rejects inconsistent masks.Okon the frozen replay andErrin production; none was loosened.akita_e2e:muldiv_akita_rejects_tampered_pow_noncesproves muldiv on the Akita build. Then, at every site, it checks:ProofOfWorkRejectedat that site;UnpricedTranscript(behindtest-utils) declares a set of 2^256. Tests use it to reach the check under test and to run F64. It must never back a real proof.Fixtures and CI
Proof bytes change (BN254 challenge values and the wire header), so the committed fixtures must be regenerated.
The FS inventories are re-blessed in this PR, so the change can be reviewed. The new lines:
check_grind, and the prover's probe;UnpricedTranscript;JoltStageProofs.pow_nonces.Stage 6b's draw function now has one squeeze site in place of four.
Validation on a Boat sandbox, using the commands from
.github/workflows/rust.ymlandfs-soundness.yml:cargo fmt --all --checkandscripts/check_style_invariants.py: clean.cargo clippy, all clean:--all --all-targetswithallocative,host, withallocative,host,zk, and with--no-default-features;-p jolt-transcript --all-targetswith--all-featuresand with--no-default-features;-p jolt-verifier --all-targetswithakitaand withakita,prover-fixtures;-p jolt-prover --all-targetswithakitaand withakita,prover-fixtures;-p jolt-field,-p jolt-sumcheck -p jolt-blindfold,-p jolt-hyperkzg,-p jolt-kernels,-p jolt-verifier-derive, and jolt-verifier and jolt-prover withprover-fixtures,zk.cargo nextest run:-p jolt-verifier --test fs_obligations --features fs-audit: passes;--cargo-profile ci -p jolt-verifier --test fs_attackswithakita,fs-audit,prover-fixtures(2 passed),fs-audit,prover-fixtures(3 passed) andfs-audit,prover-fixtures,zk(2 passed);--all-features, 89 with default features;--lib -E 'test(stage6b)': 6 passed, and 5 withakita;--cargo-profile ci -p jolt-prover --test akita_e2e --features akita,prover-fixtures: 16 passed;--lib80 (withprover-fixtures,zkand withfield-inline,akita), and Dory muldiv e2e.cargo nextest runpasses (22), andcargo run --release -- -o -p <dir>succeeds.Left to CI:
Not in this PR
challenge_degree.challenge_bytes#[doc(hidden)];🤖 Generated with Claude Code