Skip to content

perf(ec): pair EC byte range checks, and replace ecrecover's four scalar muls with one joint chain (−61.9% EC cells, z3-gated, unconditional) - #871

Draft
MauroToscano wants to merge 12 commits into
feat/keccak-hwsl-inlinefrom
feat/ec-lincomb2
Draft

perf(ec): pair EC byte range checks, and replace ecrecover's four scalar muls with one joint chain (−61.9% EC cells, z3-gated, unconditional)#871
MauroToscano wants to merge 12 commits into
feat/keccak-hwsl-inlinefrom
feat/ec-lincomb2

Conversation

@MauroToscano

Copy link
Copy Markdown
Contributor

Two EC optimizations that ship together: the existing single-scalar chips get their byte range checks paired (−13.3% ECDAS cells), and a new joint-chain lincomb2 precompile replaces ecrecover's four x-only scalar multiplications with one (−61.9% EC cells). Both paths stay live and both are specified in the new EC spec chapter.

Stacked on #860 (keccak HWSL-inline). Review that first; the diff here is EC-only.


Part 1 — AreBytes send-pairing on the existing chips (42ba68ff)

Every single-byte range check shipped as [b, 0] while the bus contract already checks both slots. Pairing them halves the sends:

interactions/row
ECDAS 388 → 290 ≈ −13.3% committed cells
ECSM 579 → 515

This is a bus repacking only — no witness, column, constraint or degree change. The equivalence argument (thoughts/ec-recover-opt/gate/pairing-equivalence.md) is that every gate lemma consumes the byte contract per column, not per send, so the theorem survives verbatim. keccak_rnd already paired; ECSM/ECDAS were the outliers, so this restores the house convention.

Part 2 — the lincomb2 precompile (new ecsm2.rs / ecdas2.rs)

ecrecover computes pk = u1·G + u2·R. The guest used to evaluate that with four x-only ECSM syscalls — x(k·P) and x((k+1)·P) per term, the +1 queries existing only so solve_y could recover y from an x-only oracle — plus an affine add. Four ~256-bit ladders, ~1528 ECDAS rows.

lincomb2 does it in one blinded joint Shamir/Straus chain returning both coordinates: mean 449.1 rows per ecrecover.

  • New syscall ecsm_lincomb2 (u64::MAX - 11), beside ecsm_mul rather than replacing it. Status returned in a register; non-zero means the guest takes its existing proven-Rust fallback. Degenerate values never trap — they arrive from transaction data, so a trap would let one crafted transaction abort a whole block's proof.
  • New chips ECSM2 (1,155 cols) and ECDAS2 (658 cols / 288 constraints), plus a preprocessed EC_T0 table holding −2^i·T₀ for the blinding correction. New buses: Addend = 29, EcT0 = 32, JointBit = 33.
  • Guest switch: the call site was already ecsm_lincomb2(..).unwrap_or_else(|| ProjectivePoint::lincomb(..)), so only the body changed — four queries and solve_y became one ecall. The guest keeps decompressing (r, v) itself and remains the parity authority; the chip's obligations are membership and canonicalisation.

Numbers

Measured against the post-pairing baseline (1.467M cells/ecrecover), because the pairing win is already banked in Part 1:

cells/ecrecover vs baseline
mean (449.1 rows) 0.559M −61.9%
worst case (514 rows) 0.640M −56.4%

Guest cycles: −78,823 and −78,493 per ecrecover on the 5- and 20-transfer blocks (agreeing to 0.4%). The design doc predicted 100–150k, so the secondary win is real but below the low end — quoting the measurement.

The design study's headline of −74.3% is denominated against the pre-pairing 1.69M baseline and would count Part 1's win twice. −61.9% is the honest figure against what actually ships.

Soundness: two forgeries found and closed

Both were found by deriving claims the design docs asserted, and both survived a chip that proved, verified, balanced every bus and passed lint. Each is now a permanent negative control.

1. The NUMS blinding argument does not close the incomplete-addition edge. The design mitigated attacker-reachable degenerate additions by seeding the accumulator at a nothing-up-my-sleeve point T₀, arguing a collision implies a known linear relation on dlog(T₀). But the prover chooses P2 = lift_x(r) from free signature bytes, so setting P2 = μ·T₀ cancels T₀'s coefficient: with P1 = G, u1 = 1 the condition collapses to μ·(c2 − 1) ≡ −2^j (mod N) — one modular inversion, no discrete log, and cheaper than the same attack on the unblinded chain. At the collision λ is unconstrained and Q is free. Verified 5/5, each packaged as a well-formed (z,v,r,s).

Fixed by an unconditional fourth relation, D_INV·(xB − xA) ≡ 1 (mod p), gated to addend-consuming rows. This retires the assumption rather than patching it: the joint-chain path rests on no cryptographic assumption the single-scalar path did not. The blind is kept only because it drops the exact-MSB lemma — convenience, not soundness.

2. Padding rows could mint scalar digits ⇒ arbitrary chosen sender. ECDAS2's per-stream digit sender was gated by the raw D1/D2 columns, not by MU. A padding row is inert on every other bus, so MU=0, PH1=1, D1=1 satisfied all 217 constraints while emitting a live JointBit send. A prover could drop a real add and supply its count from phantom rows, computing Q' = (u1−a)·G + u2·R while claiming u1·G + u2·R — and since the attacker picks the effective multipliers first (u1'=u2'=1, R = T−G), that yields any chosen recovered key, i.e. an arbitrary transaction sender, with no discrete log and no search.

The existing chip has exactly this defence (ecdas.rs:444); the rewrite dropped it. Fixed by (1−MU)·{D1,D2,S1,S2,S3,S_CORR} = 0.

Verification

  • z3 gate (thoughts/ec-recover-opt/gate/RESULTS-lincomb2.md): all lemmas UNSAT, 7 genuine forgeries SAT, 2 redundancy probes, 0 live holes. Two controls are driven by the working attacks above rather than by hypotheses. L1–L5a port by proving ECDAS2's relation arms textually identical to ECDAS's per-arm. Positive anchor: 3.32M checks over 5,960 rows.
  • Oracle: 611 three-way lincomb differentials, 1,536 exhaustive small-scalar cases, 489 Wycheproof verdicts, 40/40 ecrecover, and 69,431 rows compared field-by-field against the Rust witness — zero discrepancies.
  • Width audit: the convolution widths hold with a varying, chip-produced addend (~2^39 headroom, z3 UNSAT ×36, 6,346 real rows cross-checked). P12 needs no canonicalisation.
  • Tests: prover suite 566 passed (5 pre-existing recursion_* failures from missing program_artifacts/recursion/*.elf); ethrex-crypto 13/0; ecsm 26/0; executor lincomb2 18/0; ec_t0 17/0; lincomb2 31/0; prove_elfs 107/0. make lint clean. Both real blocks prove and verify.

Two verification gaps closed on the way past

  • range_check_balance_tests — the debug-checks bus tracker reports buses 14..33 only, so AreBytes/IsHalfword/Zero — exactly the buses fed by the hand-written collect_bitwise_from_* mirrors, several of which carry comments warning that a mismatch is "a silent bus break" — had nothing enforcing them; a drift surfaced only as an unattributed whole-proof imbalance. The new test asserts every table's range-check interactions cancel per tuple across all ~30 tables, and guards mirrors that predate this work.
  • CI cache keys (pr_main.yaml) hashed executor/programs/rust/** and syscalls/** but not crypto/ethrex-crypto/**, which the ethrex guest depends on by path — so a change confined to that crate restored a stale ethrex.elf and CI kept proving the old guest. That matters here specifically: the guest falls back to software lincomb and returns identical output, so a stale ELF is invisible to every output assertion.

Spec

spec/ecsm.typ is the first spec chapter for the EC chips (none existed — the ecsm.toml references were dead). It documents both implementations: the single-scalar path including the pairing, and the joint-chain path including the schedule, buses, the T₀ table, both constraint families above, and the canonicalisation obligations. The security section states that the path is unconditional and records why the T0-DL assumption is not load-bearing.

Not done in this PR

  • Bench — the harness and commands are in thoughts/ec-recover-opt/lincomb2/BENCH.md; nothing has been benched. Note the trap documented there: the guest falls back to software lincomb and returns the same answer, so output equality cannot detect a run where the precompile never fired — only the cell slope can.
  • Retiring the old path. ecsm_mul, ecsm.rs and ecdas.rs are now unused except by their own tests, deliberately kept as a fallback and as a control (the degenerate-add test runs the identical forged row through the old chip and asserts it passes, which is what makes the new test a test of the new relation). Removing them is follow-up work once the bench confirms the win.
  • EC share of a real block — the multiplier that converts −61.9% EC cells into a whole-prover figure. count_elements_by_table is in; the measurement is not. It is only meaningful on a real ethrex block: on a small guest, fixed tables (BITWISE at 2^16 rows, PAGE) are ~95% of the trace.

… chips

Reverse-order formal verification of the secp256k1 scalar-multiplication
accelerator (ECSM core + ECDAS double-and-add), the EC RECOVER hot path for
ethrex signature verification. Follows the keccak-hwsl-inline playbook:
independent oracle, then a z3 gate proving the EXISTING chips correct before any
optimization.

Oracle (thoughts/ec-recover-opt/oracle/): independent pure-Python secp256k1
x-only scalar-mul reference, anchored 3 ways (Wycheproof 669/669, PyPI ecdsa
differential 500+, and a differential harness against the repo crypto/ecsm
crate). Confirms the in-repo reference is correct incl. error/check order.

Gate (thoughts/ec-recover-opt/gate/): lemma board L1-L8.
- L1 carry-lift, L2 Goldilocks width audit (exact intervals + offset/r=3p
  necessity), L3 composition + on-curve, L4 value pinning (Euclid split +
  A-PRIME certificate), L5 side conditions (no 2-torsion, incomplete-addition
  unreachable, x-equality), L6 chain telescoping + Bit-counting induction,
  L7 end-to-end vs oracle (990 linear UNSAT queries).
- Faithfulness anchor: transcribed model evaluated on 12 REAL prover witnesses
  (compute_witness via an extended harness) -> 872k checks, 2190 ECDAS rows,
  all constraints 0 mod p_g.
- L8: 4 genuine forgeries/catches (c63=0, XR_SUB_P, transcription swap,
  wrong-prime) + 5 redundancy findings (mid carry window, OP*NEXT_OP,
  IS_BIT(q1[32]), KBitsZeroOnPadding).

Verdict: ECSM+ECDAS wiring VERIFIED under contracts C1-C7 + primality. No repo
source changed.
…eBytes sends/row

Every byte range check in ECSM and ECDAS shipped as its own AreBytes send
shaped [b, 0], wasting the contract's second slot: ARE_BYTES[X, Y] checks
BOTH tuple elements against the precomputed BITWISE table, which enumerates
all 2^20 (x, y, z) combinations. keccak_rnd already merges adjacent bytes per
the guidance in spec/is_byte.typ; ECSM/ECDAS were the outliers.

Pair adjacent bytes into one send each:
- ECDAS: the 32-byte prefixes of LAMBDA, XR, YR, Q0, Q1, Q2 pair as
  (2i, 2i+1); the four odd bytes pair as (ROUND, Q0[32]) and (Q1[32], Q2[32]).
  196 -> 98 sends; interactions/row 388 -> 290.
- ECSM: X2, Q0, YG and Q1's 32-byte prefix pair as (2i, 2i+1); q1[32] rides
  alone as [q1[32], 0]. 129 -> 65 sends; interactions/row 579 -> 515.

collect_bitwise_from_{ecsm,ecdas} mirror the layouts exactly (sends and
BITWISE multiplicities move together, same tuple order). LogUp aux shrinks by
ceil(N/2): ECDAS 194 -> 145 aux ext columns = -147 committed base cells/row
(~-13.3% of the table's committed footprint on the volume table of ecrecover);
ECSM 290 -> 258. Witness values, column count (667/521), constraint count
(413/200) and max_degree are unchanged; the diff is bus repacking only.

Soundness under the ECSM/ECDAS z3 gate is preserved verbatim: every lemma
consumes the byte contract per column, not per send, and the contract covers
pairs by construction (thoughts/ec-recover-opt/gate/pairing-equivalence.md).
New bus_interaction_counts test pins 515/290; e2e: ecsm battery 15/15 incl.
both forgery-reject tests, ethrex 5-transfer block proves and verifies.
Add gate/pairing-equivalence.md: why the paired-AreBytes rewrite preserves the
verified soundness theorem (contract checks both tuple elements; every gate
lemma consumes the byte contract per column, not per send; sends and
multiplicities changed symmetrically), the exact pair layout per table, and
the expected cost delta (-49 aux ext cols/row on ECDAS ~= -13.3% committed
cells; -32 on ECSM). Update the stale send-shape citations in RESULTS.md /
l1_l2_lift.py and mark candidates A1/A2 landed in chips-map.md.

Add gen_ec_bench.sh (mirrors the keccak bench methodology): generates a
pure-EC guest of N chained full-ladder ECSM ecalls (k = N_secp - 1, ~509
ECDAS rows per call) for prover A/B runs on the bench server.
Add the host-side witness generator for the lincomb2 precompile —
Q = u1*P1 + u2*P2 in one NUMS-blinded joint Shamir/Straus chain — as the
foundation the executor/chip/guest phases build against. No chip, executor,
or guest code yet; `compute_witness` (the single-scalar path) is untouched
and both stay alive through the transition.

- t0(): the pinned NUMS blinding point T0 = hash-to-curve of
  "lambdavm/ecsm/lincomb2/T0/v1" (SHA-256 try-and-increment, counter 1, even
  y). Reproduced independently by the `t0_derivation_matches` dev test (sha2)
  and the Python oracle; derivation recorded in thoughts/.../lincomb2/T0.md.
- lincomb2_witness(): seeds the accumulator at T0, runs a dense-doubling
  joint chain selecting the addend from {P1, P2, P12=P1+P2} per joint digit,
  then strips the blind by adding -2^len*T0. Reuses the audited, gate-verified
  build_step / carries_{lambda,xr,yr} machinery verbatim (a joint row is one
  ordinary double/add step); only the schedule and addend choice are new.
  Emits P2 curve-membership + the new load-bearing canonicalization witnesses
  (yP2<p, xQ<p, yQ<p) the design flagged. Degenerate inputs (u=0, u>=N, P off
  curve, P1=+-P2, Q=inf) return a Lincomb2Error the executor will map to the
  software-fallback status.

Host validation (cargo test -p ecsm: 21/21): Q matches an independent
Fp-reference lincomb AND k256 over 512 random + edge cases; every emitted row
re-satisfies its double/add relation and slope; the NUMS blind provably
cancels (pre-correction acc == Q + 2^len*T0). Locked row count per ecrecover:
mean 449.1, max 471 (one joint chain vs today's four) — confirms DESIGN's 448
estimate. Layout for the chip/executor phases in thoughts/.../lincomb2/
layout-lock.md.
… generator

Additive follow-ups to the phase-A math lock, all bit-identical on the
emitted arithmetic (cargo test -p ecsm: 26/26; row statistics unchanged at
mean 449.1 / max 471 over 512 cases).

- JointStep.nb: "an add follows me at this same round". Without it a double
  row's successor round is not a function of its own columns, so a prover
  could stall `round` and insert or drop doublings while every per-row
  relation still held. Its defining constraint is a PAIR, op-gated:
  OP*NB = 0 and (1-OP)*(NB - D1 - D2 + D1*D2) = 0 -- an ungated form is
  wrong, it fails on add rows, which carry their round's digits to select
  the addend but have nb = 0. `nb` is the same predicate as the existing
  EcdasStep::next_op, so the chip reuses NEXT_OP and `round - 1 + next_op`
  unchanged. This reverses layout-lock.md correction #2, which claimed no
  such column was needed.
- ext64/conv/limb_carries/to_le_33 widened to pub, and dinv_witness moved
  here beside them, so the prover does not re-derive that carry math. One
  implementation, not two: the same reasoning that makes the executor and
  trace builder share lincomb2_outcome.
- lincomb2_table: the -2^i*T0 constants for the preprocessed correction
  table, generated from the pinned NUMS point rather than hardcoded.
…one call

New syscall at u64::MAX - 11, beside ecsm_mul rather than replacing it --
both paths stay alive through the transition. Executor computes the witness
in software; the prover re-derives it by calling the same lincomb2_outcome
function, so the two sides cannot disagree about the status or about which
rows exist.

Status is returned in a register (inlateout("a0")): 0 = OK and Q written,
non-zero = Q untouched and the caller must fall back. ECALL decode leaves
write_register false, but that governs the CPU row's write path only -- an
accelerator may still write a register over the MEMW bus, exactly as COMMIT
writes x10 (commit.rs:29). Codes are distinct per Lincomb2Error so bench and
debug runs can tell the cases apart; the guest only tests != 0. Code 7 is
P1 != G: the chip binds P1 to the generator by construction, so an arbitrary
P1 would produce a status-0 result the prover cannot back -- catching it here
degrades that caller to the software fallback instead of poisoning a block.

Degenerate VALUES never trap. The scalars and points come from transaction
data, so a trap would let one crafted transaction abort the proof of an
entire block; a non-zero status is always sound because the fallback is
proven guest execution, and a lying status only wastes cycles. Operand
ADDRESSES are different -- they are chosen by the guest program, so a bad
address stays a hard ExecutionError.

Both paths perform every operand read and the x10 write; only the 64-byte Q
store is conditional. An early return on the degenerate path would
desynchronise the MEMW timestamps and leave the ecall without a receiver on
the Ecall bus.

Tests: 18 executor cases -- correctness against the witness and k256, one per
Lincomb2Error with the output buffer proven untouched, the P1 != G guard
including a per-byte tamper sweep, and the address/overlap/alignment guards.
…nge-check guard

The chip side of lincomb2: one joint Shamir/Straus chain (mean 449.1 rows per
ecrecover) replacing four single-scalar chains. New sibling modules -- ecsm.rs
and ecdas.rs are untouched and their tests unchanged, both paths coexist until
the old syscall retires.

ECSM2 1,155 cols / 2,380 cells; ECDAS2 658 cols / 288 constraints / 1,240
cells. Per ecrecover 0.559M cells against the 1.467M post-pairing baseline
(-61.9%); 0.640M at the 514-row worst case (-56.4%). New buses: Addend = 29
(Multiplicity::Linear, sel in 1..=4, never 0 -- zero-valued bus elements are
skipped, so a 0 would alias), JointBit = 33 (per-stream digits, received at
2*bit because both the round's double and its add carry them), EcT0 = 32.

TWO SOUNDNESS FIXES, each with an ablation control that reproduces the
forgery:

1. (1-MU)*{D1,D2,S1,S2,S3,S_CORR} = 0. The JointBit digit sender was
   multiplicity D1/D2 ungated by MU, and a padding row is inert on every other
   bus, so MU=0,PH1=1,D1=1,NB=1,OP=0 satisfied every constraint while emitting
   a live digit. A prover could drop a real add and supply its 2*bit count
   from phantom rows, computing Q' = (u1-a)*G + u2*R while claiming
   u1*G + u2*R -- and since the attacker picks the effective multipliers first
   (u1'=u2'=1, R = T-G), that yields ANY chosen recovered key, i.e. an
   arbitrary chosen transaction sender, with no discrete log and no search.
   The old chip has this defence at ecdas.rs:444; the rewrite dropped it.

2. D_INV*(xB - xA) = 1 (mod p) on addend-consuming rows, gated by
   S1+S2+S3+S_CORR (the same expression that counts the Addend receive) and
   including the correction row, never doublings. DESIGN's NUMS blinding does
   NOT close the incomplete-addition edge: the prover chooses P2 = lift_x(r)
   from free signature bytes, so P2 = mu*T0 cancels T0's coefficient and the
   collision condition becomes mu*(c2-1) = -2^j (mod N) -- one modular
   inversion, no dlog, cheaper than the attack on the unblinded chain. At the
   collision lambda is unconstrained and Q is free. This check is
   unconditional, so it retires the named T0-dlog assumption rather than
   patching it; the blind now survives only because it drops the exact-MSB
   lemma.

Also: range_check_balance_tests asserts every table's declared range-check
interactions cancel per tuple across all ~30 tables. The debug-checks bus
tracker reports buses 14..33 only, so AreBytes/IsHalfword/Zero -- exactly the
buses fed by the hand-written collect_bitwise_from_* mirrors, several of which
carry comments warning a mismatch is a silent bus break -- had nothing
enforcing them, and a drift surfaced only as an unattributed whole-proof
imbalance. This guards mirrors that predate lincomb2.

Layout pins assert both chips' column blocks tile [0, NUM_COLUMNS) exactly,
with widths restated in the test on purpose: a pin deriving its widths from
the thing it pins asserts nothing. count_elements_by_table exposes the
per-table breakdown, with both totals now summing it so the arithmetic exists
once (output verified byte-identical on three guests).
…-only dance

The call site is unchanged -- lib.rs already had the
`ecsm_lincomb2(..).unwrap_or_else(|| ProjectivePoint::lincomb(..))` shape the
status contract needs. Only the body changes: four x-only ECSM queries plus
solve_y become one ecall, with status != 0 => None => the existing proven-Rust
fallback.

Deleted with the algorithm they tested: lincomb2_with_oracle, solve_y,
ecsm_oracle, scalar_near_edge (its only caller was lincomb2_with_oracle; every
case it screened is now a chip status). affine_xy and point_from_xy are kept --
they still have callers.

The guest keeps decompressing (r, v) itself and remains the PARITY AUTHORITY:
that is proven CPU execution, and the chip's obligations are membership and
canonicalisation only. A note in the module header records why it is not
delegated, so it does not get "optimized" into the precompile later.

Measured on two real blocks, agreeing to 0.4%: -78,823 and -78,493 guest
cycles per ecrecover (-8.8% / -11.0% of the block). DESIGN predicted
100-150k, so this is materially below the low end -- reporting the
measurement, not the estimate.

Tests were re-aimed rather than dropped. The soft_oracle cases tested a
reconstruction that no longer exists; testing deleted code is not coverage.
The marshalling is split into lincomb2_operands/point_from_le_q and driven by
a soft_lincomb2 stand-in that decodes the way the executor does, giving a real
differential over the whole ABI -- which is where a byte-order slip would
actually live. generator_operand_matches_the_pinned_constant guards the P1 = G
operand: if it drifts, every ecrecover silently takes status 7.
Five cache keys hashed executor/programs/rust/**, syscalls/** and Makefile,
but not crypto/ethrex-crypto/** -- which the ethrex guest depends on by path.
A change confined to that crate therefore restored a stale ethrex.elf and
skipped the rebuild (`if: cache-hit != 'true'`), so CI kept executing and
proving the OLD guest while the source said otherwise.

That is exactly the shape of the ecrecover switch: the block tests still pass
against a stale ELF, because the guest falls back to software lincomb and
returns identical output. Cache-key only; worst case one extra rebuild.
…nch harness

spec/ecsm.typ is the first spec chapter for the EC chips (none existed; the
ecsm.toml references were dead). The T0-dlog assumption is in a box titled
AWAITING SIGN-OFF, followed by a section marked "This section blocks sign-off"
carrying the reduction's incompleteness -- DO NOT SIGN IT. With the D_INV
check in place it is not load-bearing: lincomb2 rests on no cryptographic
assumption the original chips did not.

gate/RESULTS-lincomb2.md: all lemmas UNSAT, 7 genuine forgeries SAT, 2
redundancy probes, 0 live holes. L5b is replaced by the unconditional
non-degeneracy check rather than by the NUMS reduction, which does not close.
L6 is derived (that is where the padding-row forgery came from). L1-L5a are
ported by proving ECDAS2's relation arms textually identical to ECDAS's
per-arm, which is stronger than a re-transcription. Positive anchor 3.32M
checks over 5,960 rows. Two controls are driven by working attacks rather
than hypotheses. N7 (drop yP2 < p) returns UNSAT by design and is recorded as
evidence the column is defence in depth -- DESIGN's parity-flip justification
for it is wrong: negation is (x, p-y), and y+p is the same point.

Oracle: 611 three-way lincomb differentials, 1,536 exhaustive small-scalar
cases, 489 Wycheproof verdicts, 40/40 ecrecover, and 69,431 rows compared
field-by-field against the Rust witness with zero discrepancies. BONUS 2 was
re-aimed at the parity-authority identity after solve_y was deleted -- it had
become a green test of code that no longer exists.

WIDTH-AUDIT: the convolution widths hold with a varying, chip-produced addend
(~2^39 headroom, z3 UNSAT x36, 6,346 real rows cross-checked); P12 needs no
canonicalisation. Records that byte-ness inheritance depends on the bus
staying one Packing::Direct element per byte.

BENCH.md documents the phase-H commands and three traps that each produce a
confidently wrong number: the fallback returns the same answer so only the
cell slope proves the precompile fired; CI could measure the old path; and
the EC share is meaningless on a small guest, where fixed tables (BITWISE
2^16 rows, PAGE) are ~95% of the trace. Nothing was benched.
…chain

The chapter was written before the joint-chain chips existed and still
described them as "specified, not implemented". Rewritten (411 -> 743 lines)
to cover both implementations that now ship side by side, so a reader can tell
which is which and why both are live.

- New section on the paired byte range checks: sends carry two byte columns
  instead of [b, 0], ECDAS 388 -> 290 and ECSM 579 -> 515 interactions/row,
  a bus repacking with no witness/column/constraint change. Every gate lemma
  consumes the byte contract per column rather than per send, so it survives
  verbatim.
- The joint-chain chips documented as built: schedule, the three phases and
  the ECSM2 relay, the Addend/JointBit/EcT0 buses, the preprocessed T0 table,
  the status contract, and the canonicalisation obligations -- including that
  xQ/yQ < p are load-bearing while yP2 < p is defence in depth, since
  negation is (x, p-y) and both candidates are already below p.
- Both constraint families that closed real forgeries get their own sections:
  the non-degeneracy relation and "padding rows must be inert".
- Security assumptions rewritten. The T0-dlog assumption is presented as
  proposed and NOT used: the reduction does not close, because the prover
  chooses P2 = lift_x(r) and can set P2 = mu*T0. The chain is unconditionally
  sound via the non-degeneracy relation, and the blinding is retained only
  because it drops the exact-MSB lemma.
- Records that the design document's -74.3% must not be quoted -- it is
  denominated against the pre-pairing baseline and re-banks the pairing win.
  Measured figures are -61.9% at the mean and -56.4% at the 514-row worst case.

typst compile ebook.typ exits 0.
@github-actions

Copy link
Copy Markdown

Benchmark Results for modified programs 🚀

Command Mean [ms] Min [ms] Max [ms] Relative
head ecrecover 44.7 ± 0.6 43.9 46.0 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head ecsm 2.8 ± 0.1 2.7 2.9 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head hashmap 99.6 ± 1.7 97.9 102.5 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head keccak 104.0 ± 2.5 101.5 108.9 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head syscall_commit 80.1 ± 1.6 78.1 82.6 1.00

@MauroToscano

Copy link
Copy Markdown
Contributor Author

/bench

@MauroToscano
MauroToscano marked this pull request as draft July 27, 2026 20:39
@github-actions

Copy link
Copy Markdown

Benchmark — ethrex 20 transfers (median of 3)

Table parallelism: auto (cores / 3)

Metric main PR Δ
Peak heap 51199 MB 64040 MB +12841 MB (+25.1%) 🔴
Prove time 27.291s 34.162s +6.871s (+25.2%) 🔴

⚠️ Regression detected — heap or time increased by more than 5%.

✅ Low variance (time: 1.7%, heap: 0.8%)

Commit: 3e4b16f · Baseline: cached · Runner: self-hosted bench

Three independent audits of the lincomb2 soundness gate, plus fixes for
everything they turned up. No chip changed: `prover/`, `crypto/` and
`executor/` are untouched by this commit.

TRANSCRIPTION-AUDIT.md — is the z3 model ever STRONGER than the chip? That is
the dangerous direction: a model that asserts a constraint the chip does not
enforce yields UNSAT where the chip is forgeable, and the positive anchor
cannot catch it, because honest witnesses satisfy a correct model and an
over-strong one equally well. Answer: no. Brute force over all 2^11 assignments
of the schedule booleans against an independently written second transcription
of `Ecdas2Constraints::eval` — symmetric difference empty in both directions.

But three premises were asserted and never read, which is the same class as the
JointBit bug:
- the `D_INV` gate expression was quantified over in Python and connected to
  nothing. Dropping the `S_CORR` term leaves the correction row with no
  non-degeneracy check, reachable via `P2 = -2^(len+1)*T0 - G` — one point
  subtraction, no dlog. The committed chip has the term, so this was a hole in
  the gate, not the chip.
- `chip_state()` detected the COMMENT, not the defence: deleting the idx
  22..=27 emitting loop while keeping the comments left the detector green, so
  the arbitrary-chosen-sender forgery would have scored as a passing ablation
  rather than a LIVE HOLE.
- the PORT lemma compared only the `Relation::X => {...}` arms, not the
  prologue binding operands to columns; rebinding `xa` to `cols::XR` still
  reported "all identical". Textual identity does hold today — verified by
  reading — it simply was not established by the check that claimed to.

All now detected from the emitted expressions. The padding invariant is
enforced as an EQUALITY (gated columns == columns supplying any
`Multiplicity::`), so it also fires on a NEW ungated multiplicity — the
direction the original bug arrived from. Regression suite
`audit_transcription.py` re-runs every tamper and requires detection: 22/22.

CONTRACT-BOUNDARY.md — the theorem is conditional on C1-C7 + A-PRIME, inherited
verbatim from the single-scalar gate. Re-derived for the joint chain:
- C9, previously unnamed: `T0` and the 256 `EC_T0` rows are load-bearing and
  anchored to nothing — no guest write, no MEMW comparison, no in-circuit curve
  check. `c9_constants_probe.py` tampers the generator two ways the source
  itself warns about (sign flip, off-by-one) and gets an accepted, canonical,
  on-curve, WRONG Q with every relation and all five buses balancing, 2/2. The
  constants are correct and well tested; the point is that those tests were not
  listed as part of the soundness argument, so nothing warned that regenerating
  the static commitment to silence a drift failure would launder a wrong-answer
  bug into the verifier's trust anchor.
- C5 cites `spec/logup.typ` for multiset soundness; that document is a protocol
  description containing no theorem and no Schwartz-Zippel step. The new
  multiplicity forms are fine when checked against the implementation, but the
  count-column argument needs a step C5 omits: balance is an equality in F_p,
  not over the integers, and `N2 = p-1` is impossible only because the trace has
  far fewer than p rows. That lifting is what "2 = 1+1 is the only
  decomposition" rests on.
- C4 is invoked well outside its own text (which names xG/k/xR) and holds by a
  mechanism it does not name; C7 turned out stronger than "assumed" — for ECSM2
  it is discharged in-chip; C8 should name the one-`Packing::Direct`-element-
  per-byte invariant that byte-ness inheritance depends on.

Also: the ECSM/ECSM2 membership port (`Relation::X2`/`Yg`) is now mechanically
checked. The theorem's "P2 is on the curve" depended on it and nothing verified
it. N4b was mis-aimed — it ran against a model without idx 22..=27, so its SAT
came from the model rather than the chip; re-pointed at the two live PH1=0
phases it is a genuine forgery again, and the count history is recorded so it
cannot be misquoted. `L6-COUNTING.md` no longer opens by describing a closed
break as live.

Verdicts after the detectors got stricter, all re-run: `audit_transcription`
22/22, `l1_l5_port2` 10/10 proved, `l6_joint_counting` L6 HOLDS,
`l8_negative2` 7 forgeries / 2 redundancies / 0 LIVE HOLES,
`positive_real_witness2` 3,317,091 checks over 5,960 rows, 0 mismatches — the
last of which now PARSES the chip's `JointSel` mapping instead of hand-copying
it, closing the board's one remaining modelled gap. No lemma changed verdict.

Recorded in RESULTS-lincomb2.md §7: the positive anchor evaluates constraint
VALUES only, never a multiplicity or a gating expression, so it could not have
caught the JointBit bug and cannot catch the two above. That is what the new
suite is for.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant