Skip to content

Feat/hint ecall - #876

Open
jotabulacios wants to merge 15 commits into
mainfrom
feat/hint-ecall
Open

Feat/hint ecall#876
jotabulacios wants to merge 15 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 — ethrex 20 transfers (median of 3)

Table parallelism: auto (cores / 3)

Metric main PR Δ
Peak heap 50915 MB 32322 MB -18593 MB (-36.5%) 🟢
Prove time 25.264s 17.294s -7.970s (-31.5%) 🟢

🎉 Improvement detected — heap or time decreased by more than 5%.

✅ Low variance (time: 3.5%, heap: 2.5%)

Commit: 71ba613 · Baseline: cached · Runner: self-hosted bench

@github-actions

github-actions Bot commented Jul 29, 2026

Copy link
Copy Markdown

Benchmark Results for modified programs 🚀

Command Mean [ms] Min [ms] Max [ms] Relative
head ecsm 3.4 ± 0.1 3.3 3.7 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head hashmap 112.2 ± 2.9 108.6 118.5 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head keccak 131.2 ± 4.0 127.1 140.7 1.00
Command Mean [ms] Min [ms] Max [ms] Relative
head syscall_commit 96.5 ± 1.3 95.2 98.7 1.00

@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
/// We compute the recovery directly rather than calling k256's
/// `recover_from_prehash`, which internally runs a *second* lincomb to
/// re-verify the key — doubling the ECSM ecalls for no gain here.
/// Obtain a 32-byte big-endian hint for `x_be` via the executor `hint` ecall

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.

get_hint was inserted between ecsm_ecrecover's doc block and its signature, so the 17-line doc comment above (lines 63-79: "Recover the uncompressed public key bytes … doubling the ECSM ecalls for no gain here.") now documents get_hint, and ecsm_ecrecover ends up undocumented. Move the three new helpers above line 63 (or below ecsm_ecrecover).

/// then `Q = A + B` is one affine addition. All three inversions are batched.
///
/// Generic over the oracle so unit tests can substitute a software stand-in.
/// Base-field inverse `x⁻¹ mod p`. On riscv64 the host supplies it via the

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.

Same doc-misattachment here: field_inv is inserted between lincomb2_with_oracle's doc block and its #[cfg]/signature, so the whole chord-addition derivation ("The lambda-vm ECSM precompile returns only x(k·P) … Generic over the oracle so unit tests can substitute a software stand-in.") now documents field_inv, and lincomb2_with_oracle loses its docs. Move field_inv above that doc block.

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

Copy link
Copy Markdown

AI Review

PR #876 · 24 changed files

Findings

Status Sev Location Finding Found by
confirmed high prover/src/tables/trace_builder.rs:1080 Missing MEMW register reads for Hint ecall operands in trace builder nemotron
openrouter/nvidia/nemotron-3-ultra-550b-a55b
moonmath
zro/minimax-m3
confirmed low crypto/ethrex-crypto/src/lib.rs:82 Doc comment above ecsm_ecrecover is malformed (extra doc paragraph for get_hint merged into ecsm_ecrecover's block) moonmath
zro/minimax-m3
confirmed low executor/programs/rust/hint_min/.cargo/config.toml:7 Hint guest .cargo/config.toml has CC/CFLAGS but ecsm/keccak guests do not — likely inconsistent copy-paste from ethrex moonmath
zro/minimax-m3
confirmed low prover/src/tables/hint.rs:86 debug_assert on hint timestamp <= u32::MAX is the only defense against T_hi != 0 moonmath
zro/minimax-m3

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

AI-001: Missing MEMW register reads for Hint ecall operands in trace builder
  • Status: confirmed
  • Severity: high
  • Location: prover/src/tables/trace_builder.rs:1080
  • Found by: nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b, moonmath:zro/minimax-m3
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

collect_hint_ops reads registers x10, x11, x12 but does not emit MEMW register-read operations or update register_state timestamps, unlike collect_ecsm_ops which does both.

Evidence

In collect_hint_ops (lines 1080-1120), hint_id/in_addr/out_addr are read via register_state.read() but no MemwOperation is pushed for these register reads and register_state.write() is not called to update timestamps. In contrast, collect_ecsm_ops (lines 882-980) explicitly creates MEMW register reads for x11, x12, x10 at timestamps T, T+1, T+2 and calls register_state.write() after each. The CPU's register collector (collect_register_ops_from_cpu) only handles regular instruction registers (rs1/rs2/rd), not ecall argument registers (a0-a7).

Suggested fix

Either explicitly emit MemwOperation rows for x10/x11/x12 (matching the pattern of the other ECALL handlers) and advance register_state.write(..., t), or extend the doc comment to explain why omitting them is sound (currently it only justifies the memory-side omission). Consistency with the other ECALL handlers is the safest choice.

AI-005: Doc comment above ecsm_ecrecover is malformed (extra doc paragraph for get_hint merged into ecsm_ecrecover's block)
  • Status: confirmed
  • Severity: low
  • Location: crypto/ethrex-crypto/src/lib.rs:82
  • Found by: moonmath:zro/minimax-m3
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The PR adds a new doc-comment block for fn get_hint but inserts it inline in the middle of fn ecsm_ecrecover's existing doc comment (between its existing trailing description and its body), producing a single oversized /// block that rustdoc will attribute to ecsm_ecrecover rather than get_hint. Functionally harmless but the documentation is now misleading: /// Obtain a 32-byte big-endian hint ... BENCH scaffolding. appears in ecsm_ecrecover's docs.

Evidence

Lines 74-93: the text starting at line 83 ('Obtain a 32-byte big-endian hint for x_be via the executor hint ecall ...') is a /// comment followed by #[cfg(target_arch = "riscv64")] fn get_hint(...). There is no /// line break terminating ecsm_ecrecover's block before this new paragraph, so the entire combined block is parsed as ecsm_ecrecover's doc comment.

Suggested fix

Close the ecsm_ecrecover doc block with /// (or */) before the new get_hint block, or move the get_hint doc to immediately precede fn get_hint.

AI-006: Hint guest .cargo/config.toml has CC/CFLAGS but ecsm/keccak guests do not — likely inconsistent copy-paste from ethrex
  • Status: confirmed
  • Severity: low
  • Location: executor/programs/rust/hint_min/.cargo/config.toml:7
  • Found by: moonmath:zro/minimax-m3
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

The new hint_min and hint_multi .cargo/config.toml files duplicate the CC_riscv64im_lambda_vm_elf / CFLAGS_riscv64im_lambda_vm_elf block from ethrex/ckzg, but the simpler ecsm and keccak guests (which use the same lambda-vm-syscalls dependency) build without it. The hint_min/hint_multi source uses no std::* types, so the CC/CFLAGS block is likely unnecessary dead build configuration copy-pasted from the ethrex template, or ecsm/keccak are missing it and need to be updated for consistency. Either way it's a consistency smell that will confuse future guest authors.

Evidence

Programs using lambda-vm-syscalls with std (ethrex, ckzg) set CC/CFLAGS; programs using syscalls without std (ecsm, keccak) don't. hint_min / hint_multi use syscalls without std but copy the CC/CFLAGS block verbatim from ethrex.

Suggested fix

Drop the [env] CC/CFLAGS section from hint_min and hint_multi (they don't need a C linker for std), or — if they are required for these specific builds — document why in a comment so the difference vs ecsm/keccak isn't mysterious.

AI-009: debug_assert on hint timestamp <= u32::MAX is the only defense against T_hi != 0
  • Status: confirmed
  • Severity: low
  • Location: prover/src/tables/hint.rs:86
  • Found by: moonmath:zro/minimax-m3
  • Verified by: deepseek-verifier:openrouter/deepseek/deepseek-v4-pro
  • Rejected by: -

Claim

generate_hint_trace debug-asserts op.timestamp <= u32::MAX so TIMESTAMP_1 is 0 and matches the CPU's ECALL send (which always sends ts_hi=0). The assert disappears in release builds; if a future change ever pushes a timestamp above u32::MAX, the HINT table would silently produce TIMESTAMP_1 != 0 and unbalance the bus only when verifier inputs the malicious trace. ECSM does not have an equivalent guard. This is a latent soundness cliff rather than an active bug.

Evidence

prover/src/tables/hint.rs line 86-90: debug_assert!(op.timestamp &lt;= u32::MAX as u64, ...) then set_dword_wl(row, cols::TIMESTAMP_0, op.timestamp) which packs both lo/hi without checking the high limb. The CPU's ECALL sender (prover/src/tables/cpu.rs line 970-984) hard-codes ts_hi=0. If hint's TIMESTAMP_1 != 0 the Ecall LogUp bus payload would mismatch the CPU's send.

Suggested fix

Either (a) make the assert a runtime error (return Err) so it cannot be optimized away, or (b) document the invariant in the bus_interactions() comment so any future contributor doesn't bypass it, or (c) drop the assert and add a transition constraint on TIMESTAMP_1 == 0 in the HINT AIR.

Reviewer Lanes

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

Verification Lanes

Lane Model Status Confirmed Rejected Uncertain
deepseek-verifier openrouter/deepseek/deepseek-v4-pro success 4 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
  • scalar_inv uses ct_eq despite field_inv avoiding it for normalization metadata (crypto/ethrex-crypto/src/lib.rs:106, found by kimi:openrouter/moonshotai/kimi-k2.7-code) — k256's Scalar type is based on crypto-bigint's U256 (plain [u64; 4] with no magnitude/normalized metadata), unlike FieldElement which wraps FieldElementImpl (which has magnitude and normalized tags for lazy reduction). The ct_eq on Scalar compares raw limbs directly and works correctly after mul. The field_inv comment at lines 287-291 correctly explains why ct_eq doesn't work for FieldElement, but that reasoning does not apply to Scalar. The finding's evidence about 'same U256-based representation' is incorrect — FieldElement's internal metadata is unique to the field element type.
  • No operand overlap check for hint in_addr/out_addr in executor (executor/src/vm/instruction/execution.rs:520, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The ECSM overlap check (addr_xg.abs_diff(addr_k) < 32 at line 508) prevents a MEMW ordering problem: both xG and k are READ via the MEMW bus at different timestamps (T and T+1), and overlapping regions would make timestamp ordering inconsistent. For Hint, the input read is intentionally NOT modeled on the MEMW bus (comment at lines 19-21 of hint.rs and lines 972-973 of trace_builder.rs), so there is no such ordering issue. The executor reads input first then writes output, so even overlapping buffers produce correct results. No overlap guard is needed.
  • Hint syscall uses big-endian byte order while ECSM uses little-endian; comment warns but no static enforcement (executor/src/vm/instruction/execution.rs:430, found by nemotron:openrouter/nvidia/nemotron-3-ultra-550b-a55b) — The comment at lines 430-432 documents the endianness convention (hint uses BE as k256's native format; ECSM uses LE because its chip consumes LE limbs). This is accurate documentation, not a bug. The finding merely observes there is no type-level enforcement, which is true but is normal for a documentation convention — the code functions correctly. Not a real issue.
  • Hint ecall comment claims input is 'big-endian' but executor stores it with load_u256_le / store_u256_le (executor/src/vm/instruction/execution.rs:521, found by moonmath:zro/minimax-m3) — The code comment at lines 524-525 explicitly states: 'The _le helpers only move bytes in address order, which is what a raw big-endian buffer needs.' The functions load_u256_le and store_u256_le read/write doublewords via load_doubleword/store_doubleword and reassemble via to_le_bytes/from_le_bytes, which preserves byte order in memory (they are effectively byte-order-agnostic for BE data). The names are misleading but the behavior is correct and documented. No bug exists.

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

@jotabulacios
jotabulacios marked this pull request as ready for review July 30, 2026 17:19
@jotabulacios jotabulacios mentioned this pull request Jul 30, 2026
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