Skip to content

Harden the Solidity verifier: bytecode certification and EVM replay - #6

Open
jtcoolen wants to merge 108 commits into
mainfrom
misc-fixes
Open

Harden the Solidity verifier: bytecode certification and EVM replay#6
jtcoolen wants to merge 108 commits into
mainfrom
misc-fixes

Conversation

@jtcoolen

@jtcoolen jtcoolen commented Jul 21, 2026

Copy link
Copy Markdown

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.

  • The keccak transcript-buffer bound only sized the pre-first-squeeze run over advice phases up to phase 0, so a circuit whose first Fiat-Shamir challenge belongs to a later advice phase could under-reserve the buffer and write past 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_u256s emitted all-zero words — the EIP-2537 infinity encoding — whenever coordinates() returned None, which in midnight-curves also covers off-curve points. Had a malformed point reached them, NEG_S_G2 = inf would make e(pi, O) = 1 and collapse the KZG check to final_com − v·G + x3·pi = O, which a prover can satisfy from public transcript data since x3 is squeezed before pi is read. The zero path is now gated on is_identity(); anything else aborts codegen (b24be9e). Worth knowing for review: the new assertion is unreachable in this build, because blst's on_curve ORs in vec_is_zero, so the genuine identity returns Some((0,0)) and never took the None arm. 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_vk hard-coded G1Affine::generator() as G1_BASE while taking g2 / s_g2 from the deployer's params, so a params file whose SRS base is c·G built cleanly. G1_BASE_MPTR is consumed in exactly one place — the −v·G term of the final KZG linearization — so the pairing check degenerates to v·(c−1) = 0: for c ≠ 1 every 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-private params.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.rs hard-codes -E::G1::generator() in the same position, so a rescaled SRS breaks both identically.
  • The Lagrange batch-inversion input run was built in place from X_N_MPTR (theta word 26) with no bound against the first word live at Lagrange time, which is Q_EVAL_CPTR_MPTR at theta word 201. At num_instances + |rotation_last| ≥ 175 the 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 makes lagrange_denoms a planner-registered region allocated after batch_invert_scratch, so MemoryMap::validate enforces disjointness structurally and the ~165-instance cliff disappears.
  • A caller-supplied num_instances implying 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 typed GeneratorError::AccumulatorFixedBaseTailMismatch (54907b5).
  • Quotient-VM peepholes validated a const-table slot and then emitted a subexpression that could intern past slot 255, panicking during codegen. The fused product-add scalar and the LIN7/BILIN7 limb coefficients are now reserved before the base expression is emitted, and const-table overflow falls back to a generic op (de3bd7e, 7b498f7, a01b1ea).

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 the assembly ("memory-safe") block the templates depend on — the deployed Sepolia runtime opens with mstore(0x40, 0x08e0), i.e. solc reserved [0x80, 0x8e0) while TRANSCRIPT_MPTR sat at 0x80. 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_START moves 0x800x1000, 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_START is deliberately decoupled and stays at 0x80 (6333673).
  • The accumulator pairing-batch and final two-pair frames move 0x100 / 0x3200x1000 / 0x1220, and the layout validator now rejects any generated region below the layout base (60f842b).
  • FINAL_PAIRING_SCRATCH_START had ec_pairing's input frame sharing the ACC_LHS y_lo word inside the alpha keccak preimage — correct only because FinalPairing.yul hashes before it calls the precompile. It is now derived as PAIRING_BATCH_PTR + PAIRING_BATCH_HASH_BYTES with a disjointness assertion (a1bb2c4).
  • The Lagrange denominator run is no longer written in place from X_N_MPTR over theta words 27–51 (the deployed artifact spilled four words past the cap). It is a registered lagrange_denoms region, so MemoryMap::validate enforces disjointness and 200/500-instance layouts build instead of being refused (6753679, 75f9f99).
  • Five regions were invisible to the overlap checker: the four PCS fixed windows were Phase(PcsFixed) and so never co-live with anything, selector accumulators claimed a single phase although quotient evaluation writes them, MemoryPhase was 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).
  • The phase ordering the Lagrange cap depends on is now pinned by a test asserting against rendered source, rather than resting on a comment (06e46be).

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_shifted gated the identity-flag strip on and(iszero(div(i, limbs_per_word)), first_adjust), and Yul and is bitwise. iszero(…) is 0 or 1 and first_adjust is a radix base (2^56), so they share no bit: the guard was false on every iteration of every call and packed := 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 against p−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 exactly p−1, set is_id := 1, and was accepted by the decoder as EIP-2537 infinity with ok = 1 — writing four zero words to ACC_LHS_MPTR/ACC_RHS_MPTR. That is precisely the non-canonical zero-like encoding the decoded_zero guard exists to reject, and it sits in the mutually exclusive iszero(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 with x = 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 claims is_id was always 0 and that load_acc_point's if is_id branch was dead. Both claims are wrong and were disproved by executing the pre-fix template; the branch was reachable.
  • ec_pairing folded the precompile result as and(ret, mload(scratch)), testing only the low bit and accepting any odd result word; now and(ret, eq(mload(scratch), 1)) (9e714b7).
  • Every EIP-2537 constructor probe used the all-zero infinity encoding — exactly the input a stub returning zeros, or echoing its input, answers correctly. The smoke test now also checks G1ADD(G, G) == 2G against constants computed from the curve library at render time (896fd04).
  • validate_public_accumulator read carried and fixed-base tail scalars from calldata with no < r check of its own, so the negated-base path computed mod(r − s, r) for s ≥ r. The three read sites now enforce it locally (68adb90).
  • batch_invert and scalar_inv rejected only the literal word 0 before modexp(x, r−2, r), which returns 0 for any x ≡ 0 mod r, and the multi-element path fed raw words into mulmod, 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).
  • A failed modexp in batch_invert set ret := 0 and 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, and TraceReturn.yul stored 1 without reading success; both now revert (636c7ec).
  • The quotient-VM epilogue checked q_pc == q_end and iszero(q_has_top), which both pass for a FOLD run with more than one operand live, and the selector y-power table's slot 0 was reserved but never written. A q_sp == stack_mptr check and one mstore close 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::new and validate_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 — every Q_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 the QuotientExpr tree it was lowered from, then repeats the comparison against a second build with limb superinstructions disabled (0b27485).
  • The certification challenge is derived by derive_certify_seed from 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).
  • Certification structurally cannot catch a pointer wrong in both the bytecode and the expression tree, so LoweringPlan::quotient_read_model() publishes the windows the verifier has populated by VM time and validate_quotient_mem_ptrs rejects 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::finish bounds-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).
  • Only length inequalities related the VK-embedded const table and packed bytecode to the independent plan recompile; both are now compared word-for-word, so an ordering-dependent compile over hash-backed structures cannot pin a VK that permanently rejects valid proofs (1fa3b41).
  • The limb7 chain rewriter's constant tracker ignored x := … reassignments, so a stale literal could be baked into a q_limb7 call 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 (SolidityGenerator consumes params.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).
  • A second fixture covers the point_pair encoding — the expected_acc_has_carried_scalars = false arms 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).
  • Because every accumulator word is also absorbed into the transcript, those mutations would revert at the pairing regardless, so a further commit adds 15 low-bit public-input flips that must fail at the pairing and four accumulator cases that must revert before the transcript — covering load_acc_point's malformed-infinity and decoded_zero guards, which had only template greps (07d9a9d).
  • The harness carries no per-fixture constants: has_accumulator, acc_offset and num_acc_limbs are 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).
  • The default EVM gate now compiles the accumulator render arm at all: accumulator_verifier_variants_compile_with_pinned_solc renders 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

  • The crate's workflow lived at 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); root ci.yaml gains 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 new solidity_verifier_bench.yml on push-to-main, a weekly cron and workflow_dispatch (ffd39db).
  • With HALO2_SOLIDITY_RUN_EVM_TESTS=1 set, 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).
  • Only Halo2VerifyingKey was size-checked, and the revm harness deliberately sets limit_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_limit now 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-features was a broken configuration: every Poseidon and shape-fuzz test failed with SrsError(64, 252) because the test SRS sized its monomial basis without the prover's single-h-commitment setting. The helpers now extend the basis to k + ceil(log2(cs_degree − 1)) (0aab0cf).
  • The workspace clippy gate was failing with four errors in this crate; it is now clean, with the panicking ConstraintSystemMeta::new and ProtocolPlan::from_constraint_system moved 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::computations returned an empty result for n_sets == 0. Block 6 is the only code in the generator that writes PAIRING_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 compute e(O, [1]₂)·e(O, [−s]₂) = 1 and 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_system unconditionally pushes the Linearization query and validate rejects any schedule not ending with it (so n_sets ≥ 1), and independently validate_absorbed_g1_precompile_coverage would report every absorbed commitment as unconsumed. Both early returns are now assert! — which, unlike debug_assert!, survives release builds (700af6d).
  • Three count-parity checks become hard asserts: VK permutation commitments against meta.permutation_columns, vk.fixed_comms.len() against meta.num_fixeds before deriving permutation_comm_mptr, and the accumulator fixed-base/scalar counts — promoted from debug_assert_eq!, so it no longer compiles out in release (79dd945, a9032f8, 01f4549).
  • Both lookup emitters zipped input chunks against helper evals, truncating to the shorter side on drift, and their k == 0 branches emitted 0 instead of h_eval, dropping the h == 0 binding while the accumulator still folded that eval into sum_h. The zips now assert equal lengths and both branches match plonk/logup.rs; an empty h_evals vector no longer panics (63be029, e5f77f8, d67a2e9, 33e183e).
  • Rotated non-committed instance queries resolved to the single Rotation::cur INSTANCE_EVAL word in both the VM and the Yul emitter; instance_eval_at / expression_memory_ptr now fail closed, with reachability still blocked by the try_new constructor check plus a new regression test (098f6a1, 2286dff, 5d7afe9).
  • Pointer and offset arithmetic in the encoding layer is now total: Value::as_usize asserts non-negative before casting, Display panics on a negative concrete offset instead of rendering sub(0, N), quotient memory pointers use checked u32::try_from and assert 32-byte alignment at the single construction choke point, and column_eval_var uses unsigned_abs so Rotation(i32::MIN) cannot produce a non-identifier name (b8b4c39, 9d7081b, 16f43b5, 56f20be, d149f96).
  • VK payload section reservation used unchecked usize arithmetic on externally-derived counts, so a wrapped count would produce a section map whose extcodecopy ranges alias earlier sections; both sites now return a layout error (b2e99e8).
  • An unrolled PCS rotation walk emitted one mulmod per 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

GeneratorError and RepackError are public and not #[non_exhaustive], so the new variants break exhaustive matches downstream.

  • SolidityGenerator::try_new — documented as the panic-free constructor — called plan.validate().unwrap_or_else(|err| panic!(…)), turning every ProtocolPlan::validate arm (most reachably an advice column declared but never queried) into a panic. ConstraintSystemMeta::try_new and ProtocolPlan::try_from_constraint_system now propagate as GeneratorError::Planning { stage: "constraint system" } (58824e8).
  • Two new typed errors reject previously-accepted configurations: AccumulatorFixedBaseTailMismatch and TooManyInstances (54907b5, 75f9f99).
  • repack_proof subgroup-checked G1 commitments but passed every eval and q_eval word through unchecked, returning Ok for scalars the on-chain parser is guaranteed to revert on; now RepackError::NonCanonicalScalar { offset, bytes_hex } (b4319da).
  • LightAggregator::verify gains the <T::Hash as TranscriptHash>::Input: TranscriptInputBytes bound that plonk::prepare already grew upstream; without it cargo check --workspace failed before reaching anything else. Satisfied by both concrete transcript input types, so no call site changed (2938a40).
  • lib.rs exports two new feature = "evm" test helpers, compile_solidity_runtime and runtime_free_memory_pointer_init (6333673, 0cd1bdb).
  • Crate-internal signatures a reviewer will meet in the diff: TranscriptBufferLayout::from_proof_layout gains a phase_challenge_counts: &[usize] argument, and QuotientProgramBuilder::with_limb_vm_ops loses 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 wrong AUDIT.md challenge-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:

export SOLC="$(proofs/solidity-verifier/scripts/install_pinned_solc.sh "$PWD/.solc" | tail -1)"

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 -- --nocapture

Just the new adversarial accumulator replay (both fixtures, ~2 s each):

cargo test -p halo2_solidity_verifier --all-features --test ivc_accumulator_replay -- --nocapture

SRS 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_2p19

Per-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_ -- --nocapture
HALO2_SOLIDITY_RUN_EVM_TESTS=1 cargo test -p halo2_solidity_verifier --release --features evm,truncated-challenges --test poseidon_fixture -- --nocapture

Per-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 -- --nocapture

The 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 -- --nocapture

Heavy IVC jobs, now in solidity_verifier_bench.yml (~90 min each):

export SRS_DIR="$PWD/.srs" && proofs/solidity-verifier/scripts/ensure_srs_assets.sh && proofs/solidity-verifier/scripts/run_ivc_bench.sh --skip-srs-download

Aggregation crate, for the LightAggregator::verify bound change:

cargo test -p midnight-aggregation --release --all-features

Gating env vars, all read by proofs/solidity-verifier: HALO2_SOLIDITY_RUN_EVM_TESTS=1 enables the real-EVM and proving cases (once set, a missing SRS or solc now fails instead of skipping); HALO2_SOLIDITY_RUN_IVC_BENCH=1 enables the k=20 decider (~300 MB SRS); SRS_DIR defaults to <crate>/../../zk_stdlib/examples/assets; SOLC / SOLC_INSTALL_DIR point at solc 0.8.30+commit.73712a01; HALO2_SOLIDITY_ALLOW_UNPINNED_SOLC=1 accepts a non-pinned solc, which makes gas and bytecode figures ad hoc. Note that tests/ivc_accumulator_replay.rs is gated only on #![cfg(feature = "evm")] with no env var, so under --all-features it hard-fails rather than skips if pinned solc is absent.

Notes for reviewers

  • Generated artifacts. Everything under target/*-fixture-dump/ and target/ivc-keccak-solidity-dump/ was regenerated for the 0x800x1000 rebase and the template guard changes (48a6f0c, 9bf8c51, 3fb6d84). Every diff is accounted for by an upstream source change: TRANSCRIPT_MPTR/RETURN_MPTR 0x800x1000, quotient-program pointers shifting by exactly 0xf80, ec_pairing comparing against 1, the accumulator identity guard keyed on the word index, the added G1ADD(G,G)==2G smoke vector, and LAGRANGE_DENOMS_MPTR. These bodies are generated output and were not line-reviewed.
  • Three dumps are still stale on the branch tip. target/{hybrid-mt,rsa-signature,sha-preimage}-fixture-dump/Halo2Verifier.sol regenerate with a diff (they are missing LAGRANGE_DENOMS_MPTR and still write the Lagrange run in place from X_N_MPTR). Please regenerate and commit before merge.
  • The release bytecode gate will fail on first push to main. scripts/check_release_bytecode_sizes.sh is 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.
  • A known CI gap ships unfixed. The test-solidity-verifier-evm step comment claims it covers non-canonical scalar and G1 rejection, EIP-170 runtime size and the memoryguard overlap check, but its pbt_ 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_paths and the G1 sweeps are plain #[test]s and run in no workflow. This PR's own docs/plans/REDESIGN_PROPOSALS_2026-08.md records it as finding F1 / proposal P0.1. Either fix the filter or fix the comment before merge.
  • Public API. GeneratorError and RepackError each gain a variant and are not #[non_exhaustive]; SolidityGenerator::try_new converts former panics into typed errors and rejects two previously-accepted configurations; repack_proof / encode_calldata now reject non-canonical Fr scalars; LightAggregator::verify gains a TranscriptInputBytes bound.
  • CI topology moved. Two side effects worth naming: the crate is now built with the workspace rust-toolchain.toml instead of a hardcoded 1.90.0, and the ~90-minute IVC jobs no longer run per PR. Please trigger solidity_verifier_bench.yml manually before merging this one, since it touches memory layout, proof layout, the quotient VM and the templates.
  • The deployed Sepolia verifier is no longer reproducible from current codegen (source 206,619 → 212,419 bytes; runtime 21,161 → 21,203). 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 local solc --bin-runtime differs from the recorded codehash in exactly 40 bytes (the two 20-byte AUTHORIZED_VK immutable placeholders at 0x51 and 0x125).
  • Replay fixtures are frozen rendered artifacts, so they stay self-consistent — and therefore keep passing — after a codegen change. Drift is tracked only by the commit stamp in each README, because detecting it automatically would need the SRS again. Both were regenerated at 6753679; if you land further template or layout changes, re-run the commands in those READMEs.
  • Assurance boundary, stated so it is not overread. Certification proves the emitter → reference-interpreter leg only. Reference-interpreter → Yul per-opcode conformance still rests on the opcode table plus the fixture trace differentials, and the overlap checker proves registered regions do not conflict, not that emitted Yul stays inside them. Both limits are written into docs/architecture/LOWERING_ARCHITECTURE_SPEC.md.
  • Not covered. The accumulator arms are compiled and replayed but still never executed against a freshly proved accumulator-carrying proof outside the opt-in k=20 bench; the G1MSM and pairing constructor probes remain identity-only, so a subgroup-check-less G1MSM would still pass the smoke test.

jtcoolen and others added 30 commits June 28, 2026 17:59
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]>
Comment thread .github/workflows/ci.yaml
- name: 'Install rust-toolchain.toml'
run: rustup toolchain install

- uses: Swatinem/rust-cache@v2
Comment thread .github/workflows/ci.yaml
- 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
Comment thread .github/workflows/ci.yaml
- name: 'Install rust-toolchain.toml'
run: rustup toolchain install

- uses: Swatinem/rust-cache@v2
Comment thread .github/workflows/ci.yaml
- 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
jtcoolen and others added 4 commits August 6, 2026 12:08
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]>
@jtcoolen jtcoolen changed the title Miscellaneous fixes Harden the Solidity verifier: bytecode certification and EVM replay Aug 10, 2026
@jtcoolen

Copy link
Copy Markdown
Author

Here are the six most consequential issues, ranked by what actually went wrong.

1. The bitwise-and guard — the only live defect in shipped on-chain code (72bc1b2)

load_acc_coord_shifted gated the identity-flag strip on and(iszero(div(i, limbs_per_word)), first_adjust). Yul and is bitwise, iszero(…) is 0 or 1, and first_adjust is a radix base (2^56) — they share no bit, so the guard was false on every iteration of every call and the subtraction never ran.

The commit message says this made is_id always 0 and load_acc_point's if is_id branch dead. That's wrong. The probe didn't go dead, it went inverted: it compared the un-stripped decode against p−1, so the flagged encoding stopped matching and fell through to the range check, while the codec's unflagged (p−1, p−1) sentinel pair decoded to exactly p−1, set is_id := 1, and was accepted as EIP-2537 infinity with ok = 1 — four zero words written to ACC_LHS_MPTR/ACC_RHS_MPTR. The decoded_zero guard that rejects exactly that lives in the mutually exclusive iszero(is_id) arm and was skipped. The decode-stage canonicality barrier failed open.

This was verified by execution, not reading: restoring the pre-fix guard in the fixture contracts makes the replay case "LHS accumulator decodes to zero without the identity flag" revert only after 1,286,473 gas — a complete verification followed by a pairing failure — versus 1,286,528 for a fully accepted run. Post-fix it reverts under the 643k decode-guard ceiling.

No forgery was reachable: it would need a valid outer proof whose accumulator instances are that sentinel pair, i.e. a G1 point with x = y = 0, which the circuit's codec can't emit. But transcript binding and circuit-side reasoning were doing the work the on-chain barrier exists to avoid relying on.

Fix: gate on the word index alone — if iszero(div(i, limbs_per_word)). The subtraction is already a no-op when first_adjust is zero, so the conjunction was never needed.

2. Zero KZG point sets → accept-any-proof (700af6d)

Most severe potential consequence in the PR. memory_requirements/computations returned empty for n_sets == 0. Block 6 is the only writer of PAIRING_LHS/RHS_MPTR; those are fixed theta slots never aliased by scratch; four zero words are the infinity encoding. So the verifier computes e(O, [1]₂)·e(O, [−s]₂) = 1 and returns true for anything that parses — no opening checked at all.

Unreachable twice over: try_from_constraint_system unconditionally pushes the Linearization query and validate rejects any schedule not ending with it, so n_sets ≥ 1; independently, validate_absorbed_g1_precompile_coverage would flag every absorbed commitment as unconsumed. Fix: both early returns become assert! — which unlike debug_assert! survives release builds.

3. Transcript buffer under-reservation (a01b1ea)

words = max(initial_run, eval_run, total_run), and transcript_end is what places VK_MPTR with zero slack. squeeze_to is the only thing that resets buf_len, and TranscriptProofParser.yul emits it only for phases with num_challenges > 0 — but the bound used only advice_phases.first(). A circuit declaring its challenge via challenge_usable_after(SecondPhase) absorbs later-phase commitments into the same unsqueezed run, unaccounted for. The absorb helpers do a bare mstore(buf_len, …) with no upper bound, so the overrun writes proof-supplied calldata over vk_digest, then n_inv and omega, which Lagrange.yul reads afterwards.

Needs ≥9 deferred advice commitments (the 1024-byte cushion hides fewer) and ~86+ public inputs for total_run not to dominate. Realistic outcome is a broken verifier or an unbounded-gas Lagrange spin — DoS, not a demonstrated soundness break. Fix: walk advice_phases accumulating until the first phase with phase_challenge_counts[phase] > 0.

4. Lagrange run clobbering live memory (adc589f6753679)

The denominator run was built in place from X_N_MPTR (theta word 26); the first word live at Lagrange time is Q_EVAL_CPTR_MPTR at word 201. At num_instances + |rotation_last| ≥ 175 the run overwrote the q_eval calldata cursor — codegen and solc both succeeded, then the immutable contract reverted on every proof. Fail-closed (clobbered words are consumed downstream of the KZG check, and their values are transcript-derived, not attacker-chosen), but an undetectable-until-deployment brick. The deployed Sepolia artifact runs 30 words, stopping 145 words short of anything live. Fix: adc589f adds the codegen bound; 6753679 makes lagrange_denoms a planner-registered region so MemoryMap::validate enforces disjointness and the cliff disappears.

5. ec_pairing low-bit test (9e714b7)

ret := and(ret, mload(scratch))ret is already 0/1, so this tested only the low bit of the pairing result, promoting any odd word to acceptance. This is the terminal soundness gate. The sole backstop is the precompile itself: a conformant EIP-2537 PAIRING_CHECK returns exactly 0 or 1, so "odd" and "== 1" coincide. The constructor smoke test only probes a true identity pairing, so it does not certify the false-case encoding. On a non-conformant L2 returning an odd non-1 word for a failing check, the pre-fix code would have accepted an invalid proof. Fix: and(ret, eq(mload(scratch), 1)).

6. SRS base never checked (fc42518)

G1_BASE was hard-coded to the generator while g2/s_g2 came from the deployer's params. G1_BASE_MPTR is consumed in exactly one place, so with base c·G the check degenerates to v·(c−1) = 0every honest proof reverts. Bricked verifier, not false acceptance. The commit message claims divergence from the native verifier; it's wrong — proofs/src/poly/kzg/mod.rs hard-codes -E::G1::generator() in the same position, so a rescaled SRS breaks both identically. Fix: assert Σ g_lagrange == generator, which works because Σ L_i(X) = 1 makes that sum exactly the crate-private params.g[0].

jtcoolen and others added 21 commits August 12, 2026 16:12
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants