Skip to content

feat: commit the R1CS witness over the base field (proof format 2.0)#470

Merged
shreyas-londhe merged 14 commits into
mainfrom
feat/base-field-witness-commit
Jul 13, 2026
Merged

feat: commit the R1CS witness over the base field (proof format 2.0)#470
shreyas-londhe merged 14 commits into
mainfrom
feat/base-field-witness-commit

Conversation

@BornPsych

@BornPsych BornPsych commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Commits the R1CS witness over the base field and moves the Spartan blinding polynomial g to a separate extension-field commitment, instead of committing the whole witness (padded with the blinding coefficients) in the extension field. The proof format is bumped to 2.0 and tagged per field so one field's proof is rejected by another field's verifier. bn254 and Goldilocks both ride the new uniform non-ZK base-commit path.

Builds on the field-generic spine + two-backend work (#458/#459/#460). Independent of the CI guard PR (#469).

Why

Goldilocks is a 64-bit prime: its whole value is that committed data (witness, matrix coefficients) stays in the base field Field64, with only challenges/sumcheck in the degree-3 extension Field64_3. The old scheme committed the witness in the extension and padded w1 with room for the blinding polynomial — that forces base data through extension-width commitments and couples witness sizing to blinding room. Splitting the two commitments lets the witness commit natively in the base field and sizes the blinding commitment to just its 4 * m_0 cubic coefficients.

What changed

  • provekit-commonWhirR1CSScheme gains whir_witness: GenericWhirConfig<P::Embedding> (base-field, non-ZK) and a separate whir_blinding: GenericWhirConfig<Identity<Ext<P>>>; new new_witness_config_for_size / new_blinding_config_for_size / whir_protocol_params; the old 4 * m_0 w1-padding is gone. .np format magic is derived from ProofField::FIELD_ID (np_format); all format versions bump to 2.0 (major must match on read). Covector eval helpers are embedding-aware (base × ext).
  • provekit-prover — commits the base-field witness with whir_witness (no base→ext lift) and commits g with the separate whir_blinding; BlindingState carries g's own vector + WHIR witness; g is opened by a dedicated whir_blinding.prove after the witness opens.
  • provekit-verifier — receives the ext blinding commitment right after the witness commitment (matching prover transcript order), verifies the witness with the two-step whir::Config API, and verifies g by a dedicated open at the end.
  • Backends — bn254 drops the zkWHIR config for the same non-ZK path (Identity<Fr>, FIELD_ID 0, .np magic byte-identical); Goldilocks switches to Basefield<Field64_3> (FIELD_ID 1). Adds GoldilocksEfField (Identity<Field64_3>, FIELD_ID 2) as a stand-in for challenge-bearing fixtures.
  • Tooling / tests — cli + verifier-server pass whir_witness + whir_blinding to the gnark writer; r1cs-compiler security tests check the two new configs (dimension test drops to m = 13); fixtures route base vs challenge suites to the right field.

Caveats

  • GoldilocksEfField is a stand-in. A single Field64 challenge is only ~64-bit sound, so challenge-bearing fixtures run on the ext-leaf field until a base transcript codec + k-fold repetition land (see the TODO in goldilocks/src/field.rs).
  • Witness commitment is non-hiding — WHIR openings leak witness values, so this provides sumcheck ZK only, not witness ZK (whir_witness TODO).
  • Format 2.0 is a breaking change — proofs are not compatible with 1.x verifiers.

Out of scope (tracked separately)

This PR changes only the Rust WHIR proving/verifying path. It intentionally does not touch:

  • the Go recursive-verifier/ (which still parses the old fused zkWHIR commitment layout), or
  • the provekit-gnark bridge / Groth16 prover path.

Consequence: the WHIR → recursive-verifier (Go) → Groth16 on-chain route will not verify 2.0 proofs until a paired Go update lands, tracked as follow-up. The direct Groth16+BSB22 backend (#427 / #447) is a separate crate and is unaffected by this change (the two only share a few tooling files). This keeps the PR focused on the Rust proof-system change; the recursive-verifier port is deliberately left for a coordinated follow-up.

Verification

  • cargo fmt --all --check, cargo clippy --all-targets --all-features (no new errors), cargo check --all-targets --all-features green.
  • Fixtures over both fields: bn254 7 roundtrip + 6 soundness, goldilocks 7 roundtrip + 6 soundness — all pass.
  • provekit-r1cs-compiler 41 tests pass (security + dimension, split config).
  • Core libs: common 45, prover 8, verifier 83, bn254 45, goldilocks 8; doc tests pass.

Commits

9 authored commits (base-field split across common / prover / verifier / backends / tooling / fixtures) plus one commit reconciling the base-sampling bound and rustfmt onto current main.

shreyas-londhe and others added 10 commits July 8, 2026 15:04
FIELD_ID tags each field's on-disk proof so one field's proof is rejected
by another field's verifier; bn254 reserves 0 for legacy byte-identity.
The 2.0 major bump makes an incompatible WhirR1CSScheme/WHIR payload fail
fast on read instead of mis-deserializing.
…per field

Commit the witness over the base field (whir_witness: GenericWhirConfig
<P::Embedding>) and move the Spartan blinding polynomial g to a separate
extension-field commitment (whir_blinding). g masks ext-valued sumcheck
rounds, so it needs ext randomness and cannot be embedded in a base
witness vector; sizing it to 4*m_0 coefficients drops the old m-padding.

Make the covector eval helpers embedding-aware (base×ext mixed arithmetic)
since the polynomial is now base while eval points are ext.

Fold ProofField::FIELD_ID into the .np magic (bn254 id 0 unchanged) so a
proof from one field fails another field's format check.
Mirror the scheme split: commit the base-field witness with the non-ZK
whir_witness config (no more base->ext lift) and commit the Spartan
blinding g with the separate ext whir_blinding config. BlindingState
carries g's flattened vector plus its own WHIR witness instead of an
offset into the witness padding.

The sumcheck sources blinding_eval from the standalone g vector (offset 0),
and g is opened by a dedicated whir_blinding.prove after all witness opens,
replacing the OffsetCovector-into-the-witness-open. Eval helpers take the
embedding for base x ext mixed products.
Receive the ext blinding commitment right after the witness commitment
(before challenge sampling), matching the prover's transcript order. Verify
the witness with the two-step whir::Config API (verify -> final-claim
check) and drop the blinding covector that was mixed into the witness open.
Verify g by a dedicated whir_blinding open at the end, against the power
covector at offset 0.
GoldilocksField switches its embedding from Identity<Field64_3> to
Basefield<Field64_3> (FIELD_ID 1): the witness commits in Field64, with
Field64_3 only for challenges and sumcheck — the base-commit path.

Add GoldilocksEfField (Identity<Field64_3>, FIELD_ID 2) as a stand-in for
challenge-bearing fixtures whose witness holds ext values a base witness
can't. Its distinct FIELD_ID reflects the different base byte layout, and
its TODO records the concrete removal path (base transcript codec + k-fold
repetition for 128-bit soundness). Factor the shared FieldHash glue into a
macro over both fields.
Drop the zkWHIR config (WhirZkConfig) — bn254 now uses the same non-ZK
base-commit path as every field, with Identity<Fr> so base == ext (no
actual base savings, no witness hiding). FIELD_ID 0 keeps the .np format
magic byte-identical. mavros_prove sources blinding_eval from the separate
g vector, matching the prover.
cli and verifier-server pass whir_witness + whir_blinding to the gnark
parameter writer instead of the removed zkWHIR blinded/blinding
commitments. The r1cs-compiler security tests check the two new configs
(new_witness_config_for_size + new_blinding_config_for_size), and the
dimension test drops to m=13 (the blinding-room padding bump is gone).
WhirR1CSProver now requires Standard: Distribution<Base<P>>; add it to the
harness runners that prove.
Split the roundtrip/soundness macros into base-compatible and
challenge-bearing suites. GoldilocksField runs the base suites (real
base-leaf commitment); GoldilocksEfField runs the challenge suites (ext
witness holds challenge values). bn254 runs both on itself. Thread the
base-sampling bound through the shared helpers.
Complete the base-sampling bound on `tampered_public_input_is_rejected`
with `Distribution<Base<P>>` — the merge left it with only the ext bound,
but the generic test body commits in the base field via `prove`. Rewrap
the `np_format` doc comment to satisfy rustfmt.

Reuse the `next_power_of_two` helper for `nv_blind` instead of a
re-implemented ceil-log2, and describe `GoldilocksEfField` by its purpose
(identity-embedding Goldilocks for challenge-bearing circuits).
@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs-provekit-org Ready Ready Preview Jul 13, 2026 8:41am

Request Review

@BornPsych BornPsych changed the title feat: commit the R1CS witness over the base field with a separate extension-field blinding (proof format 2.0) feat: commit the R1CS witness over the base field (proof format 2.0) Jul 8, 2026
@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown

CSP benchmarks

Metric Value
Workflow status [PASS] success
Commit f103cf22b41e
Run #29236301560
Circuits benchmarked 21
Iterations averaged per circuit 3

Prover time, peak RSS, peak heap, and verifier time are arithmetic means across the iterations. Peak heap comes from the largest peak memory entry in provekit-cli prove's tracing output; peak RSS is reported by /usr/bin/time -v (max-resident-set-size).

Each metric cell shows the current value followed by the percentage delta against the latest successful main run #29080333239. (new) marks circuits absent from the baseline.

Results
Circuit Constraints Witnesses Prover time Peak RSS Peak heap Verifier time Proof size PKP size
ecdsa_p256 143,321 (±0.0%) 258,215 (±0.0%) 1.76 s (-33.0%) 225 MB (-12.0%) 188 MB (-16.6%) 90 ms (-73.5%) 305 KB (-89.4%) 811 KB (±0.0%)
keccak_1024 788,814 (±0.0%) 1,490,812 (±0.0%) 5.11 s (-18.8%) 896 MB (-7.9%) 874 MB (-6.9%) 450 ms (-44.7%) 369 KB (-88.5%) 5.88 MB (±0.0%)
keccak_128 157,522 (±0.0%) 304,529 (±0.0%) 1.23 s (-41.7%) 235 MB (-13.1%) 202 MB (-15.4%) 110 ms (-69.4%) 305 KB (-89.3%) 1.26 MB (±0.0%)
keccak_2048 1,507,374 (±0.0%) 2,840,532 (±0.0%) 10.17 s (-12.6%) 1.70 GB (-4.6%) 1.68 GB (-5.3%) 863 ms (-37.7%) 390 KB (-88.4%) 11.97 MB (±0.0%)
keccak_256 247,782 (±0.0%) 474,010 (±0.0%) 1.42 s (-37.9%) 290 MB (-11.0%) 249 MB (-13.2%) 150 ms (-62.8%) 327 KB (-88.8%) 1.88 MB (±0.0%)
keccak_512 428,126 (±0.0%) 812,944 (±0.0%) 2.63 s (-26.9%) 541 MB (-7.7%) 455 MB (-9.4%) 250 ms (-53.7%) 350 KB (-88.6%) 3.32 MB (±0.0%)
poseidon2_12 479 (±0.0%) 563 (±0.0%) 40 ms (-88.7%) 12.40 MB (-49.3%) 3.26 MB (-77.8%) 7 ms (-93.3%) 72.69 KB (-93.2%) 436 KB (±0.0%)
poseidon2_16 556 (±0.0%) 719 (±0.0%) 40 ms (-88.6%) 12.77 MB (-48.3%) 3.46 MB (-76.7%) 10 ms (-90.0%) 74.56 KB (-92.9%) 530 KB (±0.0%)
poseidon2_2 231 (±0.0%) 278 (±0.0%) 30 ms (-91.9%) 11.41 MB (-51.7%) 2.72 MB (-80.7%) 68.86 KB (-93.6%) 108 KB (±0.0%)
poseidon2_4 529 (±0.0%) 535 (±0.0%) 30 ms (-91.3%) 11.66 MB (-51.0%) 2.86 MB (-80.0%) 74.20 KB (-92.9%) 31.66 KB (±0.0%)
poseidon2_8 363 (±0.0%) 423 (±0.0%) 30 ms (-91.4%) 12.42 MB (-49.6%) 3.15 MB (-78.3%) 72.20 KB (-93.3%) 365 KB (±0.0%)
poseidon_12 504 (±0.0%) 524 (±0.0%) 40 ms (-88.7%) 12.78 MB (-48.3%) 3.53 MB (-76.3%) 10 ms (-90.0%) 73.05 KB (-93.1%) 569 KB (±0.0%)
poseidon_16 609 (±0.0%) 633 (±0.0%) 40 ms (-88.7%) 13.33 MB (-46.6%) 3.98 MB (-73.8%) 10 ms (-90.0%) 74.06 KB (-92.9%) 717 KB (±0.0%)
poseidon_2 240 (±0.0%) 249 (±0.0%) 30 ms (-91.3%) 11.31 MB (-51.7%) 2.68 MB (-80.9%) 66.12 KB (-93.8%) 59.18 KB (±0.0%)
poseidon_4 297 (±0.0%) 309 (±0.0%) 33 ms (-90.2%) 12.05 MB (-49.8%) 3.10 MB (-78.6%) 69.37 KB (-93.5%) 332 KB (±0.0%)
poseidon_8 402 (±0.0%) 418 (±0.0%) 30 ms (-91.4%) 12.31 MB (-49.5%) 3.29 MB (-77.6%) 3 ms (-96.7%) 70.68 KB (-93.4%) 448 KB (±0.0%)
sha256_1024 196,940 (±0.0%) 339,764 (±0.0%) 1.33 s (-39.8%) 267 MB (-12.6%) 236 MB (-13.7%) 160 ms (-61.6%) 305 KB (-89.2%) 1.86 MB (-1.3%)
sha256_128 46,398 (±0.0%) 80,974 (±0.0%) 333 ms (-69.5%) 76.02 MB (-25.3%) 55.50 MB (-33.7%) 40 ms (-84.6%) 263 KB (-89.9%) 505 KB (-1.3%)
sha256_2048 345,399 (±0.0%) 612,724 (±0.0%) 2.61 s (-27.6%) 515 MB (-6.0%) 436 MB (-9.9%) 303 ms (-48.9%) 343 KB (-88.6%) 3.02 MB (+0.1%)
sha256_256 67,904 (±0.0%) 117,944 (±0.0%) 570 ms (-58.9%) 123 MB (-18.3%) 98.23 MB (-24.8%) 60 ms (-79.3%) 277 KB (-89.8%) 703 KB (-1.6%)
sha256_512 110,916 (±0.0%) 191,884 (±0.0%) 690 ms (-54.3%) 152 MB (-16.2%) 126 MB (-20.3%) 90 ms (-71.9%) 295 KB (-89.3%) 1.13 MB (+2.1%)

@zkfriendly zkfriendly left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

PR looks great!

One thing to bring up is the fact that bn254 path is no longer zk (I understand this is because zook not landed yet). But I was wondering, even with zook, it's worth considering making zk optional (there are use cases that don't need it, and this can make the prover faster) and being very explicit regarding whether zk is enabled or not. Currently, it is a bit odd that proof version 2 is NOT zk but 1* is.

Comment thread provekit/r1cs-compiler/src/whir_r1cs.rs
Comment thread tooling/cli/src/cmd/generate_gnark_inputs.rs
- test(r1cs-compiler): assert 128-bit WHIR security for Goldilocks too, not
  just bn254. Genericize assert_configs_secure over the field and pull in
  provekit-backend-goldilocks as a dev-dependency (no dependency cycle).
- feat(gnark): warn when writing gnark parameters in the 2.0 split
  witness/blinding layout, which the Go recursive-verifier does not yet parse
  (it deserializes without error but fails verification silently).
- docs(common): spell out the WhirR1CSScheme ZK posture (sumcheck-ZK on,
  witness-ZK off) and drop the stale zkWHIR naming in the gnark config.
Comment thread tooling/provekit-fixtures/tests/shared/mod.rs Outdated
Comment thread provekit/common/src/whir_r1cs.rs
@shreyas-londhe

shreyas-londhe commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

minor: Field-tag / version check is binary-only

The PR description says proofs are "tagged per field so one field's proof is rejected by another field's verifier," and that the 2.0 bump "fails fast." That's true on the binary path: io/bin.rs checks FORMAT and major == when it parses the header. The JSON path doesn't. read_json (io/json.rs:35) is a bare serde_json::from_reader with no magic, version, or field check, and the ensure! calls in verify() are hash-based (r1cs_hash, public_inputs_hash), not version-based.

A cross-field or downgraded .json proof is caught only by the downstream transcript mismatch. That's still sound, since the domain separator serializes the whole scheme, but it isn't the explicit tag gate the description implies. Either thread the version and field check into read_json, or scope the description to the binary format.

@zkfriendly zkfriendly left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Follow-ups would be to add optional zk. WHIR is already making this optional with an upcoming PR.

@BornPsych

Copy link
Copy Markdown
Collaborator Author

minor: Field-tag / version check is binary-only

The PR description says proofs are "tagged per field so one field's proof is rejected by another field's verifier," and that the 2.0 bump "fails fast." That's true on the binary path: io/bin.rs checks FORMAT and major == when it parses the header. The JSON path doesn't. read_json (io/json.rs:35) is a bare serde_json::from_reader with no magic, version, or field check, and the ensure! calls in verify() are hash-based (r1cs_hash, public_inputs_hash), not version-based.

A cross-field or downgraded .json proof is caught only by the downstream transcript mismatch. That's still sound, since the domain separator serializes the whole scheme, but it isn't the explicit tag gate the description implies. Either thread the version and field check into read_json, or scope the description to the binary format.

Confirmed, and it applies to all four FileFormat types, not only the proof: read_json is a bare serde_json::from_reader.

Two things I checked. A 1.x JSON proof deserializes cleanly into 2.0, because ProvekitProof's struct shape never changed between the versions. Only the bytes inside narg_string did, and that field is opaque hex, so any length decodes. Cross-field does get caught, but by accident: field elements hex-encode to different widths (32B for Fr, 8B for Field64, 24B for Field64_3) and the deserializer rejects short reads and trailing bytes. Hand it a circuit with zero public inputs and there is nothing left to catch it on.

Both still fail in transcript replay, so nothing unsound gets through. It just fails after the full verification run, with an error that points somewhere else.

Added a warn! on the JSON read path for now. since this pr is already reviewed and this fix would add a lot of changes in this PR. Tracked in #472.

The suite split put `tampered_public_input_is_rejected` in
`challenge_soundness_suite!`, so it only ran on `GoldilocksEfField` and the
base-commit witness path lost its dedicated public-input tamper test. It uses
`squaring_chain` and `two_public_inputs` and carries no
`Embedding<Target = Base<P>>` bound, unlike the two tests it was grouped with,
so it belongs in `soundness_suite!`.

Moving it back gives `GoldilocksField` both halves of the public-input pair: the
hash-guard rejection and the binding-covector rejection.
`np_format` derives the `.np` magic by offsetting the final byte with
`FIELD_ID`, which reserves all 256 values of that byte for `.np`. Nothing
recorded or enforced that reservation. Assert it at compile time against the
other three format constants, so a field whose magic would collide is a build
error rather than a file one reader silently accepts as another.

Offsetting one byte is a bijection on `u8`, so distinct ids always give distinct
magics and no field can collide with another. What `np_format` cannot see,
working one field at a time, is two backends declaring the same `FIELD_ID`.
`field_tags.rs` is the only place all three fields are in scope together, so it
asserts the ids and magics are pairwise unique. Reading `FORMAT` there also
const-evaluates the new assert for the non-bn254 fields, which nothing else in
the workspace does.
The magic, format, major/minor, and per-field checks live in the binary header,
so `read_json` accepts a downgraded or cross-field artifact and it fails much
later in Fiat-Shamir transcript replay, with an error that points somewhere
else. Warn on the JSON read path so nothing trusts an untagged artifact
silently.

Tagging JSON properly is a breaking schema change to the verifier-server
`/verify` endpoint and the wasm package; tracked in #472.

@shreyas-londhe shreyas-londhe left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

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.

3 participants