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
Conversation
… 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.
|
Benchmark Results for modified programs 🚀
|
Contributor
Author
|
/bench |
MauroToscano
marked this pull request as draft
July 27, 2026 20:39
Benchmark — ethrex 20 transfers (median of 3)Table parallelism: auto (cores / 3)
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
lincomb2precompile 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: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_rndalready paired; ECSM/ECDAS were the outliers, so this restores the house convention.Part 2 — the
lincomb2precompile (newecsm2.rs/ecdas2.rs)ecrecovercomputespk = u1·G + u2·R. The guest used to evaluate that with four x-only ECSM syscalls —x(k·P)andx((k+1)·P)per term, the+1queries existing only sosolve_ycould recoveryfrom an x-only oracle — plus an affine add. Four ~256-bit ladders, ~1528 ECDAS rows.lincomb2does it in one blinded joint Shamir/Straus chain returning both coordinates: mean 449.1 rows per ecrecover.ecsm_lincomb2(u64::MAX - 11), besideecsm_mulrather 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.EC_T0table holding−2^i·T₀for the blinding correction. New buses:Addend = 29,EcT0 = 32,JointBit = 33.ecsm_lincomb2(..).unwrap_or_else(|| ProjectivePoint::lincomb(..)), so only the body changed — four queries andsolve_ybecame 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:
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.
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 ondlog(T₀). But the prover choosesP2 = lift_x(r)from free signature bytes, so settingP2 = μ·T₀cancelsT₀'s coefficient: withP1 = G,u1 = 1the 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 andQis 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/D2columns, not byMU. A padding row is inert on every other bus, soMU=0, PH1=1, D1=1satisfied all 217 constraints while emitting a liveJointBitsend. A prover could drop a real add and supply its count from phantom rows, computingQ' = (u1−a)·G + u2·Rwhile claimingu1·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
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.P12needs no canonicalisation.recursion_*failures from missingprogram_artifacts/recursion/*.elf);ethrex-crypto13/0;ecsm26/0;executor lincomb218/0;ec_t017/0;lincomb231/0;prove_elfs107/0.make lintclean. Both real blocks prove and verify.Two verification gaps closed on the way past
range_check_balance_tests— thedebug-checksbus tracker reports buses 14..33 only, soAreBytes/IsHalfword/Zero— exactly the buses fed by the hand-writtencollect_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.pr_main.yaml) hashedexecutor/programs/rust/**andsyscalls/**but notcrypto/ethrex-crypto/**, which the ethrex guest depends on by path — so a change confined to that crate restored a staleethrex.elfand CI kept proving the old guest. That matters here specifically: the guest falls back to softwarelincomband returns identical output, so a stale ELF is invisible to every output assertion.Spec
spec/ecsm.typis the first spec chapter for the EC chips (none existed — theecsm.tomlreferences were dead). It documents both implementations: the single-scalar path including the pairing, and the joint-chain path including the schedule, buses, theT₀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
thoughts/ec-recover-opt/lincomb2/BENCH.md; nothing has been benched. Note the trap documented there: the guest falls back to softwarelincomband returns the same answer, so output equality cannot detect a run where the precompile never fired — only the cell slope can.ecsm_mul,ecsm.rsandecdas.rsare 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.count_elements_by_tableis 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.