Skip to content

feat(transcript): full-width BN254 challenges and proof of work for short challenges (J6) - #2108

Draft
quangvdao wants to merge 12 commits into
transcript/j3-sponge-powfrom
transcript/j6-protocol-grinding
Draft

quangvdao wants to merge 12 commits into
transcript/j3-sponge-powfrom
transcript/j6-protocol-grinding

Conversation

@quangvdao

@quangvdao quangvdao commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  1. BN254 challenges are now full width. Every BN254 challenge, challenge() and challenge_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).
  2. Proof of work (grinding) before every challenge that still falls short. This covers Akita's field Prime128OffsetA7F7 and 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 Prime128OffsetA7F7 challenge 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_bytes reduces squeezed bytes mod p. The width depends on how many draws came since the transcript last absorbed: draw k squeezes reduction_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.

    • BN254 Fr and Fq: 52 bytes for the first draw, matching J2's codec, then 1 more byte at each doubling of the draw count.
    • The 128-bit Solinas primes Fp128<P>, including Akita's Prime128OffsetA7F7 (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.
    • Binary fields: NUM_BYTES per draw, a bijection onto the field. F192 grows from 2^128 to 2^192.
  • Removed:

    • the 125-bit BN254 mask;
    • the separate big-endian 128-bit scalar decoder (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;
    • zklean's Truncate128 nodes, which nothing created.

    challenge_scalar stays as a provided method that decodes exactly as challenge does. It is kept because about 55 call sites and the FS audit's ChallengeKind::Scalar use it.

  • One source for the set size and the width. Each field states its set in CHALLENGE_SET_LIMBS. The facade reads it through ChallengeSetSize::from_limbs, and so does J2's ChallengeCodec::CHALLENGE_SET_SIZE. jolt_field::reduction_width is 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.

  • One source for the width. Transcript::challenge_short_scalar(loss) takes the width from challenge_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.
  • Losses. Each comes from the verifier's checks, read as Laurent polynomials in the challenge:
Challenge Where the verifier uses it Loss L Width k
β (each round) the round's checks span β⁻¹ to β; drawn alone after the first reduce message 2 129 bits
α (each round) the fold spans α⁻¹ to α (2). The last α is drawn right before γ with no absorb in between, so it also covers γ's 2 4 130 bits
d (final) the final pairing check spans d⁻¹ to d² 3 130 bits
γ (final) only −γ and γ⁻¹ are used; an inverse is full width anyway – full width
zero-knowledge Σ challenges squared or multiplied by other challenges – full width
stage-8 batching powers and every other challenge powers are full width anyway – full width

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.rs records real transparent and zero-knowledge proofs and checks:

    • the short draws come as β, α per round, then d;
    • each short draw comes right after an absorb;
    • only γ follows the last α before the next absorb.

    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_SIZE now comes from CHALLENGE_SET_LIMBS for every field (raised to the degree for extensions). pseudo_mersenne_power is removed.
  • J2's BN254 reduction_width is replaced by jolt_field::reduction_width.
  • A new test checks the draw-indexed width bound in grind_bits.rs.

Grinding

  • Seam. The verifier drives the legacy jolt_transcript::Transcript facade, 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 WrongNumberOfPowNonces or ProofOfWorkRejected.

  • 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 parallel scan 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 return PowDifficulty, and the verifier rejects.

Stage sites

crates/jolt-verifier/src/stages/pow.rs holds the stage sites:

  • PowSite names the 11 bursts in transcript order.
  • pow_bits, check_pow and grind_pow give the difficulty, the verifier check and the prover search.
  • StagePowNonces holds one optional nonce per site. It rides in JoltStageProofs::pow_nonces, on the Akita build only.
  • Every ConcreteSumcheck now states challenge_loss(): the loss of the challenges it draws before the batch. The derive macro sums the members' losses plus BATCH_COEFFICIENT_LOSS = 1 for 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.
  • Stage 6b draws its challenges before the batch is built, through Stage6bDraws::draw. draw and Stage6bDraws::challenge_loss run one private sequence, which lists each draw with its loss: draw squeezes, and challenge_loss adds 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 loss u64::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}:

  1. a header 2·len + has_nonces;
  2. the len messages;
  3. only if has_nonces is set, a non-empty Vec<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.

StagePowNonces is 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) for Prime128OffsetA7F7 is ⌈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

Site Loss L (why) Prime128OffsetA7F7 bits
Sumcheck round: clear, compressed, committed/ZK, BlindFold inner/outer degree bound d (Schwartz-Zippel on a degree-d round polynomial) d=1: 1, d=2: 2, d=3: 2, d=10: 4
Stage-1 uniskip round d = 27 (42 FI) 5 (6)
Stage-2 uniskip round d = 6 (12 FI) 3 (4)
BlindFold folding challenge r 2: the folded error is E₁ + r·T + r²·E₂ 2
BlindFold outer zero-check point τ num_vars: the outer sum has total degree num_vars in τ b(num_vars)
BlindFold inner combination ra, rb, rc 1: a random linear combination 1

BlindFold only runs over BN254 today, so its nonce list is empty.

Stage sites (PowSite)

Site Draws Loss L Bits
Stage1Tau τ, after the commitments ℓT + 10 (ℓT + 15 FI): multilinear in ℓT + 1 coordinates, plus the uniskip Lagrange kernel of degree 9 (14 FI) 5 for ℓT ≤ 21
Stage1 the batching coefficient, after the uniskip output claim 1 1
Stage2TauHigh τ_high, after the stage-1 openings 2 (4 FI): the product uniskip Lagrange degree 2 (3)
Stage2 the γs of RAM read-write (1) and instruction claim reduction (4), the output-check address point (ℓR), FI register claim reduction (2), and the coefficients (1) ℓR + 6 (+2 FI) 5 for ℓR ≤ 25
Stage3 Spartan shift (4), instruction input (1), register claim reduction (2), coefficients (1) 8 4
Stage4 register read-write (2), RAM val check (1), FI register read-write (2), coefficients (1) 4 (6 FI) 3 (3)
Stage5 instruction read-RAF (3 on Akita: the canonical address adds a claim), RAM RA claim reduction (2), coefficients (1) 6 3
Stage6a the six bytecode γs (degree 61, 62 FI, from the input expression), the booleanity reference address (the missing coordinates) and γ (2(N−1)), coefficients (1) about 150 8
Stage6b instruction RA γ (nv − 1), FI register increments γ (1), committed bytecode η (NUM_BYTECODE_VAL_STAGES − 1 = 5 on Akita), coefficients (1) nv (+1 FI) (+5 committed) b(nv) to b(nv + 6)
Stage7 Hamming-weight γ: the input expression's degree, 3·ram + 2·(instruction + bytecode) + balanced-increment chunks + 1; coefficients (1) about 100 7
Stage8 the Akita selector points of every packed object Σ selector coordinates b(L), 0 when no object has a selector

Soundness 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:

  • Placement. Each of the 11 sites is checked by the verifier and ground by the prover at the same transcript position, right after the prover message and before the first draw. A grep of every verifier draw found none outside a sumcheck round without a nonce in front of it. Stage 1 checks only in the non-ZK branch, which is fine because akita and zk cannot be enabled together.
  • Losses. The audit derived every loss independently from the jolt-claims expressions, and each matches the code.
  • Fail closed, no panics. A missing, extra or failing nonce is rejected; so are unknown mask bits and a wrong nonce count. A loss that overflows, or a failed lattice-booleanity shape, becomes u64::MAX and then PowDifficulty.
  • Difficulty source. Only validated statement parameters, preprocessing and constants: never proof bytes.
  • Non-Akita builds. pow_nonces exists only on the Akita build, and require_no_pow runs in both the Dory prover and the non-Akita verifier.

Its caveats, left as follow-ups:

  • Reused-point zero-checks (low). Three checks run at points drawn in earlier sumchecks: RAM Hamming booleanity (stage-1 cycle point), booleanity (stage-5 point), and Akita's canonical-address check (stage-2 point). Each adds at most 1/p per round of the earlier sumcheck, as if that round's degree were one higher. Rounding b up absorbs this except when the degree is 2^b − 1 (stage 1: degree 3, 2 bits), where the bound is 2^-128·(1 + 2^-96). This predates this PR.
  • Hard-coded losses (info). The γ-only relations state their loss as a constant. All match the current expressions, but they would drift if a relation added a γ power. Stages 6a and 7 already read theirs from the expression with challenge_degree.
  • Relation id (info). The prover and verifier build the stage-6b formula dimensions with different relation ids, which only label errors. This predates this PR.

Verdict on the short Dory challenges: SOUND, on a re-check after its first verdict of CONDITIONS. Its conditions, all met in this PR:

  • state the round-by-round model (above);
  • pin dory-pcs exactly and test the draw order;
  • re-bless the challenge-site inventory;
  • document the Poseidon bound.

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:

  • the burst order, for prover and verifier;
  • the decoder: exact least width, mask with no off-by-one, injective for every field;
  • the FS audit double, whose short draws are recorded as challenges and never as proof-of-work checks.

Notes, not blockers:

  • the prover now fails to complete with probability about 2^-129 per draw if β or α is zero; the verifier rejects zero;
  • on Poseidon, β's bound is 2^-128·(1 + 2^-124.6).

The two earlier audits (grinding; full width and wire format) were both SOUND WITH NITS. Their nits on challenge_128, Truncate128 and 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:

    • prover: about 2^b hash probes per nonce;
    • verifier: 2 hashes per nonce;
    • proof size: 1–3 bytes per nonce, plus 2 bytes for the stage-nonce mask.
  • 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.

Dory verify main full width (1f543ca) short β/α/d (this head) this head / main full width / main
fibonacci 2^20, 8 threads 72.1 86.4 69.9 0.98 1.18
fibonacci 2^20, 1 thread 90.3 102.2 88.0 0.96 1.13
sha2-chain 2^22, 8 threads 75.6 89.0 81.2 1.04 1.21
sha2-chain 2^22, 1 thread 91.7 106.6 97.7 1.03 1.16

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_2limbs or mul_hi_bigint_u128 from the arkworks fork, and nothing calls mul_u128 on 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:

r ns per pair
main-style 125-bit Montgomery limbs 17.6–17.7
full width 17.6–17.7
short 130-bit 17.7–17.8
main-style limbs via mul_by_hi_2limbs (unused on main) 11.8–11.9

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 MessageSearch probe 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).

bits all on caller old: pool above 6 bits caller 128, then pool caller 256, then pool caller 1024, then pool
parked, 1 1.7 / 1.8 1.4 / 1.5 1.1 / 1.3 1.1 / 1.2 1.4 / 1.5
parked, 3 3.0 / 4.2 2.8 / 4.0 2.8 / 3.9 2.9 / 4.1 3.0 / 4.2
parked, 6 18.8 / 26.8 20.3 / 28.9 20.7 / 90.7 20.2 / 30.6 22.1 / 29.3
parked, 8 80.7 / 112 532 / 866 474 / 681 86.1 / 554 78.6 / 128
parked, 10 288 / 436 1345 / 1963 979 / 1386 791 / 1216 269 / 695
parked, 12 1322 / 2143 2429 / 3022 2342 / 2691 2496 / 2901 1918 / 2282
warm, 8 78.9 / 110 281 / 322 208 / 209 80.4 / 203 78.1 / 117
warm, 10 267 / 424 576 / 680 300 / 373 491 / 512 278 / 602
warm, 12 1294 / 1879 1253 / 1455 737 / 882 915 / 1083 1044 / 1132

What the table shows:

  • One probe costs about 0.3 µs; waking the pool costs about 200 µs warm and 450 µs parked, the price of roughly 700 to 1500 probes.
  • With a 1024-nonce stretch, grinds up to 8 bits almost never wake the pool, so they cost what a plain sequential scan costs. The old 6-bit cutoff paid about 450 µs extra per 8-bit grind on a parked pool.
  • Shorter stretches (128, 256) still wake the pool on most 8-bit grinds.
  • At 12 bits on a warm pool, a shorter stretch is faster. Such grinds are rare.

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

  • jolt-field: BN254 differential tests check that from_challenge_bytes equals the input reduced mod p, for Fr and Fq, at the draw-indexed widths.
  • jolt-transcript:
    • the digest and legacy transcripts are pinned on the draw-indexed widths and their reduction;
    • the draw-indexed width bound (grind_bits.rs);
    • bn254_facades_need_no_proof_of_work;
    • fp128_facades_use_the_full_order, for both 128-bit primes.
  • jolt-sumcheck:
    • wrong, dropped and extra nonces are rejected on the clear, compressed and committed paths (run over Prime128OffsetA7F7, where rounds grind);
    • bn254_round_proofs_carry_no_nonce_bytes;
    • nonced_round_trips_and_rejects_malformed_input;
    • Mersenne-61 fails closed with PowDifficulty.
  • jolt-blindfold:
    • a real BN254 proof carries no nonces, and an extra one is rejected;
    • over Prime128OffsetA7F7, the sites read one ground nonce each, in order, and a missing one is rejected at its site;
    • the independent test-support prover grinds at every site.
  • jolt-transcript search: search_is_independent_of_the_thread_count places 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.
  • Short challenges:
    • challenge_bits_are_the_least_free_power_of_two checks challenge_bits_for_loss against 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_room checks a 128-bit prime returns FieldTooSmall and leaves the transcript unchanged;
    • in jolt-dory, group_scalar_challenges_are_short pins the widths 130, 129 and 130 and checks that γ and the Σ labels stay full width;
    • challenge_bursts.rs checks the draw order over real transparent and zero-knowledge proofs (2 to 6 variables).
  • jolt-verifier: StagePowNonces round-trips, costs 2 bytes when empty, and rejects inconsistent masks.
  • FS audit transcript: the test transcript now records the proof-of-work check bytes next to the challenges and replays them. A frozen replay of a mutated transcript then passes the proof of work, as the honest run did, and reaches the binding the attack test is about. The attack tests still assert Ok on the frozen replay and Err in production; none was loosened.
  • jolt-prover akita_e2e: muldiv_akita_rejects_tampered_pow_nonces proves muldiv on the Akita build. Then, at every site, it checks:
    • with a nonce: dropping it, and replacing it with a nearby failing nonce, are each rejected with ProofOfWorkRejected at that site;
    • without a nonce: adding one is rejected at that site.
  • UnpricedTranscript (behind test-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:

  • the grind-seam absorbs: the nonce in check_grind, and the prover's probe;
  • the forwarding draws of UnpricedTranscript;
  • the proof field 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.yml and fs-soundness.yml:

  • cargo fmt --all --check and scripts/check_style_invariants.py: clean.
  • cargo clippy, all clean:
    • --all --all-targets with allocative,host, with allocative,host,zk, and with --no-default-features;
    • -p jolt-transcript --all-targets with --all-features and with --no-default-features;
    • -p jolt-verifier --all-targets with akita and with akita,prover-fixtures;
    • -p jolt-prover --all-targets with akita and with akita,prover-fixtures;
    • from earlier commits: -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 with prover-fixtures,zk.
  • cargo nextest run:
    • -p jolt-verifier --test fs_obligations --features fs-audit: passes;
    • --cargo-profile ci -p jolt-verifier --test fs_attacks with akita,fs-audit,prover-fixtures (2 passed), fs-audit,prover-fixtures (3 passed) and fs-audit,prover-fixtures,zk (2 passed);
    • jolt-transcript: 114 passed with --all-features, 89 with default features;
    • jolt-verifier --lib -E 'test(stage6b)': 6 passed, and 5 with akita;
    • --cargo-profile ci -p jolt-prover --test akita_e2e --features akita,prover-fixtures: 16 passed;
    • from earlier commits: jolt-field 164, jolt-sumcheck and jolt-blindfold 175, jolt-verifier --lib 80 (with prover-fixtures,zk and with field-inline,akita), and Dory muldiv e2e.
  • zklean-extractor: cargo nextest run passes (22), and cargo run --release -- -o -p <dir> succeeds.

Left to CI:

  • the full jolt-verifier and jolt-prover test passes, including field-inline Akita e2e and ZK;
  • the fixture jobs;
  • the fuzz targets, which are a separate workspace.

Not in this PR

  1. The Poseidon facade squeezes non-uniform bytes, so its declared set overstates. This predates this PR. Poseidon should move to a native Fr squeeze.
  2. Reused-point zero-checks, described under the audit: price them in the earlier sumcheck's round loss, or keep the note on why the gap is negligible.
  3. Hard-coded γ losses: read them from each relation's input expression with challenge_degree.
  4. Smaller items:
    • jolt-spartan-verifier's τ and inner combination;
    • marking challenge_bytes #[doc(hidden)];
    • a prover-side error variant for grinding;
    • merging BlindFold's fold and outer-point nonces, which follow the same prover message. Keeping them separate is sound, and both are 0 bits on BN254.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

📏 PR diff

Note

Actual code changed: 1,976 lines

🟢 +1,617 added    🔴 −359 removed

Tests, docs, fixtures, and helper scripts excluded.

Category 🟢 Added 🔴 Removed
⌨️ Actual code +1,617 −359
🧪 Tests +1,676 −510
📚 Docs +492 −68
📦 Fixtures 0 0
🔧 Helper scripts 0 0
Total diff +3,785 −937

Every changed line is classified once.

@quangvdao quangvdao changed the title feat(transcript): grind before every sumcheck round and BlindFold challenge (J6, part 1) feat(transcript): full-width BN254 challenges and proof of work for short challenges (J6) Oct 21, 2026
@quangvdao
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
0xAndoroid force-pushed the transcript/j6-protocol-grinding branch from 0fb32ac to e78ab97 Compare October 21, 2026 17:23
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
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

No deployments
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.

1 participant