Skip to content

Feat/hint ecall - #876

Merged
jotabulacios merged 38 commits into
mainfrom
feat/hint-ecall
Aug 7, 2026
Merged

Feat/hint ecall#876
jotabulacios merged 38 commits into
mainfrom
feat/hint-ecall

Conversation

@jotabulacios

@jotabulacios jotabulacios commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

Adds a Hint ecall (u64::MAX - 20): the executor computes a value that is expensive in the
guest and cheap to check — secp256k1 field inverse, scalar inverse, field square root — writes
it into guest memory, and the guest verifies it with ordinary constrained instructions and
recomputes it in software if the check fails
. The call site is ecrecover's inversions and
root in crypto/ethrex-crypto/src/lib.rs. A HINT table puts the ecall's direct memory writes
into the memory argument.

Why

The flamegraph of a hinted ethrex block put field and scalar inversion plus square root at
most of the guest's cycles: each software inversion is thousands of instructions, while
checking one is a single multiplication. The ecall moves the computation to the host and keeps
the check in-circuit.

Details

  • The ecall (executor/src/vm/instruction/execution.rs): a0 selects the operation
    (HINT_FIELD_INV, HINT_SCALAR_INV, HINT_FIELD_SQRT), a1 and a2 point at the input and
    output buffers, both 32-byte big-endian (k256's own serialization). compute_hint uses the same
    k256 arithmetic the guest verifies against; a numeric failure (non-canonical input, no
    inverse/sqrt) returns zeros, which the guest treats as a failed check and recomputes in
    software. An unrecognized selector is rejected up front (HintUnknownSelector) rather than
    producing a silent zero.
  • HINT table (37 columns, one boolean constraint, 22 bus interactions, one row per call):
    receives the ecall on the Ecall bus, reads x12 through the memory argument to bind the
    output address, sends the four 8-byte MEMW writes of the output at out_addr +0/8/16/24, and
    range-checks the 32 output cells. The ecall writes guest memory directly, bypassing the
    load/store decode, so without those sends the output's initial→final memory chain is
    unexplained and the memory argument does not balance. The input read is deliberately not
    modelled: a read leaves the value unchanged and the guest supplied the input with ordinary
    stores.
  • Guest wrapper (syscalls/src/syscalls.rs) and the call site in
    crypto/ethrex-crypto/src/lib.rs: scalar_inv, decompress_r and the field inverse verify the
    hinted value, and fall back to a software computation if it does not check out (see Soundness
    surface
    ). The output buffer is 8-byte aligned so its writes take the aligned memory path
    (MEMW_A) rather than the general one.
  • Two test guests, hint_min (one call, commits the result) and hint_multi.

Compatibility

Like the ECSM accelerator (#657), this adds an always-on table, so FIXED_TABLE_COUNT goes
10 → 11 and the recursion verifier checks 11 tables. Prover and verifier are built from the same
code and move in lockstep, so the only operational cost is regenerating existing proofs and the
pinned recursion ELFs.

Impact (ethrex 20-tx block, vm-benchmarks-1, 5 interleaved rounds, all verify ✅)

Comparison is with-hints vs without-hints: baseline = 5fd961a0, the point on main this branch
was cut from (the same code before the hint ecall), proven by its 10-table prover; treatment =
this branch, proven by its 11-table prover. Medians:

Metric main hint Δ
Prove time 25.134 s 17.100 s −32.0%
Peak heap 50.39 GiB 32.78 GiB −35.0%
Proof size 158.5 MiB 121.9 MiB −23.1%
Guest cycles 9,098,740 5,087,162 −44.1%

ECSM calls unchanged at 80 and keccak permutations at 411 on both sides: no cryptographic work
is skipped, its inversions and roots just stop costing thousands of guest cycles each. Variance
was ~1–1.5% (prove-time CV) with clean separation.

Soundness surface

The table constrains nothing about which value was hinted — that is deliberate and it is
what makes the ecall cheap. Soundness comes from two places: what the AIR constrains (where the
value lands, that it is 32 bytes, and that the multiplicity is boolean) and what the guest
enforces (that the value is correct, or else recomputed in software).

In the AIR:

  • out_addr is bound to x12 by a MEMW register read at the ecall timestamp. The four write
    addresses come from a trace column, so without that binding the witness picks the destination
    and the table is an arbitrary-memory-write gadget — something the in-guest verify cannot
    contain, since the adversary just targets a different buffer.
  • The 32 output cells are range-checked as bytes. MEMW range-checks nothing it receives, so every
    table that writes fresh values into memory (STORE, KECCAK, ECSM, PAGE) checks its own cells;
    otherwise the witness can keep the linear combination consistent while encoding field elements
    outside [0, 256) where loads and the ALU expect bytes.
  • mu, the multiplicity gating every interaction, is bit-constrained (mu·(1−mu) = 0), matching
    every other multiplicity-column table (ECSM/ECDAS/COMMIT/STORE/MEMW_R). The Ecall bus alone
    does not give this: its tuple carries a free timestamp column, so LogUp pins only the sum of
    mu over the rows sharing a tuple — a 1/2 + 1/2 split would satisfy it. The bit constraint
    makes the argument local instead of resting on how MEMW handles its own multiplicities
    downstream.

In the guest (verify-then-fallback). A hinted value is untrusted and prover-chosen, so it
may only ever save work — never change the answer. That rules out two failure modes, not one:

  • Accepting a wrong value is caught by the in-guest check: x·inv == 1 for the inverses,
    y² == x³+7 plus parity selection for the root. All three verify by difference rather than
    ct_eq, because k256 compares magnitude and normalization tags too — a naive comparison never
    matches.
  • Steering a rejection is not left to the hint either. A failed check is not treated as
    "the value is invalid"; the guest recomputes in software. scalar_inv falls back to
    invert_vartime (its caller guarantees r ≠ 0, so a failed check means the host lied);
    decompress_r falls back to AffinePoint::decompress (a genuine non-residue must still yield
    None). Without this, a prover feeding garbage could turn a valid signature into a recovery
    failure — ECRECOVER returns empty — making honest and attacked executions both provable with
    different state roots. An unknown selector is a hard ecall error for the same reason.
  • A witness that writes the same wrong value in both the HINT and MEMW rows satisfies every
    constraint. The consistency test in this PR catches an inconsistent trace, which is the
    failure mode of a buggy trace builder, not of an adversary.

The guarantee is therefore a property of the program, not of the machine, and it extends to
every future call site. Constraining the value in the AIR instead (out·in == 1) is possible
and costs rows; the guest already performs exactly that multiplication.

The ecall validates that both 32-byte operands stay inside their low 32-bit address limb,
since the tables send addresses as [lo32, hi32] with the per-write offset added to lo32
alone.

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Benchmark — real block (ethrex_mainnet_25368371.bin) (median of 3)

continuations · epoch 2^22 · 11 epochs

Metric main PR Δ
Peak heap 47024 MB 46980 MB -44 MB (-0.1%) ⚪
Prove time 157.521s 140.811s -16.710s (-10.6%) 🟢

🎉 Improvement on the real block — prove time down 10.6%.

Prove-time spread 0.3% (140.604s / 140.982s / 140.811s)

Commit: 02d8168 · Baseline: cached · Runner: self-hosted bench

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/ai-review

@github-actions

Copy link
Copy Markdown

Codex Code Review

  • High — HINT writes are not bound to the syscall’s a2 output pointer. hint.rs:142 receives only the timestamp/syscall, then uses an unconstrained out_addr for memory writes. A malicious prover can therefore inject the hinted bytes at any address, diverging from executor semantics and potentially altering unrelated VM state. Add a constrained MEMW register-read interaction linking out_addr to x12, as the ECSM table does.

Comment thread prover/src/tables/hint.rs Outdated
Comment thread executor/src/vm/instruction/execution.rs Outdated
Comment thread crypto/ethrex-crypto/src/lib.rs
Comment thread crypto/ethrex-crypto/src/lib.rs Outdated
Comment thread prover/src/tests/prove_elfs_tests.rs Outdated
Comment thread prover/src/tables/hint.rs Outdated
Comment thread executor/programs/rust/hint_min/.cargo/config.toml Outdated
Comment thread executor/programs/rust/hint_min/src/main.rs Outdated
@claude

claude Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review — Hint ecall

The mechanics are well built and the docs are unusually thorough about why each piece exists. The wins are real and the plumbing (Ecall receive + 4 MEMW write sends, collect_hint_ops mirroring collect_ecsm_ops, Makefile globbing the new guests, FIXED_TABLE_COUNT/air_refs kept in sync) looks correct. I found one soundness gap that is materially worse than the one the PR body documents, plus a set of doc/cleanup items. Details inline.

Critical

The HINT row's out_addr is unconstrained — this is an arbitrary-memory-write, not just an unconstrained value. (prover/src/tables/hint.rs)

The Ecall receive carries [ts, syscall]; nothing binds ADDR_OUT_0/1 to the ecall's a2. Both existing ecall receivers do bind their operands to the register file — ecsm.rs:318 reads x11/x12/x10, keccak.rs:185 "read register x10 to bind addr". So a prover chooses any address and any 32 bytes, emits matching MEMW writes, and the memory argument balances.

This breaks the PR's stated soundness anchor. The "Soundness surface" section argues the guest's x·inv == 1 / y² == x³+7 check rejects a wrong value — true, but irrelevant when the write can be redirected: aim it at the recovered pubkey, EVM state, or the commit buffer and no verify is in the path at all. Fix is the ECSM pattern: bind x12 via a MEMW register read at T on both the bus and in collect_hint_ops.

High

Nothing prevents this from reaching a production proof. (executor/src/vm/instruction/execution.rs) All "BENCH ONLY" markers are comments. The syscall is live in the default executor build, HINT is in FIXED_TABLE_COUNT for every proof, and any guest can call it. A default-off cargo feature (syscall recognition + k256 dep + table + AIR slot) would make the bench possible and the hole impossible.

Medium

  • prover/src/tests/prove_elfs_tests.rs — the forgery test's doc claims the test makes "the hint value load-bearing"; it only catches an internally inconsistent trace. The PR body says the opposite, correctly.
  • crypto/ethrex-crypto/src/lib.rs — two insertions land between an existing doc block and its function, so ecsm_ecrecover and lincomb2_with_oracle both lose their docs to the new helpers.

Low

  • prover/src/tables/hint.rs:37 re-declares HINT_SYSCALL_NUMBER instead of importing it (ecsm.rs imports).
  • Stray [env] CC/CFLAGS block in both new guests' .cargo/config.toml (only C-dependent guests need it).
  • hint_min's module doc overstates the bus surface (no read is modelled) and the alignment requirement (unaligned routes to general MEMW; the ethrex call site is unaligned anyway).
  • MU is unconstrained under EmptyConstraints — a boolean constraint or a note on why the Ecall receive suffices.

Checked, no issue found

Guest-side verifies are correct: the normalizes_to_zero comparisons and the negate(1)-vs-Neg magnitude reasoning hold; a zeros-on-failure hint is rejected by every one of the three checks; the parity re-selection in decompress_r is safe because x³+7 = 0 has no solution over F_p (secp256k1 has odd prime order, so no 2-torsion). Executor/trace-builder byte order agrees (both little-endian-packed in address order), addr_limb_ok bounds rule out addr + 24 overflow, MemoryState is image-backed so collect_hint_ops can't diverge from the executor on rodata inputs, and unaligned buffers are handled (executor store_doubleword byte path, prover general MEMW route).

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

AI Review

PR #876 · 38 changed files

Warning: the diff was truncated before review.

Findings

Status Sev Location Finding Found by
confirmed low executor/Cargo.toml:10 executor/Cargo.toml k256 dep comment "BENCH ONLY" is inaccurate — it is used in production proving glm
openrouter/z-ai/glm-5.2
kimi
openrouter/moonshotai/kimi-k2.7-code
confirmed low prover/src/tables/hint.rs:60 Unused import in new HINT table kimi
openrouter/moonshotai/kimi-k2.7-code
confirmed low prover/src/tables/trace_builder.rs:3774 Disk-spill sizing omits HINT table rows kimi
openrouter/moonshotai/kimi-k2.7-code

Status column reflects the verdict from the verifier: deepseek-verifier (openrouter/deepseek/deepseek-v4-pro).

AI-002: executor/Cargo.toml k256 dep comment "BENCH ONLY" is inaccurate — it is used in production proving
  • Status: confirmed
  • Severity: low
  • Location: executor/Cargo.toml:10
  • Found by: glm:openrouter/z-ai/glm-5.2, kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The dependency comment says k256 is for host-side hint computation and is 'BENCH ONLY', but compute_hint (which needs k256) is called unconditionally from the production trace builder, not just benchmarks/tests.

Evidence

executor/src/vm/instruction/execution.rs::compute_hint is pub and uses k256::FieldElement/Scalar (from_bytes, invert, sqrt). It is invoked from prover/src/tables/trace_builder.rs:1010 inside collect_hint_ops, which runs during every production Traces::from_elf_and_logs/collect_epoch build for any program issuing a hint ecall — not only under a bench feature or #[cfg(test)]. The Cargo.toml comment # Host-side computation of non-constraining hints ... BENCH ONLY. is therefore misleading.

Suggested fix

Drop the BENCH ONLY qualifier from the comment (e.g. # Host-side computation of non-constraining hints (modular inverse / sqrt) for the Hint ecall — same k256 arithmetic the guest verifies against.), so a future maintainer does not attempt to feature-gate or demote k256 to a dev-dependency and break production proving.

AI-007: Unused import in new HINT table
  • Status: confirmed
  • Severity: low
  • Location: prover/src/tables/hint.rs:60
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

prover/src/tables/hint.rs imports VmTable from super::types but never uses it, leaving dead code in a newly added table file.

Evidence

Line 60 of prover/src/tables/hint.rs contains use super::types::{BusId, FE, GoldilocksExtension, GoldilocksField, VmTable};. Searching the file shows no reference to VmTable.

Suggested fix

Remove VmTable from the import list.

AI-008: Disk-spill sizing omits HINT table rows
  • Status: confirmed
  • Severity: low
  • Location: prover/src/tables/trace_builder.rs:3774
  • Found by: kimi:openrouter/moonshotai/kimi-k2.7-code
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

count_table_lengths (used for disk-spill peak-heap estimation) does not count HINT table rows, so programs that make many hint ecalls will have underestimated table lengths. The same omission already exists for ECSM, but the PR introduces a new fixed-size table and should account for it.

Evidence

prover/src/tables/trace_builder.rs lines 3774-3956 define count_table_lengths, which counts cpu, memw, memw_aligned, memw_register, load, lt, shift, mul, dvrm, branch, commit, decode, pages, etc. There is no hint_count field in TableLengths and no counting of cpu_op.ecall_hint. In contrast, the actual trace builder (collect_ops_from_cpu and build_traces) generates a hint trace with one padded row per hint ecall.

Suggested fix

Add a hint_padded_rows field to TableLengths and increment a hint_count counter when cpu_op.ecall_hint is true in the count_table_lengths pass, mirroring how other fixed tables are sized.

Reviewer Lanes

Lane Model Prompt Status Findings
glm openrouter/z-ai/glm-5.2 general success 1
kimi openrouter/moonshotai/kimi-k2.7-code general success 3
minimax minimax/MiniMax-M3 general error: opencode failed (provider/auth/runtime error) and no findings were submitted 0
moonmath zro/minimax-m3 general error: opencode failed (provider/auth/runtime error) and no findings were submitted 0
nemotron openrouter/nvidia/nemotron-3-ultra-550b-a55b general success 4

Verification Lanes

Lane Model Status Confirmed Rejected Uncertain
deepseek-verifier openrouter/deepseek/deepseek-v4-pro success 3 4 0

Native Codex and Claude reviews run separately and post their own comments. They are not included in this structured provenance report.

Discarded candidates (4) — rejected by the verifier
  • Hint ecall lacks operand overlap check between in_addr and out_addr (executor/src/vm/instruction/execution.rs:550, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The hint ecall's input read (in_addr) is intentionally not modeled on the MEMW bus (documented in hint.rs: 'The input read... is intentionally not modelled'). The ECSM overlap check exists because both xG and k operands have MEMW bus interactions at different timestamps — overlap would break the MEMW chain. For hint, the input is read into a local buffer before any writes and generates no bus interactions, so overlap cannot create a trace provability issue. Behavior is deterministic: read first, compute, write. The 'inconsistency' with ECSM is by design, not a bug.
  • Executor adds k256 dependency for host-side hint computation (executor/Cargo.toml:18, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — This is not an actual issue — it merely observes that the PR adds a k256 dependency to the executor. The dependency is required for the new feature (compute_hint). The finding itself says 'this is acceptable.' Adding a necessary dependency is not a defect.
  • Test programs hint_min and hint_multi omit in-guest verification (executor/programs/rust/hint_min/src/main.rs:1, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — Both hint_min and hint_multi are explicitly documented as test programs exercising the HINT table's bus surface for the prover. hint_min line 2: 'No in-guest verify — this exercises exactly the Hint table's bus surface.' The ethrex-crypto crate properly implements verify-then-fallback. These are not templates for production guests — they are P0 provability tests.
  • compute_hint returns zeros for non-canonical inputs without error (executor/src/vm/instruction/execution.rs:142, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The zero-return on non-canonical input is the documented design (execution.rs lines 121-126): 'not a loud failure... guest's in-circuit verify rejects the value and recomputes.' The dispatch already rejects unknown hint_id before compute_hint is called. This is correct for the verify-then-fallback pattern.

Raw lane outputs, candidates, final issues, and model metrics are uploaded as workflow artifacts.

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench

k256 is on the prove path, not only in benchmarks: the trace builder's
collect_hint_ops recomputes every hint's output with compute_hint because the
value is not carried in the CPU log. A maintainer trusting the label and
feature-gating the dependency away would break proving.
The HINT table range-checked in_addr's low limb on the ALU bus but left out_addr
to the memory bus, reasoning that an output address straddling the 2^32 limb
boundary cannot balance. The bus does bound it, but only to 2^32 - 25: the write
bases are out_addr_lo + 8i, so the largest one stops being a canonical limb at
2^32 - 24, while MEMW's carry columns resolve the bytes past it correctly. The
executor rejects anything above 2^32 - 32 with HintAddressOverflow, which left the
seven-value window 2^32-31 ..= 2^32-25 that the AIR accepted and the executor
halts on — a prover could prove a hint call the VM rejects.

Send the same LT range-check for out_addr's low limb. The existing in_addr bound
is reused unchanged, since 2^32 - 31 is exactly addr_limb_ok(addr, 31) for either
operand, and is renamed HINT_ADDR_LIMB_BOUND now that it covers both. The trace
builder emits the matching LT op, and the sizing pass counts three LT rows per
hint call instead of two — LT is an upper-bound table there, so the count only has
to stay >= the built trace, which is why the count_table_lengths drift test does
not catch an undercount on its own.

Tests assert that both address columns carry an ALU LT sender against that bound,
and that the bound accepts exactly the limbs addr_limb_ok accepts, with the
seven-value window as an explicit regression.
HINT_SELECTOR_BOUND was a literal 3 in the prover, while the executor decided
validity with matches!(hint_id, HINT_FIELD_INV | HINT_SCALAR_INV | HINT_FIELD_SQRT).
Nothing linked the two, so appending a fourth selector would make the HINT table
assert LT(selector, 3) = 1 against an LT row the builder emits as 0 — an unbalanced
ALU bus with no algebraic pointer to the cause.

Move the bound next to the selectors it bounds, express the ecall's rejection as
is_valid_hint_selector, and const-assert that every selector below the bound is
valid and that the bound itself is not. The prover re-exports the bound instead of
restating it, so a selector added without moving the bound fails to compile rather
than surfacing as a bus imbalance at proving time.
…t comments

#899 corrected the same two stale comments this branch had already fixed locally:
the k256 dependency label, which claimed BENCH ONLY for a prove-path dependency,
and hint_min's claim that the ethrex call site is unaligned. Both conflicts resolve
to #899's version, which states the same facts, so the two local comment commits
are superseded. The output-address range-check and the selector-bound commits touch
different files and are unaffected.
@diegokingston

Copy link
Copy Markdown
Collaborator

/bench

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench-verify

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Benchmark started on the bench server. Two verifier arms (monolithic + continuations over an ethrex 20-tx block), then the recursion-guest cycle comparison, which adds guest builds on top — longer on a cold runner. The bench server is occupied until it finishes.

@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench-gpu

@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

Verifier benchmark — 2c3b3e8c00 vs main (20 pairs, monolithic + continuations)

ethrex 20-tx block · monolithic · blowup=2, 219 queries

Metric main PR Δ
Verify time (ABBA, 20 pairs, per-side) 3.058s 3.057s -0.04% ⚪
Proof size (exact, 1 reading) 155.88 MiB 156.27 MiB +0.25% 🔴

Per-side (⚠️ PR REJECTS the baseline's valid proof — likely a VERIFY REGRESSION, not a format change): A/B/B/A cancels machine drift but not proof-specific variance — read the Verify-time Δ as approximate.

  pairs: 20   mean A (PR): 3.057s   mean B (main): 3.058s
  [parametric] paired-t   mean -0.04%   sd 0.56%   se 0.13%
               95% CI: [-0.30%, +0.22%]   (t df=19 = 2.093)
  [robust]     median +0.03%   Wilcoxon W+=105 W-=105  p(exact)=1.0000  (z=+0.02)

  run-to-run jitter:    A CV 0.56%   B CV 0.49%        (lower = steadier)
  within-session drift: -0.05% over the run, 1st->2nd half -0.04%

INCONCLUSIVE — effect not separable from 0 at n=20 (point estimate ~+0.03%). Add pairs to resolve.

ethrex 20-tx block · continuations, epoch 2^20 (9 epochs) · blowup=2, 219 queries

Metric main PR Δ
Verify time (ABBA, 8 pairs, per-side) 8.182s 8.169s -0.15% ⚪
Proof size (exact, 1 reading) 470.68 MiB 474.18 MiB +0.74% 🔴

Per-side (⚠️ PR REJECTS the baseline's valid proof — likely a VERIFY REGRESSION, not a format change): A/B/B/A cancels machine drift but not proof-specific variance — read the Verify-time Δ as approximate.

  pairs: 8   mean A (PR): 8.169s   mean B (main): 8.182s
  [parametric] paired-t   mean -0.15%   sd 1.49%   se 0.53%
               95% CI: [-1.40%, +1.09%]   (t df=7 = 2.365)
  [robust]     median -0.15%   Wilcoxon W+=14 W-=22  p(exact)=0.6406  (z=-0.49)

  run-to-run jitter:    A CV 0.66%   B CV 0.99%        (lower = steadier)
  within-session drift: +0.07% over the run, 1st->2nd half -0.17%

INCONCLUSIVE — effect not separable from 0 at n=8 (point estimate ~-0.15%). Add pairs to resolve.

Verify-time rows only: drift-free interleaved A/B/B/A, with paired-t and exact Wilcoxon — trust the verdict when the two agree. Proof sizes are single exact readings (no averaging). - = PR faster.


Recursion guest cycles — verifier running INSIDE the VM (main vs PR)

empty program · monolithic · blowup=2, 1 query (diagnostic — NOT a real verifier cost)

Single exact reading per ref — no ABBA: guest cycles are deterministic for a fixed
(guest ELF, input blob), so there is no machine drift to cancel.

Metric main PR Δ
Guest cycles 460.1M 463.3M +3.2M (+0.69%)
Keccak calls 2994 3029 +35
  baseline  origin/main  8b88a8d676  guest=recursion-min.elf
  PR        2c3b3e8c0027bd73665a5b61118bc03fe98db3a6  2c3b3e8c00  guest=recursion-min.elf
  note: cycles reproduce to ~±100k (build codegen + proof nondeterminism);
        treat sub-100k deltas as noise, not signal.
raw (exact integer counts)
ref_b_sha=8b88a8d676280d25de7e8690423a502c55f6ec27 ref_b_elf=recursion-min.elf ref_b_cycles=460138973 ref_b_keccak=2994 ref_b_execute_wall_s=9
ref_a_sha=2c3b3e8c0027bd73665a5b61118bc03fe98db3a6 ref_a_elf=recursion-min.elf ref_a_cycles=463311810 ref_a_keccak=3029 ref_a_execute_wall_s=9
delta_cycles=3172837 delta_keccak=35

ethrex 20-tx block · continuations, epoch 2^21 (main 5 / PR 3 epochs) · blowup=2, 219 queries (128-bit)

Single exact reading per ref — no ABBA: guest cycles are deterministic for a fixed
(guest ELF, input blob), so there is no machine drift to cancel.

Metric main PR Δ
Guest cycles 4860.3M 3191.8M -1668.5M (-34.33%)
Keccak calls 6807496 4263971 -2543525
  baseline  origin/main  8b88a8d676  guest=recursion-cont-blowup2.elf
  PR        2c3b3e8c0027bd73665a5b61118bc03fe98db3a6  2c3b3e8c00  guest=recursion-cont-blowup2.elf
  note: cycles reproduce to ~±100k (build codegen + proof nondeterminism);
        treat sub-100k deltas as noise, not signal.
raw (exact integer counts)
ref_b_sha=8b88a8d676280d25de7e8690423a502c55f6ec27 ref_b_elf=recursion-cont-blowup2.elf ref_b_cycles=4860321552 ref_b_keccak=6807496 ref_b_execute_wall_s=79
ref_a_sha=2c3b3e8c0027bd73665a5b61118bc03fe98db3a6 ref_a_elf=recursion-cont-blowup2.elf ref_a_cycles=3191814035 ref_a_keccak=4263971 ref_a_execute_wall_s=50
delta_cycles=-1668507517 delta_keccak=-2543525

The unit tests under `executor/src/tests/` live in the lib target
(`#[cfg(test)] pub mod tests;` in lib.rs), so none of the `--test <name>`
steps select them, and the `test_ckzg` step filters by name and runs only
ignored tests. They therefore never ran in CI — including the hint ecall's
`HintUnknownSelector` / `HintAddressOverflow` / per-selector coverage, which
has no other home.

The new step shares the lib test binary with the `test_ckzg` step, so it
costs a test run rather than an extra compile.
…ints

The existing lying-hint tests all feed `[0; 32]` / `[0xFF; 32]`, which die
in `Scalar::from_repr` / `FieldElement::from_bytes` and never reach the
verify predicate. So the checks the fast paths' soundness actually rests on
— `(x * inv) == 1` and `x·inv - 1 == 0` — had no test that exercised their
rejecting branch.

- `field_inv` / `scalar_inv`: hints that parse cleanly and simply are not
  the inverse (`inv + 1`, `-inv`), which must be rejected and recomputed.
- `decompress_r`: an oracle returning the *other* root. That is not a lie —
  `-y` is as valid a root of x³+7 as `y` — so the verify accepts it and the
  fallback never runs, leaving the parity-selection branch solely
  responsible for the sign. With the honest oracle that branch fires only
  for the `k` whose root happens to have the wrong parity; forcing the
  negation exercises it for every `k`.

Also drops a dangling "property C1" reference from the module doc and
states the property directly.
The guest called `HINT_FIELD_INV` three times, so the AIR's `selector < 3`
range-check was only ever exercised at 0 — an accepted-value bound that no
end-to-end test pushed against. One call per selector (`HINT_FIELD_INV`,
`HINT_SCALAR_INV`, `HINT_FIELD_SQRT`) covers the whole accepted range;
`sqrt`'s input is 4, a quadratic residue mod p, so the hint is a real root
rather than the zeros `compute_hint` returns on a numeric failure.

`test_prove_hint_multi_rust_guest`'s expected value follows, now computed
through `compute_hint` per selector instead of assuming three field
inverses.
`is_valid_hint_selector` and its const-assert tie the AIR's range-check to the
executor's accepted set, so the prover and executor can no longer disagree. The
*guest* is a third declaration and is still unbound: `lambda-vm-syscalls`
re-declares the same three selectors as `usize`, in a crate the workspace
excludes, linked to the executor's `u64` copies by nothing but a comment.

A divergence there is silent. The ecall would either trap on an unknown
selector, or — worse, for a value that stays in range — return the wrong
function's answer, which the guest's verify-then-fallback swallows as "the host
lied" and quietly recomputes in software. Nothing fails; the guest just runs
~2000x slower for the right result.

`lambda-vm-syscalls` is added as a dev-dependency for it. Unlike
`crypto/crypto`'s and `ethrex-crypto`'s copies it is not target-gated, so it
does build on the host — safe because that crate's guest-only items (the
`#[global_allocator]` and the `_start`/`main` entrypoint) are already
`cfg(target_arch = "riscv64")`, and `executor::tests` is itself `#[cfg(test)]`,
so the non-test lib build never links it.
Follow-on to "Range-check the HINT output address low limb" and "Derive the HINT
selector bound", which added interactions and constants but left these behind.

- `hint.rs`: the `HintConstraints` doc still said the LogUp argument "already
  fixes `mu`'s value via the timestamp-unique `Ecall` tuple", framing `IS_BIT`
  as belt-and-braces. That contradicts the module doc directly above it: the
  `Ecall` tuple carries a per-instruction timestamp, a free column, so LogUp
  pins only the *sum* of `mu` over rows sharing a tuple — which a witness can
  satisfy by spreading `mu` with integer weights summing to 1. `IS_BIT` is
  load-bearing, and the doc now says so and points at that argument. Its bus
  list was also stale (one register read, no LT senders); it is three and three.
- `prover/src/test_utils.rs`: same stale bus surface on `create_hint_air`.
- `crypto/ethrex-crypto/src/lib.rs`: the comment justifying `negate(y2)` over
  `negate(rhs)` claimed negating `rhs` "would silently compute the wrong value
  in release". That is not what happens. k256's `negate(magnitude)` computes
  `2*(magnitude+1)*P_limb - self` under a `debug_assert!(self.magnitude <=
  magnitude)`; for a magnitude-2 operand the result stays non-negative, so the
  value is correct and it is the debug assert that fires. The reason to prefer
  `negate(y2)` is real, but it is a build-configuration hazard, not a wrong
  answer — worth stating accurately in a comment that exists to explain a
  non-obvious choice.

@MauroToscano MauroToscano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking on one concrete item, plus a process point about the approval. The feature itself reviewed well — the verify-then-fallback design survived an adversarial pass (5,138 end-to-end ecrecover comparisons plus ~7,000 per-seam, debug and release, against the vendored k256) and I could not find a hint for which field_inv, scalar_inv or decompress_r returns anything other than the software result. This is not about the design.

Blocking: the tests that validate the operand checks never run in CI

test-executor runs only --test asm, --test rust, --test flamegraph, and a test_ckzg-filtered --ignored step. Because executor/src/lib.rs is #[cfg(test)] pub mod tests;, everything under executor/src/tests/ is a lib target that no CI command selects. I checked every cargo test / nextest invocation in the workflow — the nextest archive is scoped -p lambda-vm-prover -p stark -p crypto -p ecsm, executor absent.

Concretely: the tests covering HintUnknownSelector, HintAddressOverflow (both operands, plus the accepted 2^32 − 32 boundary) and compute_hint's three selectors — including the ones added by 5c55e4c1are not executed anywhere. The soundness-relevant validation this PR added is unverified by CI.

The fix is one step in an already-required job, with no added compile time (the lib test binary is already built by the test_ckzg step):

      - name: Run executor lib unit tests
        run: cargo test --release -p executor --lib

The mechanism is pre-existing — ecsm_tests, keccak_tests and memory_tests are equally dark — so this is not a regression introduced here. But this PR is what makes it matter.

#905 carries that fix, along with three still-stale comments and three test gaps, and targets this branch so merging it folds everything in. It is green. Merging it first is the smallest path; cherry-picking d7380474 alone would also unblock.

Process: the approval predates all of the soundness work

The current approval is from be9066bf (2026-07-31). Head is now 2c3b3e8c20 commits later, and the operand-binding work is entirely inside that window:

  • c2283bfb Bind and range-check the HINT ecall operands
  • 5c55e4c1 Range-check the HINT output address low limb, like the input one
  • d3a85f49 Derive the HINT selector bound from the executor's accepted set
  • plus two main merges and #899

Those commits are the AIR's soundness surface — they are what make the table's accepted set match the executor's. They have not been reviewed by the approver. Worth a fresh look before merge, independent of the CI item above.

For what it is worth, I reviewed them and they are good: 5c55e4c1 is complete (bus interaction, the matching LtOperation, lt_count += 2 → += 3, and with_capacity(26) → 27), and d3a85f49 is better than the const-assert I had proposed — is_valid_hint_selector as a single source of truth with set equality asserted in both directions catches a fourth selector added below the bound, which mine would not have. I dropped mine in favour of it.

Two non-blocking asks

The body's numbers are stale: 41 columns not 37, and 27 bus interactions after 5c55e4c1, not 22. The Details and "In the AIR" sections also omit the x10/x11 bindings and the LT range-checks — which are exactly the checks an auditor most needs to see, since they are what align the AIR with the executor.

The Impact table needs re-baselining. 5fd961a0 was the right control when written, but 9ccdaf28 (#861, guest thin LTO — its own commit message measures −2.28%/−2.37% guest cycles on the ethrex fixtures) and d83b4d9e (#863, GPU continuation proving) have landed between it and this branch. Roughly 2 points of the −44.1% belong to #861.

More importantly the fixture is unrepresentative: this PR's own /bench on the real block reports −10.4% prove time and +2.1% peak heap against the body's −32% / −35%. The two reconcile — 3 hints per ecrecover × 29 ecrecovers × ~67k cycles ≈ −11% of the real block's 50.78M guest cycles — because the 20-transfer fixture runs 9.16 ECSM per Mcycle versus a real block's 2.28, so the work this PR accelerates is ~4× denser there. The win is real and well separated (0.7% spread); the suggestion is just to lead with the real-block number. The +2.1% heap is not this table — a marginal always-on AIR measures ~5 MB — it is that cutting guest cycles packed the same crypto into 11 epochs instead of 13.

Merge-order hazard, for whoever lands this

#874 also takes FIXED_TABLE_COUNT 10 → 11, at byte-identical lines (86, 546, 621). Whichever merges second must resolve to 12, not keep 11. prove_elfs_tests.rs:2825 asserts the length — but nothing asserts the two AIR lists agree in order, and lines 546 and 621 are separate conflict hunks, so an independent resolution can order hint and dma differently in the prover and verifier lists. That surfaces only as a prove/verify failure. ~10 lines zipping the lists by name would close it permanently. #896 stacks on #874 and #879 on this branch, so it is a four-PR sequence.

jotabulacios and others added 2 commits August 6, 2026 10:16
Review follow-ups for #876: the CI gap, three stale soundness comments, and three test gaps
k256 0.13.4 swaps its FieldElement implementation on `debug_assertions`
(arithmetic/field.rs): debug selects the magnitude-tracking `field_impl`
wrapper, release selects the raw `FieldElement5x52`. The guest ELF is built
with `cargo build --release`, so every hint-verification test was exercising
an implementation the guest never compiles -- and `test-ethrex-crypto` was
the only test step in pr_main.yaml without `--release`.

The two builds are not interchangeable for these tests. `ConstantTimeEq`
differs between them: the debug wrapper compares the magnitude and normalized
tags alongside the limbs, the release type compares limbs only. A
magnitude-contract violation would panic loudly in the tested build and
compute a silently wrong value in the shipped one.

Keep both: release is what ships, and debug's magnitude asserts turn a
contract violation into a panic rather than a wrong answer.

@MauroToscano MauroToscano left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Lifting my change request — the blocking item is fixed.

#905 merged, so feat/hint-ecall now carries both:

  • cargo test --release -p executor --lib in the test-executor job, so the tests covering HintUnknownSelector, HintAddressOverflow and the three selectors actually run. Those include the ones 5c55e4c1 added, which were previously unexecuted anywhere.
  • test-ethrex-crypto running both profiles. k256 0.13.4 swaps its FieldElement implementation on debug_assertions, and the guest ships --release — so the hint-verification tests had been exercising an implementation the guest never compiles, in the one workflow step without --release.

Verified both are present on the branch; CI is running.

On the operand work that landed after the original approval — I reviewed those commits and they hold up:

  • 5c55e4c1 is complete, not just similarly named: the third BusInteraction, the matching LtOperation in the trace builder, lt_count += 2 → += 3, and the capacity bump. It derives the same 7-value window independently.
  • d3a85f49 is better than the const _: () = assert! I had proposed and supersedes it — is_valid_hint_selector as a single source of truth with set equality asserted in both directions catches a fourth selector added below the bound, which mine would not have. I dropped mine in its favour.

The design itself I could not break: an adversarial sweep of 5,138 end-to-end ecrecover comparisons plus ~7,000 per-seam, debug and release against the vendored k256, found no hint for which field_inv, scalar_inv or decompress_r returns anything other than the software result, and the fallback cannot be skipped.

Two things left for whoever merges, neither blocking:

Merge order with #874. Both take FIXED_TABLE_COUNT 10 → 11 at byte-identical lines (86, 546, 621). Whichever lands second must resolve to 12. prove_elfs_tests.rs:2825 asserts the length, but nothing asserts the two AIR lists agree in order — and 546 and 621 are separate conflict hunks, so an independent resolution can order hint and dma differently in the prover and verifier lists, which surfaces only as a prove/verify failure. ~10 lines zipping the lists by name would close it permanently. #896 stacks on #874 and #879 on this branch, so it is a four-PR sequence.

Body numbers are stale — 41 columns not 37, and 27 bus interactions after 5c55e4c1, not 22; and the Impact table is baselined at 5fd961a0, which predates #861's guest thin LTO (worth ~2 points of the −44.1%). The real-block /bench on this PR reports −10.4% prove time and +2.1% peak heap against the body's −32%/−35%; the two reconcile via ecrecover density, and the heap delta is the epoch-count shift, not the new table. Worth leading with the real-block figure.

@jotabulacios
jotabulacios enabled auto-merge August 7, 2026 12:56
@jotabulacios

Copy link
Copy Markdown
Collaborator Author

/bench

@jotabulacios
jotabulacios added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit 58160b6 Aug 7, 2026
23 checks passed
@jotabulacios
jotabulacios deleted the feat/hint-ecall branch August 7, 2026 19:34
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.

4 participants