Harden the Solidity verifier: bytecode certification and EVM replay - #6
Harden the Solidity verifier: bytecode certification and EVM replay#6jtcoolen wants to merge 108 commits into
Conversation
What: Change the memory lifetime of the four PCS fixed windows (rot_points, x1_powers, q_com, q_eval_set) in VerifierMemoryLayout::new from MemoryLifetime::Phase(MemoryPhase::PcsFixed) to MemoryLifetime::Permanent, and remove the now-unused MemoryPhase::PcsFixed enum variant. Why: These windows are not transient scratch. They are written during PCS preparation and then read across several *later* phases: x1_powers feeds both the rolled q_eval fold (PcsQEvalSourceTable) and the fused final MSM (PcsFinalMsm), while rot_points/q_eval_set feed the f_eval interpolation. Because MemoryLifetime::intersects treats two distinct Phase values as never co-live, tagging these long-lived windows with their own phase made MemoryMap::validate() blind to any overlap between them and the PCS scratch that consumes them. The planner's central non-overlap invariant therefore silently did not cover these windows. They were safe in practice only because their fixed theta-relative addresses (words 52-201) happen to sit below the commitment region while all consuming scratch is allocated above it, a property of the hard-coded address map rather than something the validator guaranteed. How: Nothing ever reuses these byte ranges, so the correct lifetime is Permanent, which forces validate() to reject any future overlap against them. With the four registrations switched over, MemoryPhase::PcsFixed has no remaining users and is deleted. An explanatory comment records the lifetime invariant at the allocation site. No address, size, or rendered output changes; all 28 layout unit tests and the full crate build pass. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: add an inclusive phase-span memory lifetime and tag selector_accumulators as live from QuotientVm through PcsFinalMsm. Why: selector accumulators are written during quotient evaluation and read by later PCS phases, so a single PcsFinalMsm phase let validation miss unsafe overlaps. How: teach MemoryLifetime::intersects about phase spans, apply the span to selector_accumulators, and cover the behavior with memory-layout regression tests.
What: size the accumulator pairing batch region with PAIRING_BATCH_HASH_BYTES. Why: the old expression only matched the hash frame because current G2 and G1 encodings have a coincidental ratio. How: import the named accumulator hash-frame constant and keep the existing memory-region alias pointed at it.
What: include rot_points, x1_powers, q_com, q_eval_set, and q_eval_cptr ends when placing trace_u256_log_word. Why: the trace scratch bound should stay correct if theta-window caps move beyond today's g1_identity guard. How: add each theta-window end to the max-bound list and cover the placement with an oversized-window regression test.
What: reserve constant slots for product-add fused opcodes before emitting the base expression. Why: emitting the base can add enough constants to push the product scalar past the u8 slot checked earlier, causing codegen to panic. How: insert the fused product scalar with const_slot before base emission and add a regression with 256 base constants followed by a fused mem*mem*const add.
What: reserve limb-shape coefficient slots before emitting the extracted residue expression. Why: residue emission can add enough constants to invalidate the earlier u8-slot preflight and panic in emit_limb_shape. How: call const_slot for each recognized limb coefficient before residue emission and add a regression with a LIN7 subshape plus 256 residue constants.
What: add regression coverage that the Solidity generator rejects rotated non-committed instance queries, and clarify the code comments around the single local public-instance evaluation. How: keep the existing constructor-time Rotation::cur guard, document why non-committed instance_eval is a single word, add a debug assertion in quotient lowering, and replace two trace/layout magic literals with named constants. Why: the lowering path only reconstructs the non-committed public-input polynomial at the current rotation, so rotated instance queries must fail at codegen time instead of producing a verifier that disagrees with native Midfall.
Size the pre-first-squeeze transcript run from every advice phase up to and including the first challenge-bearing phase, not just phase 0, so the valid "advice in an early phase, challenge in a later phase" shape no longer under-sizes the buffer and overruns VK_MPTR. In the quotient VM limb-decomposition and product-add peepholes, re-check that fused opcode coefficients still fit their one-byte constant slots after intervening residue/base emission, falling back to generic ops instead of panicking in the u8::try_from(...).expect(...). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The generated Lagrange block writes num_instances + num_neg_lagranges + 1 denominator words in place starting at X_N_MPTR (theta word 26) and batch-inverts them, but the memory planner modeled only the prefix-product scratch half of that call: the input run was never registered as a region or bounded, batch_invert_scratch_bytes sized only the modexp scratch, and validate() capped just the PCS fixed windows. Nothing caps num_instances, so for large enough circuits the run silently overwrote state that is already live at Lagrange time: Q_EVAL_CPTR_MPTR (theta word 201, stored by the proof parser and read by the PCS q_eval fold) at num_instances + num_neg_lagranges >= 175, the virgin-zero G1_IDENTITY_MPTR (word 209, relied on as the committed-instance MSM base) at >= 183, and the decoded proof evaluations (word 220) at >= 194. Codegen succeeded but the deployed verifier rejected every honest proof, a silent memory-safety/completeness break. Extract batch_invert_input_words as the shared source of truth for the run size, record it on VerifierMemoryLayout, and make validate() reject any layout whose run reaches min(Q_EVAL_CPTR_WORD, G1_IDENTITY_WORD, REVERSED_EVALS_WORD), failing codegen with a clear error before any Solidity is rendered. A capacity check is used instead of an arena region because the run intentionally overlays the theta band and PCS fixed windows that are dead at Lagrange time but modeled Permanent, so registering it would produce fake overlaps; the existing benign spill into ROT_POINTS stays allowed. Add a boundary regression test covering the exact pass/fail instance counts. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
g1_to_u256s and g2_to_u256s returned the all-zero EIP-2537 word array whenever coordinates() yielded None. In midnight-curves, coordinates() returns None not only for the point at infinity but for any off-curve point, and the all-zero words are exactly the EIP-2537 encoding of the identity. An off-curve/corrupted point (e.g. an s_g2 from an unchecked deserialization path) was therefore silently baked into the generated verifier as the identity: NEG_S_G2 collapsing to infinity degenerates the e(w, -s*G2) KZG pairing term, and a malformed VK commitment becomes an identity commitment (a different circuit), both with no build error. Distinguish the genuine identity (is_identity()) from an invalid point and assert on the latter, so codegen fails loudly instead of emitting a soundness-broken constant. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
generate_base_vk emits G1Affine::generator() as the VK header G1_BASE while taking g2/s_g2 from the deployer-supplied params, with nothing asserting params.g[0] == G. midnight-proofs' ParamsKZG accepts arbitrary g vectors, so a params file whose SRS base is c*G (c != 1) built without error. The generated verifier then computes the -v*G term of the KZG combination from G while every proof/VK commitment is over c*G, so the on-chain pairing check enforces a different (scaled) statement than the native Rust verifier: honest proofs are rejected, and accept/reject can diverge from the reference verifier, all silently at build time. g[0] is crate-private in midnight-proofs, but the commitment of the constant-1 polynomial equals g[0], and in the Lagrange basis that commitment is sum(g_lagrange). Assert sum(g_lagrange) == generator at codegen time so a mismatched SRS fails the build instead of producing a verifier that silently diverges from the reference verifier. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
record_yul_const_assignment recorded 'let name := literal' bindings for limb7 coefficient recognition but ignored non-let reassignments, because yul_let_assignment returns None for 'x := ...'. A stale literal therefore survived a later runtime reassignment, so a block that binds a variable to a limb7 coefficient literal and then reassigns it to a runtime value could be falsely matched: specialize_limb7_chains would replace the chain with a q_limb7 call baking in the literal while the original code multiplied by the reassigned runtime value, silently miscompiling the quotient identity (no codegen or Yul error). This is unreachable today only because gate-identity lines are SSA, but nothing asserts that and the Evaluator already emits non-let reassignment lines on other paths. Track constants via yul_assignment (let or not) and forget any variable reassigned to a non-constant value, so a stale coefficient can never be matched. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
The VK payload embeds the quotient const table and packed bytecode compiled inside generate_vk, but LoweringPlan recompiles the program from scratch and renders the interpreter (program length, opcode gating, native-callback ordering, stack sizing) from that second build. The only cross-checks were length/reservation inequalities; nothing compared the bytes actually embedded in the VK payload with the second build. Parity rested solely on compile determinism over HashMap/HashSet-backed structures, so an ordering-dependent compile could ship a pinned VK whose bytecode disagrees with the rendered interpreter, permanently rejecting every valid proof (fail-closed on-chain DoS) invisibly at codegen time. Compare vk.constants[quotient_const_offset..] and [quotient_program_offset..] word-for-word against the plan build's consts and PackedProgramCodec::encode_words(bytes) in validate_generator_invariants, failing codegen on any divergence. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
validate_quotient_program proved stack effects, instruction lengths, and mem-token bytes, but never checked that decoded const-table indices fall inside the emitted table. An encoder/planner regression emitting an out-of-range const slot (this class already produced one real bug, the quotient VM const-table overflow) would ship a verifier that loads an arbitrary trailing VK word as a gate coefficient, deterministically flipping accept/reject with nothing failing at build or runtime. Add validate_quotient_const_slots, which walks the finalized byte stream and bounds-checks every constant slot across all const-bearing opcodes (push/add/mul const and const_u8, the add-mul const_u8 forms and their runs, LIN7/BILIN7 rows and pairwise coeffs, and the dynamic AFFINE_SUM and MODARITH7 layouts). It runs after validate_quotient_program in QuotientProgramBuilder::finish, whose length validation guarantees the walked layout is in bounds. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: make kzg::memory_requirements and kzg::computations panic instead of
returning a default/empty result when the intermediate-set construction yields
zero point sets.
How: replace the two `if n_sets == 0 { return ... }` early returns with an
assert!(n_sets != 0, ...) carrying the soundness rationale.
Why: with zero point sets, computations() emits no Block 6, so
PAIRING_LHS_MPTR/PAIRING_RHS_MPTR are never written. Zero-initialized EVM
memory is the EIP-2537 encoding of the BLS12-381 point at infinity, so
FinalPairing.yul's ec_pairing computes e(inf, G2) * e(inf, -sG2) = 1 and the
verifier accepts ANY transcript-parseable proof with no cryptographic checking
of the openings. This is unreachable today only because ProtocolPlan::validate
requires the schedule to end with the Linearization query (n_sets >= 1); the
KZG emitter is the last line of defense for the pairing check and must fail
closed rather than silently emit an accept-all verifier.
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: assert the rotation-point walk in kzg::computations does not unroll more than MAX_ROTATION_WALK_STEPS (4096) mulmod steps. How: compute the walk length as |max_rot| + |min_rot| and assert it against a generous cap before emitting the forward/backward omega walks. Why: the block emits one `mulmod` line per unit step across the whole rotation span, so the emitted Yul grows with rotation magnitude, not with the number of distinct rotations (which is separately capped at ROT_POINTS_CAP_WORDS=28). A legitimate circuit using a large rotation (e.g. Rotation(50_000)) would pass every existing capacity check yet emit enough code to exceed the EIP-170 24KB runtime-size limit, producing an undeployable verifier with no diagnostic. The cap fails closed with a clear message well above any realistic circuit's rotation range; the documented remedy is to roll the walk into a Yul loop. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: rewrite the misleading soundness note above the Montgomery batch inverse in kzg::computations to state the real lbasis_j != 0 invariant and the actual failure mode. How: comment-only. Document that lbasis_j is non-zero only because distinct rotation *values* map to distinct rotation *points* (which holds because the domain order n=2^k exceeds the rotation span for every supported circuit), and that a degenerate tiny-domain circuit aliasing two rotations fails closed (a zero lbasis product makes scalar_inv revert) rather than producing a wrong f_eval. Add a TODO to thread the domain order in for a codegen-time assert. Why: the previous comment claimed lbasis_j is non-zero simply because construct_intermediate_sets de-duplicates rotations, but that de-dups i32 rotation values, not the points x*omega^rot; two distinct rotations alias when rot_i == rot_j (mod n). The comment also contradicted a sibling finding on scalar_inv: the batch path reverts on a zero product, so the true behavior is fail-closed, not a silent wrong evaluation. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: correct the comment claiming PAIRING_LHS = pi is 'paired against G2_BASE' in kzg::computations Block 6. How: comment-only. pi (PAIRING_LHS) is actually paired against NEG_S_G2_BASE: FinalPairing.yul calls ec_pairing(success, PAIRING_RHS_MPTR, PAIRING_LHS_MPTR) with the slots swapped, and ec_pairing pairs arg0 against G2_BASE and arg1 against NEG_S_G2_BASE. The comment now states this and ties it to the KZG identity e(final_com - v*G + x3*pi, [1]_2) = e(pi, [s]_2). Why: the executed code is correct, but the contradictory comment invited a maintainer to 'un-swap' the ec_pairing call (or swap the mcopy destinations) to make code match comment, which would flip the equation to e(pi,[1]) = e(RHS,[s]) and reject all honest proofs (fail-closed completeness break). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: add a TODO(structural) comment in construct_intermediate_sets_impl explaining that KZG query grouping and the duplicate-query eval-consistency assert compare EcPoint/Word handles by memory pointer, not by runtime value. How: comment-only. Records that this diverges from the midnight-proofs prover (groups by polynomial identity) and verifier (groups by commitment value), that it is safe today only because SolidityGenerator supports a single committed-instance column and all consumers depend only on first-appearance order, and that with >= 2 committed-instance columns sharing G1_IDENTITY_MPTR at one rotation the assert would panic at codegen. A value-based grouping is the real fix but is structural and out of scope here. Why: the auditor flagged both a codegen DoS (assert panics for >= 2 shared committed-instance columns) and a prover/verifier grouping divergence rooted in pointer identity; the fix is structural, so the invariant is documented to prevent a future refactor from silently dropping the eval-consistency check. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: promote the debug_assert_eq!(rotation, 0) in DataQuotientExpressionEnv::instance to a hard assert_eq!. How: replace debug_assert_eq! with assert_eq! and document why it must fail closed even in release builds. Why: the non-committed public-instance column is only interpolated at Rotation::cur(), so a rotated query must never reach this arm. debug_assert compiles out in release, so a rotated query slipping past the far-away constructor guard (builder/api.rs) would silently substitute instance(x) for instance(x*omega^k), generating a verifier whose quotient identity differs from the circuit (it could reject valid proofs and accept ones violating the intended constraint). A hard assert matches the fail-closed style of word_to_quotient_expr and ptr_to_quotient_mem in this file. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: replace the two `offset as u32` casts in ptr_to_quotient_mem with u32::try_from(...).expect(...). How: convert the isize Value offset via u32::try_from, which rejects both negative and > u32::MAX offsets, dropping the now-redundant assert!(offset >= 0) guards. Why: Value::Integer/Identifier carry isize offsets, so `offset as u32` silently wraps a planner offset above u32::MAX, and the generated VM bytecode would mload a wrapped, unrelated address, producing a verifier that computes the quotient numerator from the wrong memory word with no build-time diagnostic. Every other narrowing in this file already uses a checked conversion; this was the one silent cast. Practically unreachable today (EVM gas keeps frames far below 4GiB) but now fails loudly at generation time. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: assert every quotient memory pointer/token-offset is 32-byte word aligned in ptr_to_quotient_mem. How: at the single QuotientMem construction choke point, assert offset % WORD_BYTES == 0 for both the absolute Literal address and the TokenOffset offset. Why: the offline bytecode safety pass proves structural/stack safety and now bounds-checks const-table slots (validate_quotient_const_slots), but nothing validated the embedded memory operands. Every address the quotient VM reads is a word slot, so a non-word-aligned pointer indicates a truncated/mis-encoded address that would make the interpreter mload a straddling window and fold the wrong value into the numerator, flipping accept/reject with no build-time diagnostic. Guarding at ptr_to_quotient_mem covers all memory pointers in one place; all 151 lib tests pass, confirming the invariant. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: fail closed on a rotated non-committed instance query in instance_eval_at and expression_memory_ptr. How: hard assert_eq!(rotation, 0) in instance_eval_at's non-committed branch (the value resolver), and return None for rotation != 0 in expression_memory_ptr's Instance arm (which only detects consecutive direct memory pointers, so declining is safe). Why: both helpers resolve ANY rotation of a non-committed instance column to the single Rotation::cur() INSTANCE_EVAL word. Commit 5d7afe9 added the guard to the parallel-lookup path in quotient_numerator/vm/mod.rs but not to this file, so the only protection was the constructor-time guard in builder/api.rs. Any future entry point or refactor that lowers a circuit querying the public instance column at rotation +/-1 without going through SolidityGenerator::try_new would silently evaluate instance[rot] as instance[cur], generating a verifier that checks a different polynomial identity than the native Midfall verifier. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: replace rotation.abs() with rotation.unsigned_abs() in column_eval_var. How: one-line change; unsigned_abs returns u32 and is total over all i32. Why: rotation.abs() overflows for rotation == i32::MIN, panicking in debug and wrapping to a colliding/garbage variable name in release. This is only reachable if the constraint system supplied to codegen contains a query at Rotation(i32::MIN), so it is a build-time DoS on codegen inputs, not an on-chain issue, but unsigned_abs makes the conversion total. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: change the k == 0 empty-input-chunk branch of lookup_computations to emit the helper eval (h_eval) as the identity value instead of a hard-coded 0. How: replace the `let zero := 0` stub with out.push((lines, h_eval.to_string())). Why: the reference verifier (plonk/logup.rs) computes an empty chunk's helper constraint as helper_eval * (empty product = 1) - (empty sum = 0) = helper_eval, which enforces h == 0. Emitting 0 drops that binding while the accumulator constraint still folds the chunk's h_eval into sum_h, so a prover could set the unconstrained h freely and shift selector*sum_h to forge lookup balance. The branch is dead today (chunks are never empty), but emitting the reference-faithful value fails safe if a future chunking refactor ever reaches it. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: make lookup_computations' accumulator block tolerate an empty h_evals vector instead of indexing h_evals[0]. How: replace `let sum_h := h_evals[0]` + slice with a split_first(), emitting `let sum_h := 0` when there are no helper evals. Why: a LogUp argument with no input expressions produces zero helper chunks and an empty h_evals vector; the native verifier folds sum_helpers over the empty set to F::ZERO, but this emitter panicked with an index-out-of-bounds during code generation. This makes the Solidity generator match the native handling (and the structured native path in quotient.rs) instead of crashing at build time on a degenerate but legal circuit. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: make quotient_stack_words_for_build return at least one word. How: add .max(1) to the max_stack/native-scratch computation and document why. Why: every inline/native direct_quotient_block writes one eval-scratch word at eval_scratch_slot == quotient_stack_mptr, but the function could return 0 for a degenerate-but-valid VK whose gates all fit the inline prefix with no permutation sets, lookups, or VM items. That write is only in-bounds today because layout/memory.rs independently sizes the region as max(quotient_stack_len, MODEXP_FRAME_BYTES) for modexp-frame sharing, which is documented as a modexp concern, not as covering the quotient eval scratch. If that clamp is ever refactored away, the inline write would land one word past the registered region. Flooring here keeps the invariant with the code that emits the write; the modexp clamp still dominates real layouts, so this changes no current output (151 lib tests still pass). Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: change the k == 0 empty-input-chunk branch of structured_lookup_loop_block to fold h_eval instead of 0 into q_lookup_eval. How: replace `let q_lookup_eval := 0` with `let q_lookup_eval := <h_eval>`, mirroring the Yul emitter fix. Why: same latent constraint-drop as in quotient_numerator/yul_emit.rs. The native verifier computes helper_eval * 1 - 0 = helper_eval for an empty chunk, enforcing h == 0; emitting 0 leaves h unconstrained while the accumulator still folds this h_eval into sum_h, which would let a prover forge lookup balance if a future chunking refactor ever produced an empty chunk. Dead code today but now fails safe in both lookup emission paths. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: add assert_eq!(input_chunks.len(), h_evals.len()) before the chunk/helper-eval zip in both structured_lookup_loop_block (quotient.rs) and lookup_computations (quotient_numerator/yul_emit.rs). How: assert the two counts match immediately before zipping them. Why: both paths fold one helper identity per element of input_expression_chunks().zip(h_evals). If data.lookup_evals ever carried fewer helper evals than chunk_by_degree produces chunks (e.g. an EvalRead-schedule or protocol drift after a midnight_proofs update), zip would silently drop the excess chunks, removing helper constraints from the y-batched numerator and producing a verifier that accepts proofs with unconstrained h values (forged lookup membership). This is only guarded transitively today via the protocol lookup count check; a direct assert at each zip site fails loudly at codegen. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
What: add a doc comment explaining why native_identity_estimate_block uses a hard-coded selector_gap of Some(1) that differs from the real selector_fold.gap_for(identity) used at emission. How: documentation only; no behavior change. Why: an auditor flagged the gap mismatch between the estimate (Some(1)) and the real native emission in compact_quotient_computation_blocks (gap_for). The estimate feeds only the native-vs-VM gate selection heuristic and runs during native_gate_candidates, before the selector_fold plan exists (the plan is derived from the selection outcome), so the real gap is genuinely unavailable here and the proxy is intentional. The divergence can only shift which gates are promoted to native callbacks, never the correctness of the emitted verifier. Documenting this prevents a future maintainer from mistaking it for a bug or wiring in gap_for where the plan does not yet exist. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
…tch layout Re-render fixtures/moonlight-wrap from commit 3fb6d84 via the Moonlight wrap bench (origin/codex/wrap-bench-cherry-picks at 1940ea9, run in a temporary worktree) so the checked-in point_pair verifier uses the planner-registered LAGRANGE_DENOMS_MPTR region. Halo2VerifyingKey.sol is byte-identical to the previous render; calldata.bin carries the fresh proof (same 8516-byte layout). The trace-enabled run matched all 244 native Rust/Solidity trace points, differentially validating the repointed Lagrange block, and the fresh proof verified on revm Prague in 1,279,482 gas (+1,671 vs the previous fixture, memory-expansion cost of the relocated denominator run). Both ivc_accumulator_replay arms pass against the new sources. Co-Authored-By: Claude Opus 5 <[email protected]>
| - name: 'Install rust-toolchain.toml' | ||
| run: rustup toolchain install | ||
|
|
||
| - uses: Swatinem/rust-cache@v2 |
| - name: 'Install rust-toolchain.toml' | ||
| run: rustup toolchain install | ||
|
|
||
| - uses: Swatinem/rust-cache@v2 |
| - name: 'Install rust-toolchain.toml' | ||
| run: rustup toolchain install | ||
|
|
||
| - uses: Swatinem/rust-cache@v2 |
| - name: 'Install rust-toolchain.toml' | ||
| run: rustup toolchain install | ||
|
|
||
| - uses: Swatinem/rust-cache@v2 |
| - name: 'Install rust-toolchain.toml' | ||
| run: rustup toolchain install | ||
|
|
||
| - uses: Swatinem/rust-cache@v2 |
| - name: 'Install rust-toolchain.toml' | ||
| run: rustup toolchain install | ||
|
|
||
| - uses: Swatinem/rust-cache@v2 |
| - name: 'Install rust-toolchain.toml' | ||
| run: rustup toolchain install | ||
|
|
||
| - uses: Swatinem/rust-cache@v2 |
| - name: 'Install rust-toolchain.toml' | ||
| run: rustup toolchain install | ||
|
|
||
| - uses: Swatinem/rust-cache@v2 |
The replay suite mutated ~229 inputs per fixture but every one was caught by an independent range, packing, or framing check, so nothing exercised the verifier's most important negative property: a valid proof must not verify against different public inputs. If instance absorption regressed -- wrong order, count, endianness, or dropped entirely -- the whole suite would still have passed. The only binding test lived in tests/ivc_keccak_solidity.rs behind HALO2_SOLIDITY_RUN_IVC_BENCH=1 and a 300 MB SRS, so it never ran in CI. Add three CI-runnable cases, all replaying the frozen fixtures: - Instance binding: flip the low bit of each non-accumulator public input to the nearest other canonical value (15 cases across both fixtures -- acc_offset is 4 for ivc, 11 for moonlight-wrap). Nothing but the Fiat-Shamir challenges can reject these. - Identity flag on x with an honest y. write_encoded_identity always wrote the full canonical quadruple, so is_acc_encoded_identity short-circuited and load_acc_point's malformed-infinity check was never reached with x_is_id set. - Both coordinates carrying the codec's p-1 zero sentinel without the identity flag, which every packing and field check accepts, leaving the decoded_zero guard as the only separation between the codec's zero and EIP-2537's point at infinity. Both decode guards were previously covered only by string greps over the template in src/lowering/tests.rs. Because accumulator words are themselves absorbed into the transcript, any mutation there also perturbs the challenges, so a bare revert cannot distinguish "the guard fired" from "the transcript changed" -- deleting a guard would leave its test passing. Anchor the new assertions on the accepted run's gas instead: a decode guard fires before the transcript (~311k, under half of a full run) while a binding failure costs a complete verification (~1.287m). Measured separation is ~4x. Co-Authored-By: Claude Opus 5 <[email protected]>
…6-08) Two review-snapshot documents for the Solidity verifier code generator: - docs/architecture/ARCHITECTURE_REVIEW_2026-08.md: as-built architecture (layering, lowering pipeline, design decisions, generated runtime, trust/assurance table, cross-cutting contracts). - docs/plans/REDESIGN_PROPOSALS_2026-08.md: robustness/quality assessment with per-finding verified evidence, and prioritized P0-P3 redesign proposals (CI test selection, runtime FMP guard, Result threading, plan caching, operand-layout descriptor, statement IR, doc refresh). Co-Authored-By: Claude Fable 5 <[email protected]>
cargo +nightly fmt --all -- --check was failing in CI on unstable options (imports_granularity, wrap_comments) that stable rustfmt silently ignores locally. Co-Authored-By: Claude Sonnet 5 <[email protected]>
|
Here are the six most consequential issues, ranked by what actually went wrong. 1. The bitwise-
|
Review of the moonlight-wrap render (Halo2Verifier.sol 3861a403, Halo2VerifyingKey.sol ec94cabe) against midfall/proofs, covering architecture, conformance and security across crypto/soundness, EVM implementation, and operational/supply-chain lenses. Static analysis was cross-checked by execution: both contracts compiled with the pinned toolchain and run under revm 19 / SpecId::PRAGUE against fixtures/moonlight-wrap/calldata.bin plus 40 adversarial mutations. The fixture reproduces its documented 1,279,482 gas exactly. Verdict: sound as rendered. No critical or high soundness break in the contract; every phase checkable against the Rust reference conforms. 23 findings. Highest is H-1: NEG_S_G2_BASE is taken verbatim from the supplied SRS with no ceremony binding and no build-time consistency check, while every other control in the repo verifies only self-consistency and would pass on a substituted setup. Includes patches/P11_srs_binding.patch (proposed fix for H-1, needs cargo check) and patches/review-proposed-all.patch (full proposed set). Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01PhfUFgvirfyTF9eidjSMRe
…en precompile probes
Applies the four template-level fixes from the 2026-08 review that could
be validated end to end. See docs/audit/FIXES_APPLIED_2026-08.md for what
this does NOT fix.
P2 (M-1) Assert solc's stack-spill reservation stays below the generated
layout: `if gt(mload(0x40), TRANSCRIPT_MPTR) { revert }` in verifyProof,
and the equivalent in require_eip2537_precompiles, which runs in the
creation frame that compiled_memoryguard_does_not_overlap_generated_layout
does not inspect. The invariant is now enforced by the deployed bytecode
rather than by a generator-side test an integrator never runs.
P3 (M-1) Pin the pragma to 0.8.30. Not cosmetic: solc 0.8.24 at runs=1
emits a 29,567-byte runtime and 0.8.30 at runs=100000 emits 29,836 --
both exceed EIP-170 and halt with CreateContractSizeLimit. The floating
^0.8.24 advertised a compiler that cannot produce a deployable contract.
The spill reservation also moves with the version (0x8c0 on 0.8.24,
0x8e0 on 0.8.26+), which is why P2 is not theoretical.
P5 (M-3) Four known-answer probes for the two precompiles that decide
acceptance, which were previously tested with identity inputs only:
G1MSM([2]G)==2G; G1MSM must reject an on-curve point outside the r-order
subgroup (gas-bounded, since a rejecting precompile consumes everything
forwarded); e(G,G2)e(-G,G2)==1; and e(G,G2)e(G,G2)!=1, which catches a
0x0f that always returns 1. Addresses AUDIT.md TA-6, still un-triaged.
P6 (I-1) The NatSpec claimed generated scratch starts at 0x80 while
TRANSCRIPT_MPTR is 0x1000 -- the exact stale belief TA-5 identified as
the hazard, hardcoded so every render shipped it. Also corrects the
docs/MEMORY_LAYOUT.md path.
P13 (M-5) install_pinned_solc.sh now verifies the compiler by SHA-256 and
fails closed. ACTION REQUIRED: the hashes are TODO placeholders, so the
script refuses to install until they are filled from list.json.
Measured on the moonlight-wrap render (solc 0.8.30, revm 19 Prague):
runtime 21,286 -> 21,299 bytes (+13)
deploy gas 5,330,806 -> 5,766,768 (one-time; probes are constructor-only)
verify gas 1,279,482 -> 1,279,513 (+31)
40-case adversarial suite: outcomes identical
NOTE: these are template changes, so the committed fixtures under
fixtures/ and deployments/ are now stale relative to them and need a
re-render. tests/ivc_accumulator_replay.rs compiles the committed .sol
rather than fresh template output, so it will keep passing either way --
that is finding L-1.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01PhfUFgvirfyTF9eidjSMRe
… aggregation dep The midnight-aggregation dependency unconditionally enabled its truncated-challenges feature, which Cargo feature unification forced onto midnight-proofs in every build of this crate. The prover therefore always truncated challenges, while verifiers generated without this crate's own truncated-challenges feature did not mirror it — the exact "wrong setting on either side silently produces invalid pairings" hazard the feature's doc comment warns about. Every evm-only fixture test (rsa_signature, sha_preimage, hybrid_mt) had been failing with valid proofs reverting mid-PCS since the workspace merge that introduced the dependency. Truncation now flows exclusively through this crate's own truncated-challenges feature (which already forwards to midnight-proofs and midnight-aggregation), so both sides always agree. All fixture tests pass in both --features evm and --features evm,truncated-challenges. Co-Authored-By: Claude Fable 5 <[email protected]>
… MSM comment truth Three generator-side closures from the 2026-08 review (docs/audit/HALO2_VERIFIER_REVIEW_2026-08.md): M-2/P1 — bound the gas forwarded to precompiles. A rejecting EIP-2537 or modexp call consumes ALL gas supplied to the STATICCALL, so a single malformed proof point burned 63/64 of the transaction budget (measured 29.5M of a 30M limit). Every precompile call site — templates, PCS emitter, and constructor probes — now forwards the exact EIP-2537/EIP-2565 scheduled cost from the new layout::gas model, rendered as generated constants (G1ADD_GAS, G1MSM_GAS_1PAIR, G1MSM_GAS_SMOKE, PAIRING_GAS_2PAIR, MODEXP_GAS, ACC_RHS_MSM_GAS) or per-site literals with the pair count in a comment. Exact-schedule bounds are sufficient by construction per EIP-2537's DDoS-protection rationale; an upward repricing fork requires regenerating and redeploying, and the constructor smoke probes forward the same bounds so deployment onto a repriced chain fails fast. The two pinned quotientEvaluator calls keep gas() deliberately: regular calls refund unused gas on failure. This reverses commit 2b2bf49's gas() forwarding (old finding M-04) with generated spec formulas instead of the hand-tuned literals that motivated it. Verified by eip2537_gas_schedule_matches_spec_vectors, eip2537_calls_forward_exact_schedule_gas, and the revm test malformed_proof_point_rejects_with_bounded_gas (rejection now costs no more than honest verification). H-1 (code half) — bind the SRS G2 side to the commitment basis. Applies the review's P11 patch with its two compile blockers repaired (omega shadowing; undefined helper): generate_base_vk now asserts g2 is the canonical generator and that s_g2 pairs consistently with the tau reconstructed from g_lagrange (srs_tau_is_consistent, Pippenger MSM so 2^20-point production SRSes stay fast). This is the only build-time control that can catch a substituted SRS; every other control checks self-consistency. Tested by srs_tau_binding_accepts_honest_params_and_rejects_foreign_s_g2 and the gated midnight_srs_assets_bind_s_g2_to_lagrange_tau, which validates the real midnight-srs-2p19/2p20 assets. L-2/P7 — the emitted q_eval_set comments now report evaluation terms and commitment terms separately (identity-pinned commitments are skipped by the MSM but kept in the eval fold), a whole-pairs assert guards the final-MSM gas derivation, and the generated opcode summary is rendered from the same op_usage predicates that gate the interpreter cases so each artifact documents exactly its own opcodes (I-2). The stale pragma test now pins 0.8.30, completing commit 460a666's M-1 fix. Co-Authored-By: Claude Fable 5 <[email protected]>
…it-trail truth Closes the documentation and reproducibility findings of the 2026-08 review (M-5, I-1..I-4, TA triage; tracker updated in FIXES_APPLIED_2026-08.md): - install_pinned_solc.sh: real official SHA-256 pins for solc 0.8.30+commit.73712a01 (linux-amd64, macosx-amd64), verified by a live download; fixed a latent bash-3.2 incompatibility (declare -A) that broke the script on stock macOS; Rosetta mapping documented. - New scripts/record_srs_provenance.sh + REPRODUCIBLE_BUILDS.md "SRS Provenance": asset hashes recorded and verified byte-identical against the Midnight trusted-setup ceremony's official MIDNIGHT_SRS_CATALOG.md (github.com/midnightntwrk/midnight-trusted-setup) — the non-code half of H-1. Ceremony citation flagged for deployment-owner confirmation. - New scripts/generate_artifact_manifest.sh produces the REVIEW_PACKET §4 manifest rows per fixture dump; --optimize-runs added to every recorded flag list with the runs=100000-undeployable warning; the three provenance stamps labeled by what they index (canonical table in REPRODUCIBLE_BUILDS.md "Provenance Identities"). - src/codegen/* path sweep: living reviewer docs rewritten to the current src/lowering tree; AUDIT.md / AUDIT_FINDINGS.md carry a path-migration map instead of rewritten history; AUDIT_FINDINGS #4 now cites validators and tests that actually exist. - TA-1..TA-8 triaged with Status lines (TA-1 fixed — the quoted scalar-0 skip no longer exists; TA-5/TA-6 fixed by 460a666; TA-7's fail-closed zero-denominator semantics now specified in the spec). - Spec/mapping docs updated for the exact gas bounds; the spec's supported-target section now states the schedule requirement and the deploy-time fail-fast on repriced chains. Co-Authored-By: Claude Fable 5 <[email protected]>
…plates All tracked dumps under target/ lagged commit 460a666 (still pragma ^0.8.24, no P2 memory guard) and predated the exact gas bounds. Regenerated poseidon, rsa-signature, sha-preimage, and hybrid-mt via their fixture tests and ivc-keccak via scripts/run_ivc_bench.sh, all on the canonical evm,truncated-challenges profile. Every regenerated verifier was deployed and accepted a real proof under Prague revm as part of regeneration; the IVC final Keccak proof verified in 1,306,084 gas (checkpoint profile), and the recorded runtime hashes in REPRODUCIBLE_BUILDS.md were refreshed from that run. Not regenerated: moonlight-wrap-solidity-dump (rendered by an external Moonlight checkout; see fixtures/moonlight-wrap/README.md) and the committed fixtures/ replay sources, which intentionally pin the render-time artifacts they replay. Co-Authored-By: Claude Fable 5 <[email protected]>
…-drift findings Converts four "happens to be correct" invariants from the 2026-08 review into asserted properties (P8, P9, L-2 assert half, I-6), plus the I-2/I-3 comment corrections: - P9 (L-7): batch_invert scratch sits immediately below lagrange_denoms with zero slack, and an overflow corrupts denominator[0] silently (the modexp still succeeds). The planner now mirrors the template's run-length expression and refuses to plan a layout where the two formulas disagree or the scratch cannot hold n-2 prefix products plus the modexp frame. Region-shape test across instance/rotation shapes. - P8 (L-5): CHALLENGE_MPTR == THETA_MPTR is legal only for the empty window. The planner now asserts the challenge window covers the phase sum of user challenges the transcript parser actually squeezes and ends at or before theta; an undersized window refuses to plan. Positive and should-panic tests. - L-2: the MSM emitters omit identity-pinned commitments while keeping their evals; that is sound only because the identity pointer is reserved for the committed-instance column. queries() now asserts every identity-pinned query is a CommittedInstance source. - I-6: (1) the upstream verifier orders permutation z queries as (Cur, Next) pairs then reversed Last openings, while the generator interleaves per set -- the identical point-set structure is now re-derived under the upstream ordering and asserted, not assumed; (2) the fixed-eval proof section is column-sized upstream and query-sized here -- the two counts are now asserted equal at plan time so a rotated or repeated fixed query fails loudly instead of silently desynchronizing the proof stream. - I-2: the constructor MSM smoke comment no longer claims the probe pre-expands the runtime memory range (creation-frame memory is discarded; only input-size coverage carries over); the unused-looking `r` parameter of validate_public_accumulator is documented as render-shape dependent. - I-3: the impossible identity-flag/sentinel-mismatch branch in load_acc_point is now labeled unreachable-by-construction. Co-Authored-By: Claude Fable 5 <[email protected]>
… errors, alpha vk-binding Three runtime-template findings from the 2026-08 review: P12 (L-6) — the compact quotient VM's operands were raw memory pointers whose safety rested entirely on the VK codehash pin plus build-time validation. The interpreter now clamps every decoded memory-pointer operand (simple, fused, run-loop, and 7-limb forms, including vector bases and the modarith7 condition pointer) against the coarse [operand_lo, operand_hi] union of the plan's quotient read windows — computed by the same quotient_read_windows() the build-time validator uses, so the two layers cannot drift. Single-comparison wrap-around form, ~6 gas per operand. FOLD_SELECTOR's bucket index and y-power gap are clamped against their codegen-known table sizes, and ADD/MUL pops revert at the stack floor, which the terminal eq(q_sp, base) check cannot do for balanced underflows. Wide (u16) constant-table indexes get the same clamp; u8 forms stay build-time-validated with the rationale documented. P4 (L-3) — verifyProof could only ever revert with empty returndata, so malformed calldata, a swapped VK, a non-canonical scalar, a failed precompile, and a rejected proof were indistinguishable. The verify path now reverts with one of seven declared custom errors (BadCalldataShape, VkMismatch, NonCanonicalScalar, BadPointEncoding, PrecompileFailed, ProofRejected, QuotientProgramInvalid) via a fail(sel) helper writing the selector to legal 0x00 scratch; the quotient VM uses its own q_program_fail() since it renders into both contracts. Constructor smoke probes intentionally keep bare reverts (user-scoped decision). NatSpec now states the never-returns-false semantics and documents the exact calldatasize pin — ERC-2771 forwarders and calldata-appending relayers cannot call the verifier directly (L-4, keep-and-document decision). Selectors are pinned against keccak256 of the declared signatures by p4_error_selectors_match_declared_errors, and typed_errors_identify_rejection_classes decodes three classes end-to-end under revm. I-7 — the accumulator/KZG batching randomizer alpha hashed only the domain tag and four G1 points, binding the VK transitively. vk_digest now joins the preimage (verifier-local randomness, no prover interaction); the batch frame grew one word and its layout constants shifted accordingly. All fixture artifacts re-render, compile under pinned solc, and verify real proofs in revm; the bounded-gas invariant still holds. Co-Authored-By: Claude Fable 5 <[email protected]>
…tree digest gate P10 (L-8) — nothing in a deployed artifact identified the generator build: no commit, no feature profile, no SRS identity, and CBOR metadata is stripped. Every rendered verifier now carries `bytes32 public constant BUILD_ID`, a keccak over the build's identity components: the crate's enabled feature profile (exported by the new build.rs), the vk_digest, the expected VK runtime codehash (zero when embedded), an SRS fingerprint keccak(n || G2 || s_g2 || [tau]G1) — the tau commitment reuses the H-1 Pippenger helper, and n + tau IS the SRS identity — plus an optional 32-byte deployment provenance tag supplied via the new `RenderOptions::provenance` (Copy-friendly `[u8; 32]`, typically keccak of the generator commit + build context). Provenance is None by default so repository fixtures stay byte-stable across commits; deployment builds set it and publish the preimage components in the deployment record. Tested: renders are deterministic, and the provenance tag changes BUILD_ID and nothing else. P14 (L-1) — the replay tests compile committed pre-rendered sources, so a template edit without fixture regeneration kept every test green while deployable artifacts drifted (the review demonstrated a malicious Yul edit surviving the full default test suite). New feature-ungated tests/template_digest.rs pins a keccak of the sorted templates/ tree; any template edit now fails default CI until the fixtures are regenerated, the README stamps updated, and the digest re-pinned. The deeper L-1 legs (expression front-end certification, native-kernel differential) remain tracked future work. Co-Authored-By: Claude Fable 5 <[email protected]>
…d accepted-risk records L-9 — new docs/reference/DEPLOYMENT_AND_INCIDENT_RESPONSE.md states the immutability corollaries the review found undocumented, and turns the wrapper guidance into requirements: replaceable verifier address, wrapper-held pause, and chainid + wrapper-address + anti-replay binding in the proven statement (without which every accepted proof replays across all chains and deployments, forever). Includes the deployment record checklist and an incident/migration playbook keyed on BUILD_ID. verifyProof's NatSpec and AUDIT.md TA-8 now point at it. L-10 — the 128-bit truncated-challenge profile is recorded as an accepted risk: per-attempt PCS soundness ≈2^-107.6 (2^-108 with the x1.3585 sampling-bias factor), essentially tight, expected grinding work ≈2^108, with the truncate(x1^i)=0 edge case and the derivation's assumptions, against a stated 2^100 target security level and a deployment-owner sign-off line. I-5 — the Keccak transcript's missing domain separation is recorded as an accepted defence-in-depth gap in the spec and the risk register: tags cannot be added verifier-side without rejecting every midnight-proofs proof (same upstream-protocol class as M-4). FIXES_APPLIED gains the 2026-08-13 follow-up section closing the Low/Informational batch. Co-Authored-By: Claude Fable 5 <[email protected]>
… re-measure, pin digest Regenerates the five regenerable fixture dumps on the canonical evm,truncated-challenges profile after the L/I-batch template changes (exact gas bounds were already in; this adds the P12 operand clamps, P4 typed errors, I-7 alpha vk-binding, and BUILD_ID). Every regenerated verifier deployed and accepted a real proof under Prague revm; the full gated trace suite passes 212/212 including the native/Solidity trace differential, and both --features evm and evm,truncated-challenges modes stay green. Measured (IVC bench, checkpoint profile): 1,365,883 gas total; the P12 runtime operand clamps cost +59,951 gas, all inside the quotient-VM section (314,530 -> 374,481, +19.1% of that section, ~+4.6% of the transaction); every other section including PCS block 5 is unchanged. Runtime sizes: verifier 12,637 B (+183), evaluator 9,790 B (+238), VK unchanged. REPRODUCIBLE_BUILDS.md records the new hashes, the gas delta, and keeps the previous recordings for comparison. Also: the template-tree digest (P14) is pinned at the final template state; RenderOptions initializers in the test suites carry the new provenance field (None keeps dumps byte-stable); the moonlight-wrap fixture README now states its staleness relative to the hardened templates (regenerable only from the external Moonlight checkout). Co-Authored-By: Claude Fable 5 <[email protected]>
…ened codegen The committed replay fixtures were pinned pre-hardening snapshots, so the replay tests had stopped exercising current output (the exact situation L-1/P14 flagged). Both are now re-rendered at f894f75: - fixtures/moonlight-wrap: re-rendered via the external Moonlight checkout (origin/codex/wrap-bench-cherry-picks @ 1940ea9, scratch worktree with the documented local-path Cargo unification; the user's Moonlight clone was left untouched). The real wrap decider proof verifies on-chain in 1,338,272 gas, and the trace render matched all 244 native Rust/Solidity trace points against the hardened templates. - fixtures/ivc: refreshed from the canonical target/ivc-keccak-solidity-dump render (1,365,883 gas, checkpoint profile). Both fixture verifiers now carry the exact precompile gas bounds, typed errors, VM operand clamps, BUILD_ID, and the alpha vk-binding; the accumulator-mutation replay matrices pass against the regenerated artifacts, and the provenance stamps are updated. Co-Authored-By: Claude Fable 5 <[email protected]>
… fmt The three Solidity verifier jobs all failed at the "Install pinned solc" step with `install_pinned_solc.sh: Permission denied` — the script landed with mode 100644 while every other script in that directory is 100755. With the step failing, `SOLC` was exported empty, so the codegen tests died on `Command '' not found` and the two EVM jobs on "pinned solc unavailable". Also silence `clippy::too_many_arguments` on the two builders that gained an eighth argument in the hardening work (BUILD_ID provenance on `generate_verifier_from_plan`, operand bounds on `quotient_template_program`), matching the existing convention in this crate and across the workspace, and take the rustfmt wrap in test.rs. Verified locally against the pinned solc 0.8.30+commit.73712a01: `cargo +nightly fmt --all -- --check`, both clippy invocations, the non-EVM test job (212 lib tests + fixtures), the 8 `pbt_` real-EVM tests, the Poseidon fixture, and the native/Solidity trace equivalence test. Co-Authored-By: Claude Opus 5 <[email protected]>
…loy (MF-1)
`modexp_gas_word_frame()` returned the EIP-2565 price (1360) and its doc
comment claimed EIP-7883 "only reprices operands wider than 32 bytes". That
reading was wrong. EIP-7883 changes two independent things, and only one of
them is width-specific: the `2 * words^2` multiplication-complexity branch
applies to `max_length > 32`, but the removal of the `/ 3` divisor from
`multiplication_complexity * iteration_count` applies to every operand size.
The verifier's only frame (32/32/32, exponent FR_MODULUS - 2) is therefore
priced at 4064 on Osaka/Fusaka chains, not 1360.
The failure mode is not graceful. `staticcall` forwards a fixed amount, so on
a repriced chain the precompile runs out of gas inside the MANDATORY Lagrange
batch inversion and every `verifyProof` call reverts `PrecompileFailed` --
total, deterministic liveness loss on a contract that deployed cleanly.
And it would have deployed cleanly: `require_eip2537_precompiles()` probed
MCOPY, G1ADD, G1MSM (including the negative subgroup vector) and PAIRING, but
never 0x05 -- the one precompile the runtime cannot do without. The documented
"deployment onto an already-repriced chain fails fast" property simply did not
hold for modexp.
* layout::gas::modexp_gas_word_frame now returns the MAXIMUM over both live
schedules (EIP-2565 1360, EIP-7883 4080 at the generic 255-iteration
bound for a 32-byte exponent). Over-forwarding is free on success -- unused
gas is returned -- and only widens the burn of one failing call by the
difference; under-forwarding bricks the verifier.
* PrecompileSmoke.sol gains a modexp known-answer probe forwarding the same
MODEXP_GAS the runtime uses. The vector is the runtime's own operation,
Fermat inversion in Fr: 2^(FR_MODULUS - 2) checked as
mulmod(result, 2, FR_MODULUS) == 1, which needs no new rendered constant
and still rejects a precompile that returns zeros or echoes its input.
* modexp_gas_bound_covers_every_live_schedule derives both prices from their
EIP texts rather than asserting a magic number, so the next repricing
forces a conscious edit; constructor_probes_modexp_at_the_pinned_runtime_bound
pins the probe's shape and asserts every precompile the runtime calls is
now probed at its pinned bound.
Not done here, and deliberately not papered over:
* The replay harness cannot exercise this. Pinned revm 19 exposes
SpecId::OSAKA, but that variant is Prague+EOF in this version and its 0x05
handler is `berlin_run` (EIP-2565 pricing, `/ 3` intact), so switching the
spec would produce green tests that prove nothing. Real coverage needs a
revm bump to a version whose Osaka handler implements EIP-7883; evm.rs now
records that gap at the SpecId pin instead of leaving it implicit.
* The committed fixtures still carry the old bound and no modexp probe:
regenerating them needs the pinned solc, which was not available where this
fix was applied. Both fixture READMEs are marked STALE with what remains to
be done. They stay valid inputs for the replay tests, which exercise
verification logic rather than the modexp bound.
* Consequently the Sepolia moonlight-wrap deployment predates this fix and,
Sepolia having activated Fusaka, is expected to be bricked (fail-closed)
today. Worth one eth_call to confirm and record.
Verified: cargo test -p halo2_solidity_verifier --lib (171 passed, +2 new),
--test template_digest (digest re-pinned for the template edits), cargo fmt,
and clippy clean for this crate. EVM/fixture tests could not run without solc.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LkVhkWAGsAV72XGvp9wRMJ
…, split precompile faults from rejections (MF-2/3/4)
Three independent hardening findings from the same review pass. None changes
which proofs verify; all three change what happens when something is wrong.
MF-2 -- the memory-layout guard reverted bare.
`if gt(mload(0x40), TRANSCRIPT_MPTR) { revert(0, 0) }` is the ONLY on-chain
check that solc's stack-spill reservation has not grown into the generated
absolute layout, and the failure it catches is permanent: a fork that
recompiles at a different (version, optimiser-runs) pair can still fit
EIP-170, deploy cleanly, and then revert on every proof with empty returndata
-- indistinguishable from every other empty revert. It now reverts
MemoryLayoutViolated(), declared alongside the other typed errors, and the
constructor's twin guard is typed too, so the same build fault fails at
DEPLOYMENT rather than on first use. The smoke probes keep bare reverts: they
report a chain-capability failure, which the deployment transaction already
identifies.
MF-3 -- the quotient VM interpreter had three holes its terminal checks
cannot see. The program is VK-codehash-pinned so none is reachable on-chain
with a well-formed artifact; these are containment for a future generator
bug, mirroring on the deployed side what the reference VM already enforces at
build time (`stack.len() == 1` per identity, `const_at` bounds,
`identity_segment` rejecting a native marker mid-expression):
* FOLD_MAIN/FOLD_SELECTOR consumed `q_top` without checking `q_has_top`.
With the cached top dead, a fold silently re-folds a STALE value and both
terminal checks (`q_has_top == 0`, `q_sp == base`) still pass.
* Native callbacks RESET `q_sp` to the stack base. Operands spilled by a
preceding partial expression were discarded with no trace: an identity
could drop out of nu_y(x) while the program still ended balanced. They
now assert the stack is already empty instead of making it so.
* `q_sp` walked upward with no ceiling; every spill site now clamps it to
the planner-registered region, exposed as `quotient_stack_hi`.
Constant-table indexes: the u8 forms keep their documented exemption (drift
bounded to 0x1FE0 bytes, inside the VK-reserved region, covered by
validate_quotient_const_slots). The u16 forms do NOT share that argument --
an out-of-range u16 index reaches 0x1FFFE0 bytes past the table, well outside
anything pinned -- so those three sites are clamped against the rendered
table length.
MF-4 -- three paths conflated "the chain could not run this" with "this input
is bad". Both fail closed, but they are different incidents, and the typed
taxonomy exists precisely so a responder knows which one to investigate:
* ec_pairing reported ProofRejected even when the pairing staticcall itself
failed or returned the wrong size. A failed call is now PrecompileFailed;
only a pairing that RAN and returned != 1 rejects the proof.
* batch_invert returned a bare boolean, so a zero Lagrange denominator --
reachable only if the squeezed x lands on a domain point, i.e. a
transcript event with probability ~n/r -- surfaced as PrecompileFailed,
pointing responders at the node when the proof was the cause. It now
returns its failure cause and the Lagrange boundary branches on it.
* validate_public_accumulator did the same in reverse: a G1MSM staticcall
that could not run surfaced as BadPointEncoding. Also split.
A G1MSM *subgroup rejection* deliberately keeps surfacing as PrecompileFailed:
the precompile is the validator, and renaming that path would cost a
pre-check this verifier intentionally does not perform.
New pinning tests: memory_layout_guard_reverts_with_a_typed_selector,
quotient_vm_interpreter_fails_closed_on_malformed_programs (counts every
guard site, so a regenerated template that drops one fails),
precompile_faults_and_input_rejections_use_distinct_selectors. p4's selector
table gains MemoryLayoutViolated().
Verified: cargo test -p halo2_solidity_verifier --lib (174 passed, +3 new)
and --test template_digest (re-pinned), cargo fmt, clippy clean for this
crate. As in the MF-1 commit, EVM/fixture tests could not run without solc,
and the committed fixtures stay STALE pending regeneration.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LkVhkWAGsAV72XGvp9wRMJ
…nd the operational gaps it exposed
Closes the documentation half of the MF series. Two of the items I proposed
turned out to be wrong on inspection, and both are recorded as withdrawn --
a false-positive finding is worth writing down precisely so the next reviewer
does not "fix" it:
* MF-10 (native selector folds hardcode a y^1 gap) is a FALSE POSITIVE. All
three emission sites already use `selector_fold.gap_for(identity)`; the
fixed `Some(1)` lives only in `native_identity_estimate_block`, a size/gas
proxy for gate selection that is never emitted, and whose doc comment
already says "Do not 'fix' this to call gap_for here: the plan is
intentionally unavailable at this point" (it is derived from the selection
outcome). The literal `0x20` visible in a rendered artifact is the real
computed gap for that identity, not an assumption.
* The proposed vk_digest CI job would assert `x == x`. The payload word is
written directly from `self.vk.transcript_repr()` in `lowering/vk.rs` --
one expression over one in-memory VK, not two independent paths. The
residual it was meant to close (that vk_digest binds the SEMANTIC
constraint system) is not reachable that way; it stays covered by the
trace-replay tests and, off-transcript, by BUILD_ID, per the standing M-4
decision in §5.3.
Documentation added:
* DEPLOYMENT_AND_INCIDENT_RESPONSE.md gains two wrapper obligations and a
revert-triage section. W-4 (MF-5): a low-level staticcall to an address
with NO CODE returns success with empty returndata, so a wrapper pointed
at the wrong address or chain reads a missing verifier as a valid proof --
call through the typed interface or check returndatasize, and treat every
try/catch branch as a rejection (custom errors do not match
`catch Error(string)`). W-5 (MF-9): the canonical identity accumulator
(O, O) is well-formed and passes the pairing layer by construction, so
"this accumulator continues the expected fold" is a circuit/wrapper rule,
never a verifier one.
* §4 gains the post-fork triage entry for MF-1: exact-gas forwarding means
an upward repricing bricks the verifier outright, so the symptom is a
sudden TOTAL PrecompileFailed rate right after a fork activation, on
proofs that still verify natively. The pre-fix Sepolia deployment is the
worked example and should be assumed bricked pending one eth_call.
* §6 is a new selector-to-cause table (Caller / Deployment / Proof / Chain /
Build), which is what MF-4's split was for: the first incident question is
"chain, build, or proof?", and it should be answerable from four bytes.
* The spec's precompile section now lists the modexp probe and every
known-answer/negative probe the constructor actually runs, and explains
why MODEXP_GAS is the maximum over live schedules rather than one fork's
price.
* AUDIT_FINDINGS.md gains the dated MF-series table with dispositions,
including what these commits do NOT close: stale fixtures, the stale
Sepolia deployment, and the revm-19 harness gap.
Generator comments: MF-8 explains why set 0 carries one more eval term than
commitment terms (the omitted commitment is the identity, and that omission
is what forces the committed-instance eval to zero -- indirectly, via
batching, not by an equality check); MF-12 records the three properties that
together make the `memory-safe` annotation honest, so none is removed
individually.
Verified: cargo test --lib (174) and --test template_digest (re-pinned for
the two commented templates), fmt, clippy clean.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LkVhkWAGsAV72XGvp9wRMJ
…fix what it caught
The MF-1/2/3/4 commits were landed with only the pure-Rust tests: the pinned
solc binary is not downloadable from the environment they were applied in
(binaries.soliditylang.org unreachable), so every test that compiles Solidity
self-skipped. The same compiler COMMIT is published to npm as a WASM build, so
the suite can in fact be run behind a thin shim exposing the native CLI surface
`src/evm.rs` drives (`--bin`/`--bin-runtime`, `--via-ir`, `--no-cbor-metadata`,
stdin); it reports `0.8.30+commit.73712a01`, the pinned identity.
Running it immediately caught a real regression in MF-4, which the pure-Rust
tests structurally could not see:
`BatchInvertHarness` is a hand-written Yul contract built from a STRING at
runtime and compiled by solc, not by rustc. MF-4 gave `batch_invert` a second
return value, and the harness still destructured one -- so the extracted
helper no longer compiled. `cargo test --lib` stayed green throughout because
nothing in that path type-checks the harness. Fixed by destructuring both
values and discarding the cause, which is all this harness asserts on.
Also strengthened one assertion. `verifier_rejects_when_x_is_forced_to_domain_root`
used `assert_solidity_rejects`, which only checks THAT the call reverted. That
is the single live path reaching the zero-Lagrange-denominator branch, so
without a selector check MF-4's re-route was pinned in template text and never
observed executing. It now asserts the revert payload is exactly
`ProofRejected()` -- the transcript event it is -- rather than the
`PrecompileFailed()` it used to report, which sends an incident responder to
inspect the node when the proof was the cause.
Result: 217 passed / 0 failed under `--features evm,rust-verifier-trace`,
including native-Rust-vs-Solidity trace equivalence, the transcript
differential fuzzer, the constructor precompile-rejection tests,
`typed_errors_identify_rejection_classes`, and
`compiled_verifier_runtime_fits_the_eip170_limit` -- so the added modexp probe
and VM guards do not breach the code-size limit, which was the main open risk
from the earlier commits.
Two honest caveats, now recorded in AUDIT_FINDINGS.md rather than left
implicit:
* The WASM build is the same compiler commit, but this repository's
reproducibility claim pins the NATIVE binary's sha256. Nothing compiled
this way may be deployed or used to pin a hash, and the code-size result
should be re-confirmed with the pinned binary before release.
* The Poseidon-gated tests need an SRS asset whose host is also unreachable
here, so they ran against a locally generated k=6 substitute (fresh, known
tau). Those tests regenerate the VK and proofs in-process from whatever SRS
they load, so they are self-consistent -- but this is why the committed
fixtures still cannot be regenerated, and their STALE notes are corrected
to say so: the blocker is the SRS and a Moonlight checkout, not solc.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LkVhkWAGsAV72XGvp9wRMJ
…ed bound (MF-1) The MF-1 commit added a constructor modexp probe and a test pinning its SHAPE in the rendered template text. That is not the property that matters. What matters is that the probe REJECTS: an artifact whose MODEXP_GAS sits below the chain's price must fail to deploy, because the alternative -- what the original bug did -- is deploying cleanly and reverting every proof forever. `constructor_rejects_a_modexp_bound_below_the_chain_price` renders the verifier, lowers MODEXP_GAS to one gas under what this harness's revm charges for the 32/32/32 frame, and requires construction to fail. It carries a positive control: the same fixture, compiler, and EVM, unmutated, must construct successfully. Without that control a rejection caused by anything else in the pipeline would read as the probe working. The mutation moves the BOUND rather than the schedule because the schedule cannot be moved here: pinned revm 19 exposes SpecId::OSAKA, but that variant is Prague+EOF and its 0x05 handler is still `berlin_run`, so no spec setting in this dependency reproduces EIP-7883. Lowering the bound produces the identical runtime condition -- forwarded gas below the precompile's price -- which is what the shipped 1360 did against EIP-7883's 4064. This closes the last acceptance criterion from the remediation plan that was previously recorded as unreachable. Full EVM-gated suite: 218 passed / 0 failed under `--features evm,rust-verifier-trace`. The pre-rendered fixture replays (`tests/ivc_accumulator_replay.rs`, both accumulator arms) also pass, so the committed STALE artifacts still exercise their decoder paths. Not reachable from here, and left as the standing action in DEPLOYMENT_AND_INCIDENT_RESPONSE.md §4: confirming the pre-fix Sepolia deployment is bricked. Public Sepolia RPC endpoints are unreachable from this environment, so the eth_call remains for someone with network access. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01LkVhkWAGsAV72XGvp9wRMJ
…measure the WASM/native compiler gap Two corrections, one of them to a claim I made in the MF-1 commit message and propagated into the incident-response playbook. **The Sepolia deployment is not bricked.** I asserted it should be assumed so post-Fusaka, reasoning from the deployed artifact carrying `MODEXP_GAS = 1360`. It carries no such constant. That deployment predates the exact-gas hardening entirely: all 17 of its staticcalls forward `gas()`, the three modexp sites included, so an upward repricing is absorbed from the caller's remaining gas rather than being fatal. The recorded source is genuinely what is deployed -- recompiling it reproduces `deployment.json`'s `runtimeCodeHash` byte-for-byte apart from the two immutable AUTHORIZED_VK slots -- so this is settled by reading it, and the `eth_call` the playbook asked for is unnecessary. Worth stating plainly because it is easy to get backwards, and I did: EXACT-GAS FORWARDING IS WHAT CREATES REPRICING FRAGILITY. The older `gas()`-forwarding renders survive any repricing but carry the DoS that exact bounds were introduced to close (M-2); the current renders bound the DoS and inherit fragility to repricing, which is precisely why MF-1's constructor probe matters. Neither property is free. The exposed population is artifacts with exact bounds rendered before MF-1 -- not the Sepolia deployment. **The WASM/native compiler gap is now measured, not assumed.** The previous commit noted that the suite ran against solc-js and that the pinned claim is on the native binary's sha256. Comparing them directly on a real 21,161-byte via-IR verifier: exactly 40 bytes differ, and they are the two 20-byte immutable slots a fresh compile leaves zeroed. All 21,121 other bytes match. So the EIP-170 code-size result carries native weight; re-confirming on a pinned-binary host is release hygiene rather than an open risk. Not done, and not doable from this environment: regenerating the committed fixtures still needs the SRS asset (host unreachable) and, for moonlight-wrap, a Moonlight checkout on the branch its README names. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01LkVhkWAGsAV72XGvp9wRMJ
…meter analysis
Removes the 2^108 grinding figure and everything reporting it, at the
maintainer's request:
* DEPLOYMENT_AND_INCIDENT_RESPONSE.md §5.1, the accepted-risk record with
its per-attempt probabilities, target level, and sign-off line. The
surviving §5.2/§5.3 are renumbered to §5.1/§5.2 so no gap advertises the
removal, and the two in-repo cross-references (AUDIT_FINDINGS.md, the
spec's transcript section) are repointed to match.
* HALO2_VERIFIER_REVIEW_2026-08.md: the L-10 finding, its summary-table row,
and recommendation 4.3.6.
* FIXES_APPLIED_2026-08.md: the L-10 open-item row and the two references
that carried the residual figures.
* AUDIT_FINDINGS.md: the MF-6 and MF-7 dispositions no longer cite the
removed section. MF-7 keeps the substantive reason the profile is accepted
-- it mirrors the midnight-proofs prover, so a verifier-side change alone
is impossible -- which does not depend on the figure.
The mechanism itself is untouched and still fully specified: the spec
continues to define the 128-bit mask on x3 and the truncated x1/x4 powers
(§7 step 23, §12, and the reimplementation checklist), and the
`truncated-challenges` feature comment in Cargo.toml still warns that prover
and verifier must agree. What is gone is the quantification of what the mask
costs, not the description of what it does.
Two consequences worth stating plainly, since this is an audit trail:
* No document now records that truncation was analysed or signed off. A
future deployment owner reading §5 sees two accepted risks where there
were three, with nothing indicating a third was assessed and removed.
* AUDIT.md's mod-r sampling-bias derivation (the 1.3585 factor) is left
intact. It is a separate Informational finding about challenge sampling
and mentions truncation only in passing, without any soundness figure.
Verified: cargo test --lib (174) and --test template_digest still green; every
§5.x cross-reference in the repository resolves to an existing heading.
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01LkVhkWAGsAV72XGvp9wRMJ
What this PR does
A correctness, fail-closed and assurance pass over
proofs/solidity-verifier(87 commits, 76 files, +18,693 / −4,056), plus three pieces of new machinery: render-time self-certification of the emitted quotient-VM bytecode, an adversarial EVM replay suite for the public-accumulator decode path, and root-level CI that actually runs the crate's tests.Calibration up front, because the commit count invites the wrong reading: nothing here is a soundness fix on a shipped artifact. No commit in this range changes whether any released or fixture verifier accepts an invalid proof. The fix set breaks down as codegen paths that could have emitted a wrong contract for a configuration we do not ship, a memory-layout rebase that removes a latent compiler-dependency rather than a known miscompilation, and a long tail of fail-closed hardening on paths that were verified unreachable before the change. Those three categories are kept separate below on purpose.
The load-bearing content of the PR is arguably the assurance work: the quotient lowering's shape recognizers were previously validated only indirectly by fixture trace differentials, and the accumulator decoder had no executing coverage at all — the tests that looked like coverage were
verifier_template.contains("…")greps that never rendered or ran. That is why the always-false Yul guard below survived review.Codegen paths that could have emitted a wrong verifier
Build-time defects: the generator produced, or could produce, bad output with no failure. No shipped fixture, example or deployed artifact is affected by any of them.
VK_MPTR. Reachable only when the deferred phases add nine or more advice commitments and the instance vector is large enough for the initial run to dominate. The run is now sized across every advice phase up to the first challenge-bearing one (a01b1ea).g1_to_u256s/g2_to_u256semitted all-zero words — the EIP-2537 infinity encoding — whenevercoordinates()returnedNone, which in midnight-curves also covers off-curve points. Had a malformed point reached them,NEG_S_G2 = infwould makee(pi, O) = 1and collapse the KZG check tofinal_com − v·G + x3·pi = O, which a prover can satisfy from public transcript data sincex3is squeezed beforepiis read. The zero path is now gated onis_identity(); anything else aborts codegen (b24be9e). Worth knowing for review: the new assertion is unreachable in this build, because blst'son_curveORs invec_is_zero, so the genuine identity returnsSome((0,0))and never took theNonearm. This is hardening against a future raw/FFI constructor or a curve backend without that special case, not a fix for a reachable path.generate_base_vkhard-codedG1Affine::generator()asG1_BASEwhile takingg2/s_g2from the deployer's params, so a params file whose SRS base isc·Gbuilt cleanly.G1_BASE_MPTRis consumed in exactly one place — the−v·Gterm of the final KZG linearization — so the pairing check degenerates tov·(c−1) = 0: forc ≠ 1every honest proof reverts. The consequence is a bricked verifier discoverable only after deployment, not acceptance of anything false.sum(g_lagrange)is now asserted equal to the generator, which is the right check because the Lagrange basis satisfiesΣ L_i(X) = 1, making that sum exactly the crate-privateparams.g[0]the codegen cannot read directly (fc42518). Note: this commit's message claims the emitted verifier would diverge from the native verifier. It would not —proofs/src/poly/kzg/mod.rshard-codes-E::G1::generator()in the same position, so a rescaled SRS breaks both identically.X_N_MPTR(theta word 26) with no bound against the first word live at Lagrange time, which isQ_EVAL_CPTR_MPTRat theta word 201. Atnum_instances + |rotation_last| ≥ 175the run overwrote the q_eval calldata cursor; codegen and solc both succeeded, and the deployed contract then computed garbage q_evals and reverted on every proof. Fail-closed (the clobbered words are consumed downstream of the KZG check, and the overwritten values are transcript-derived, not attacker-chosen), but an undetectable-until-deployment brick. adc589f adds the codegen-time bound; 6753679 makeslagrange_denomsa planner-registered region allocated afterbatch_invert_scratch, soMemoryMap::validateenforces disjointness structurally and the ~165-instance cliff disappears.num_instancesimplying an accumulator fixed-base scalar tail larger than the VK can supply bases for rendered trailing bases pointing past the fixed-commitment region; now a typedGeneratorError::AccumulatorFixedBaseTailMismatch(54907b5).Generated memory layout moved off solc's via-IR spill window
The generated layout started at
0x80, on top of the spill reservation solc emits for theassembly ("memory-safe")block the templates depend on — the deployed Sepolia runtime opens withmstore(0x40, 0x08e0), i.e. solc reserved[0x80, 0x8e0)whileTRANSCRIPT_MPTRsat at0x80. Dropping the annotation was tried and does not compile (stack too deep), so the fix removes the consequence. The overlap was real but never produced an incorrect verification result — under the pinned solc the overlapping slots are dead where the verifier writes. This removes a latent recompilation hazard, and it is the reason for the large regenerated-artifact diff.LOW_MEMORY_SCRATCH_STARTmoves0x80→0x1000, above the largest observed reservation, and a new test reads the free-memory-pointer prologue back out of the compiled runtime so the two cannot re-converge.VK_CONSTRUCTOR_PAYLOAD_STARTis deliberately decoupled and stays at0x80(6333673).0x100/0x320→0x1000/0x1220, and the layout validator now rejects any generated region below the layout base (60f842b).FINAL_PAIRING_SCRATCH_STARThadec_pairing's input frame sharing theACC_LHSy_lo word inside the alpha keccak preimage — correct only becauseFinalPairing.yulhashes before it calls the precompile. It is now derived asPAIRING_BATCH_PTR + PAIRING_BATCH_HASH_BYTESwith a disjointness assertion (a1bb2c4).X_N_MPTRover theta words 27–51 (the deployed artifact spilled four words past the cap). It is a registeredlagrange_denomsregion, soMemoryMap::validateenforces disjointness and 200/500-instance layouts build instead of being refused (6753679, 75f9f99).Phase(PcsFixed)and so never co-live with anything, selector accumulators claimed a single phase although quotient evaluation writes them,MemoryPhasewas declared out of execution order, the trace-only linearization MSM band was not a region at all, and the theta-window ends were missing from the trace-log bound. All fixed with no address or output change (4f272f0, bb6f463, 914c61c, ed547ae, bafd8d7).Fixes in the rendered on-chain templates
Only the first was a live defect in shipped code, and it is the most serious fix in this PR. The rest were each verified fail-closed before the fix, via a later range check, an earlier revert, or a conformant precompile; they harden the on-chain path rather than close exploitable holes.
load_acc_coord_shiftedgated the identity-flag strip onand(iszero(div(i, limbs_per_word)), first_adjust), and Yulandis bitwise.iszero(…)is 0 or 1 andfirst_adjustis a radix base (2^56), so they share no bit: the guard was false on every iteration of every call andpacked := sub(packed, first_adjust)never executed. The consequence is not that the identity probe went dead — it went inverted. The probe compared the un-stripped decode againstp−1, so the flagged encoding (BLS_P_MINUS_ONE_PACKED_0_WITH_ID_FLAG) no longer matched and fell through to the base-field range check, while the codec's unflagged(p−1, p−1)sentinel pair decoded to exactlyp−1, setis_id := 1, and was accepted by the decoder as EIP-2537 infinity withok = 1— writing four zero words toACC_LHS_MPTR/ACC_RHS_MPTR. That is precisely the non-canonical zero-like encoding thedecoded_zeroguard exists to reject, and it sits in the mutually exclusiveiszero(is_id)arm, so it was skipped. The decode-stage canonicality barrier genuinely failed open. What actually rejected such calldata was downstream and incidental: the accumulator words are absorbed into Fiat-Shamir, so substituting them perturbs every challenge and the final KZG pairing fails. Confirmed empirically — with the pre-fix guard restored in the fixture contracts, the replay case "LHS accumulator decodes to zero without the identity flag" reverts only after 1,286,473 gas (a complete verification, then a pairing failure) against 1,286,528 for a fully accepted run; post-fix the same input reverts early, under the 643k decode-guard ceiling. No forgery was reachable, because it would need a valid outer proof whose committed accumulator instances are the(p−1, p−1)sentinel pair — a G1 point withx = y = 0, which the circuit's foreign-point codec cannot emit. But that is circuit-side reasoning plus transcript binding doing the work, which is exactly what the on-chain barrier exists to avoid relying on. The fix gates on the word index alone (72bc1b2). Note: this commit's own message claimsis_idwas always 0 and thatload_acc_point'sif is_idbranch was dead. Both claims are wrong and were disproved by executing the pre-fix template; the branch was reachable.ec_pairingfolded the precompile result asand(ret, mload(scratch)), testing only the low bit and accepting any odd result word; nowand(ret, eq(mload(scratch), 1))(9e714b7).G1ADD(G, G) == 2Gagainst constants computed from the curve library at render time (896fd04).validate_public_accumulatorread carried and fixed-base tail scalars from calldata with no< rcheck of its own, so the negated-base path computedmod(r − s, r)fors ≥ r. The three read sites now enforce it locally (68adb90).batch_invertandscalar_invrejected only the literal word 0 beforemodexp(x, r−2, r), which returns 0 for anyx ≡ 0 mod r, and the multi-element path fed raw words intomulmod, so accept/reject depended on batch length. All paths now fail closed identically, with a revm test that reproduces the old acceptance against the unfixed template (d870dd1, aa62d19).modexpinbatch_invertsetret := 0and continued, reading back the stale EIP-198 frame header and overwriting the input range with garbage; it now returns immediately (2e49d03).ec_pairing's entry guard was the one exit returning a zero flag instead of reverting, andTraceReturn.yulstored1without readingsuccess; both now revert (636c7ec).q_pc == q_endandiszero(q_has_top), which both pass for aFOLDrun with more than one operand live, and the selector y-power table's slot 0 was reserved but never written. Aq_sp == stack_mptrcheck and onemstoreclose both (b680bf2, 3f95c05).New: the emitted quotient-VM bytecode certifies itself at render time
The quotient lowering's shape recognizers pattern-match algebraic forms out of an expression tree. They are now a checked optimization: certification runs from
LoweringPlan::newandvalidate_generator_invariants, so a recognizer bug aborts the render instead of being pinned into a codehash-pinned VK.vm/reference.rs(+550) is a second, independent interpreter of the bytecode ABI — everyQ_OP_*, including the LIN7 / BILIN7 / MODARITH7 / AFFINE_SUM / RUN_* compaction forms — over a Keccak-derived pseudorandom memory oracle.vm/certify.rs(+365) executes each identity's finalized bytecode against direct evaluation of theQuotientExprtree it was lowered from, then repeats the comparison against a second build with limb superinstructions disabled (0b27485).derive_certify_seedfrom both builds' bytes and const tables, the oracle expression trees and the VK payload, so a miscompile cannot be crafted against a fixed sample point. The memoryguard test also extends to the plain and trace quotient evaluators and each accumulator variant, since every via-IR compilation unit gets its own spill reservation (0cd1bdb).LoweringPlan::quotient_read_model()publishes the windows the verifier has populated by VM time andvalidate_quotient_mem_ptrsrejects any literal, u16, token, limb or pairwise-span address that is misaligned or outside them — with a total walker pinned by a test, so a new pointer-bearing opcode cannot silently lose coverage (20766cb).QuotientProgramBuilder::finishbounds-checks every const-table index the finalized stream decodes, so an encoder regression cannot ship a verifier loading a trailing VK word as a gate coefficient (8db58be).x := …reassignments, so a stale literal could be baked into aq_limb7call while the Yul multiplied by a runtime value (c80cbc8).New: adversarial replay of the accumulator decode path, runnable in CI
Fixtures were re-based from proving inputs to rendered outputs so this can run on a stock runner: a verifier cannot be rendered from a VK without the full SRS (
SolidityGeneratorconsumesparams.g_lagrange()), so a blob-based replay could never have run in CI.tests/ivc_accumulator_replay.rs(+689) deploys shipped rendered contracts plus matching calldata under revm in about two seconds, asserts the fixture proof is accepted as a baseline, then asserts rejection of non-canonical limb packing, malformed coordinates and the canonical encoded point at infinity. Two gas-based helpers attribute each revert to a stage, so a deleted guard cannot leave the test green for the wrong reason (70dd1a8).point_pairencoding — theexpected_acc_has_carried_scalars = falsearms the deployed Moonlight wrap verifier uses, previously only ever compiled — and both fixtures gain framing / truncation / selector / ABI-head / length mutations, every proof commitment against off-curve, base-modulus and non-canonical-padding G1 vectors, and every eval scalar and non-accumulator instance set to the Fr modulus: roughly 230 EVM calls per fixture (6f78686).load_acc_point's malformed-infinity anddecoded_zeroguards, which had only template greps (07d9a9d).has_accumulator,acc_offsetandnum_acc_limbsare parsed back out of the rendered VK payload comments, the encoding kind is recovered from the payload width, the G1 commitment run is discovered from the EIP-2537 padding signature, and the modulus/infinity sentinels are read out of the verifier source under test (70dd1a8, 6f78686).accumulator_verifier_variants_compile_with_pinned_solcrenders three accumulator shapes against the Poseidon VK and compiles both contracts under the pinned solc (5912061).CI: the suite now gates PRs, and gates fail loudly
proofs/solidity-verifier/.github/workflows/ci.yml. GitHub Actions only reads workflows from the repository root, so once the crate was vendored into midfall that file never executed and nothing in the suite gated a merge. It is deleted (−153); rootci.yamlgains three fast per-PR jobs — unit /--all-targets, real-EVM property tests, and the native↔Solidity trace differential — while the two ~90-minute IVC jobs and the release bytecode size/hash gate move to a newsolidity_verifier_bench.ymlon push-to-main, a weekly cron andworkflow_dispatch(ffd39db).HALO2_SOLIDITY_RUN_EVM_TESTS=1set, a missing SRS or unpinned solc printed a skip line and returned green while compiling no Solidity and verifying no proof — 28 of 34 end-to-end tests returned early, which is how the tracked dumps drifted unnoticed. Prerequisites are now assertion failures carrying fetch instructions once the gate is opted into; not opting in stays a quiet skip (bea362e).Halo2VerifyingKeywas size-checked, and the revm harness deliberately setslimit_contract_code_size = usize::MAX, so an over-limit verifier passed the entire suite and then failed to deploy.compiled_verifier_runtime_fits_the_eip170_limitnow compiles the embedded, separate, quotient and VK renders and checks each runtime against 24,576 bytes, and the hand-rolled VK data-contract blob gets a codegen-time limit check (a30fef4, 69889be).--all-featureswas a broken configuration: every Poseidon and shape-fuzz test failed withSrsError(64, 252)because the test SRS sized its monomial basis without the prover'ssingle-h-commitmentsetting. The helpers now extend the basis tok + ceil(log2(cs_degree − 1))(0aab0cf).ConstraintSystemMeta::newandProtocolPlan::from_constraint_systemmoved behind#[cfg(test)](743d5da).Fail-closed hardening on paths that are not reachable today
Listed separately on purpose: none of these fixes a defect that can occur in a current build. They remove the possibility of a future one shipping silently.
kzg::memory_requirements/kzg::computationsreturned an empty result forn_sets == 0. Block 6 is the only code in the generator that writesPAIRING_LHS_MPTR/PAIRING_RHS_MPTR, those are fixed theta slots never aliased by scratch, and four zero words are exactly the EIP-2537 encoding of the point at infinity — so the emitted verifier would computee(O, [1]₂)·e(O, [−s]₂) = 1and return true for any calldata that survives transcript parsing, with no opening ever checked. This is the most severe potential consequence in the PR, and it is unreachable twice over:ProtocolPlan::try_from_constraint_systemunconditionally pushes the Linearization query andvalidaterejects any schedule not ending with it (son_sets ≥ 1), and independentlyvalidate_absorbed_g1_precompile_coveragewould report every absorbed commitment as unconsumed. Both early returns are nowassert!— which, unlikedebug_assert!, survives release builds (700af6d).meta.permutation_columns,vk.fixed_comms.len()againstmeta.num_fixedsbefore derivingpermutation_comm_mptr, and the accumulator fixed-base/scalar counts — promoted fromdebug_assert_eq!, so it no longer compiles out in release (79dd945, a9032f8, 01f4549).k == 0branches emitted0instead ofh_eval, dropping theh == 0binding while the accumulator still folded that eval intosum_h. The zips now assert equal lengths and both branches matchplonk/logup.rs; an emptyh_evalsvector no longer panics (63be029, e5f77f8, d67a2e9, 33e183e).Rotation::curINSTANCE_EVALword in both the VM and the Yul emitter;instance_eval_at/expression_memory_ptrnow fail closed, with reachability still blocked by thetry_newconstructor check plus a new regression test (098f6a1, 2286dff, 5d7afe9).Value::as_usizeasserts non-negative before casting,Displaypanics on a negative concrete offset instead of renderingsub(0, N), quotient memory pointers use checkedu32::try_fromand assert 32-byte alignment at the single construction choke point, andcolumn_eval_varusesunsigned_abssoRotation(i32::MIN)cannot produce a non-identifier name (b8b4c39, 9d7081b, 16f43b5, 56f20be, d149f96).usizearithmetic on externally-derived counts, so a wrapped count would produce a section map whoseextcodecopyranges alias earlier sections; both sites now return a layout error (b2e99e8).mulmodper unit step across the whole span, so a large rotation passed every capacity check and produced an undeployable over-24 KB verifier with no diagnostic. Codegen caps the span at 4,096 with an explicit remedy, and the quotient stack scratch is floored at one word (6e14bfe, 0b56186).Caller-facing API and error surface
GeneratorErrorandRepackErrorare public and not#[non_exhaustive], so the new variants break exhaustive matches downstream.SolidityGenerator::try_new— documented as the panic-free constructor — calledplan.validate().unwrap_or_else(|err| panic!(…)), turning everyProtocolPlan::validatearm (most reachably an advice column declared but never queried) into a panic.ConstraintSystemMeta::try_newandProtocolPlan::try_from_constraint_systemnow propagate asGeneratorError::Planning { stage: "constraint system" }(58824e8).AccumulatorFixedBaseTailMismatchandTooManyInstances(54907b5, 75f9f99).repack_proofsubgroup-checked G1 commitments but passed every eval and q_eval word through unchecked, returningOkfor scalars the on-chain parser is guaranteed to revert on; nowRepackError::NonCanonicalScalar { offset, bytes_hex }(b4319da).LightAggregator::verifygains the<T::Hash as TranscriptHash>::Input: TranscriptInputBytesbound thatplonk::preparealready grew upstream; without itcargo check --workspacefailed before reaching anything else. Satisfied by both concrete transcript input types, so no call site changed (2938a40).lib.rsexports two newfeature = "evm"test helpers,compile_solidity_runtimeandruntime_free_memory_pointer_init(6333673, 0cd1bdb).TranscriptBufferLayout::from_proof_layoutgains aphase_challenge_counts: &[usize]argument, andQuotientProgramBuilder::with_limb_vm_opsloses its#[cfg(test)]gate because the dual-build certifier needs it in production (a01b1ea, 0b27485).Docs, comments and formatting
No behaviour change: corrected comments on the KZG batch-inversion non-zero argument, the deliberately swapped pairing orientation, the pointer-identity query-grouping invariant, the proxy
selector_gap, the pairing-batch domain tag and two field docs (36fc616, 66c32f9, 688efde, fd3842c, 822980e, 55d548b); named-constant substitutions with identical values (4352a71, 14cb772); a numerically wrongAUDIT.mdchallenge-bias item corrected, stale paths fixed in the lowering spec, the Sepolia deployment record annotated, and two new architecture/redesign documents (3f89a98, 277c096, 29b4eb9, 4ae8053); rustfmt, intra-doc links and two CHANGELOG entries (11928d1, bee1833, ac9604d).How to test this PR
Pinned solc, required by every Solidity job:
Per-PR gate
test-solidity-verifier— includes the new certification, pointer-validator and accumulator-replay tests. Needs solc; needs no SRS:cargo test -p halo2_solidity_verifier --all-features --all-targets -- --nocaptureJust the new adversarial accumulator replay (both fixtures, ~2 s each):
cargo test -p halo2_solidity_verifier --all-features --test ivc_accumulator_replay -- --nocaptureSRS for the EVM and trace jobs (CI restores this from the
fixed-srs-cache):mkdir -p zk_stdlib/examples/assets && curl -L -o zk_stdlib/examples/assets/bls_filecoin_2p19 https://midnight-s3-fileshare-dev-eu-west-1.s3.eu-west-1.amazonaws.com/bls_filecoin_2p19Per-PR gate
test-solidity-verifier-evm— adversarial property tests, then the end-to-end Poseidon fixture (real proof → render → solc → Prague revm):HALO2_SOLIDITY_RUN_EVM_TESTS=1 cargo test -p halo2_solidity_verifier --release --all-features pbt_ -- --nocaptureHALO2_SOLIDITY_RUN_EVM_TESTS=1 cargo test -p halo2_solidity_verifier --release --features evm,truncated-challenges --test poseidon_fixture -- --nocapturePer-PR gate
test-solidity-verifier-trace— the per-identity differential between the native Midfall verifier and the generated Solidity verifier, and the only check comparing quotient-VM semantics against the Rust verifier end to end:HALO2_SOLIDITY_RUN_EVM_TESTS=1 cargo test -p halo2_solidity_verifier --release --features evm,truncated-challenges,rust-verifier-trace,solidity-trace --lib native_midfall_verifier_trace_matches_solidity_trace -- --nocaptureThe three compiled-artifact tests that the
pbt_filter does not select (see reviewer notes):HALO2_SOLIDITY_RUN_EVM_TESTS=1 cargo test -p halo2_solidity_verifier --release --all-features --lib compiled_memoryguard_does_not_overlap_generated_layout compiled_verifier_runtime_fits_the_eip170_limit batch_invert_fails_closed_on_noncanonical_words_in_all_paths -- --nocaptureHeavy IVC jobs, now in
solidity_verifier_bench.yml(~90 min each):Aggregation crate, for the
LightAggregator::verifybound change:cargo test -p midnight-aggregation --release --all-featuresGating env vars, all read by
proofs/solidity-verifier:HALO2_SOLIDITY_RUN_EVM_TESTS=1enables the real-EVM and proving cases (once set, a missing SRS or solc now fails instead of skipping);HALO2_SOLIDITY_RUN_IVC_BENCH=1enables the k=20 decider (~300 MB SRS);SRS_DIRdefaults to<crate>/../../zk_stdlib/examples/assets;SOLC/SOLC_INSTALL_DIRpoint at solc 0.8.30+commit.73712a01;HALO2_SOLIDITY_ALLOW_UNPINNED_SOLC=1accepts a non-pinned solc, which makes gas and bytecode figures ad hoc. Note thattests/ivc_accumulator_replay.rsis gated only on#![cfg(feature = "evm")]with no env var, so under--all-featuresit hard-fails rather than skips if pinned solc is absent.Notes for reviewers
target/*-fixture-dump/andtarget/ivc-keccak-solidity-dump/was regenerated for the0x80→0x1000rebase and the template guard changes (48a6f0c, 9bf8c51, 3fb6d84). Every diff is accounted for by an upstream source change:TRANSCRIPT_MPTR/RETURN_MPTR0x80→0x1000, quotient-program pointers shifting by exactly0xf80,ec_pairingcomparing against 1, the accumulator identity guard keyed on the word index, the addedG1ADD(G,G)==2Gsmoke vector, andLAGRANGE_DENOMS_MPTR. These bodies are generated output and were not line-reviewed.target/{hybrid-mt,rsa-signature,sha-preimage}-fixture-dump/Halo2Verifier.solregenerate with a diff (they are missingLAGRANGE_DENOMS_MPTRand still write the Lagrange run in place fromX_N_MPTR). Please regenerate and commit before merge.scripts/check_release_bytecode_sizes.shis not in this diff, so its pinned runtime hashes predate the layout rebase — and the new bench workflow runs that script as its release size/hash step on push-to-main. Refresh the hashes as part of this PR or immediately after.test-solidity-verifier-evmstep comment claims it covers non-canonical scalar and G1 rejection, EIP-170 runtime size and the memoryguard overlap check, but itspbt_name filter selects none of them:compiled_memoryguard_does_not_overlap_generated_layout,compiled_verifier_runtime_fits_the_eip170_limit,batch_invert_fails_closed_on_noncanonical_words_in_all_pathsand the G1 sweeps are plain#[test]s and run in no workflow. This PR's owndocs/plans/REDESIGN_PROPOSALS_2026-08.mdrecords it as finding F1 / proposal P0.1. Either fix the filter or fix the comment before merge.GeneratorErrorandRepackErroreach gain a variant and are not#[non_exhaustive];SolidityGenerator::try_newconverts former panics into typed errors and rejects two previously-accepted configurations;repack_proof/encode_calldatanow reject non-canonical Fr scalars;LightAggregator::verifygains aTranscriptInputBytesbound.rust-toolchain.tomlinstead of a hardcoded 1.90.0, and the ~90-minute IVC jobs no longer run per PR. Please triggersolidity_verifier_bench.ymlmanually before merging this one, since it touches memory layout, proof layout, the quotient VM and the templates.deployments/sepolia/moonlight-wrap/is deliberately left as deployed and the drift documented instead, including which fixes the on-chain code does not carry and why a localsolc --bin-runtimediffers from the recorded codehash in exactly 40 bytes (the two 20-byteAUTHORIZED_VKimmutable placeholders at0x51and0x125).docs/architecture/LOWERING_ARCHITECTURE_SPEC.md.