Skip to content

Review follow-ups for #876: the CI gap, three stale soundness comments, and three test gaps - #905

Merged
jotabulacios merged 5 commits into
feat/hint-ecallfrom
review/hint-fixes
Aug 6, 2026
Merged

Review follow-ups for #876: the CI gap, three stale soundness comments, and three test gaps#905
jotabulacios merged 5 commits into
feat/hint-ecallfrom
review/hint-fixes

Conversation

@MauroToscano

@MauroToscano MauroToscano commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review follow-ups for #876, as commits rather than comments so they can be taken or dropped
individually. Targets feat/hint-ecall, so merging brings them into #876 itself.

Rebased and slimmed. This originally carried the out_addr range-check, the "BENCH ONLY" label
removal, the hint_min alignment comment and a selector-bound const-assert. All four have since
landed independently on feat/hint-ecall (5c55e4c1, 1fa60ec6, 2a5a6120, d3a85f49) and have
been dropped rather than merged over. I checked both of the substantive ones rather than assuming
name-equivalence:

  • 5c55e4c1 (out_addr) is complete and equivalent — the third BusInteraction on ADDR_OUT_0,
    the matching LtOperation in the trace builder, lt_count += 2 → += 3 in count_table_lengths,
    and with_capacity(26) → 27. It derives the same seven-value window independently. Nothing left to
    do there.
  • d3a85f49 (selector bound) is better than what this PR had and supersedes it. It introduces
    is_valid_hint_selector as a single source of truth and const-asserts set equality in both
    directions; the const-assert here only checked the three constants were 0/1/2 and that the bound was
    one past, which would not have caught a fourth selector added below the bound. The AIR re-exports
    HINT_SELECTOR_BOUND rather than restating it, so there is no second declaration to drift.

What remains is five commits: one CI gap, three still-stale comments, and three test gaps.

The design itself holds up — I could not break the verify-then-fallback. An adversarial sweep of
5,138 end-to-end ecrecover comparisons plus ~7,000 per-seam, in both debug and release against the
vendored k256, found no hint for which field_inv, scalar_inv or decompress_r returns anything
other than what the pure-software path returns, and the fallback cannot be skipped. y = 0 is
unreachable, to_bytes() normalises before serialising, and the asm! block cannot cache a stale
*out.

The CI gap — d7380474

test-executor runs only --test asm, --test rust, --test flamegraph and a test_ckzg-filtered
--ignored step. Because executor/src/lib.rs has #[cfg(test)] pub mod tests;, everything under
executor/src/tests/ is a lib target that no CI command selects — including the hint tests that
are the only coverage anywhere for HintUnknownSelector, HintAddressOverflow (both operands, plus
the accepted 2^32 − 32 boundary) and compute_hint's three selectors.

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, with executor absent — so nothing anywhere runs
them.

One step in an already-required job, and no added compile time: the lib test binary is already built
by the test_ckzg step. The mechanism is pre-existing (ecsm_tests, keccak_tests, memory_tests
were equally dark), so this is a missed opportunity rather than a regression in #876 — but it means
the operand-validation tests added by 5c55e4c1 are currently unrun too.

Three comments that would mislead an auditor — 23384f9c

These matter more than usual here, because in this table the doc comments are the soundness
argument. All three were checked against the current branch, and the operand work left them behind.

prover/src/tables/hint.rs — the HintConstraints doc says the LogUp argument "already fixes
mu's value via the timestamp-unique Ecall tuple". That is the exact opposite of the module doc
~300 lines above, which correctly proves the Ecall bus pins only the sum of mu (the timestamp
is a free witness column) and that IS_BIT is therefore load-bearing. git log -S dates the stale
sentence to ad21c37a and the correct analysis to be9066bf — the later reversed the earlier and
the sentence was left behind.

It is the stated justification for the table's only algebraic constraint, so a future cleanup reading
it deletes IS_BIT(mu) and reopens the +1/+1/−1 spread-multiplicity hole. The same doc block also
still lists the table's bus surface as one register read and no LT senders, which is now three reads
and three LT senders.

prover/src/test_utils.rscreate_hint_air's doc still describes "x12 register read, four
output MEMW writes", with no mention of the x10/x11 reads or the three LT senders.

crypto/ethrex-crypto/src/lib.rs — the comment justifying negate(y2) over negate(rhs) claims
that negating rhs "would silently compute the wrong value in release". Against vendored k256 0.13.4
that is false: negate(m) is 2*(m+1)*P_limb − self and is value-correct at any magnitude absent
limb underflow, and for a magnitude-2 operand 4*P_limb − self ≥ 0. The real consequence is tripping
its debug assert. The code's choice is right; only its stated reason is wrong — which matters,
because a reader who believes negate is value-incorrect above its declared magnitude may "fix" real
code on that basis.

Test gaps

0b93d949 — pin the guest's selector constants against the executor's. d3a85f49 binds
executor ↔ AIR. The guest is a third declaration: lambda-vm-syscalls re-declares the same
selectors as usize, in a workspace-excluded crate, tied to nothing but a comment. That is a
different property and still unguarded.

The failure mode is silent, which is what makes it worth a test: a selector divergence makes the
verify-then-fallback swallow the wrong answer as "host lied", so the guest recomputes in software and
returns a correct result ~2000× slower, with nothing failing anywhere. Adds a
#[cfg(test)]-only dev-dependency on syscalls; verified it cannot reach the non-test build
(cargo tree -p executor --edges normal has zero occurrences) and that the guest-only hazards in
that crate — global_allocator, entrypoint — are already cfg-gated.

762b3540 — exercise all three selectors in hint_multi. The guest called HINT_FIELD_INV
three times, so HINT_SCALAR_INV and HINT_FIELD_SQRT reached the prover nowhere. Now covers all
three at no extra CI cost: same guest, same single prove, expectation in prove_elfs_tests updated
to match. Both ELFs were rebuilt so the test runs against the new binary rather than a stale
artifact.

0c29db1b — negated-sqrt and canonical-but-wrong hints. Two gaps in the guest-side coverage.
Both lie shapes previously tested ([0;32], [0xFF;32]) die in parsing, so the verify predicate's
rejecting branch was never exercised on a canonical value. And the negated-sqrt parity arm was
covered only by accident, via the parity of four hard-coded k values — I confirmed the branch is
live today by disabling the negation and watching decompress_r_honest_hint_matches_software fail,
but nothing pinned it.

A note on the tamper tests

Worth stating plainly, because it applies to the operand tests on both sides of this rebase: the
tamper-style tests for ADDR_OUT_0, ADDR_IN_0 and SEL_0 are defence-in-depth, not isolation
tests.
I measured this by reverting the range-check and re-running — editing ADDR_OUT_0 also
unbalances the x12 register read and the four MEMW writes, so the proof is rejected with or without
the LT sender. The two pre-existing siblings admit as much in their own doc comments.

The structural test is the one with teeth: with the fix reverted it fails (left: 2, right: 3). That
follows the precedent test_hint_binds_out_addr_to_x12 already set in that file for exactly this
situation, and it is worth preferring for the LT senders too.

Verification

On the rebased base, both hint guest ELFs rebuilt: cargo test --release -p executor --lib 36/36
· crypto/ethrex-crypto 24/24 · cargo test --release -p lambda-vm-prover hint 13/13 ·
make lint exit 0 (fmt-check + 4 clippy configurations, zero non-nvcc diagnostics).

Not included — for the author

  • The PR 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 — the checks that make the AIR's accepted set match the executor's, and so the ones an
    auditor most needs to see.

  • The Impact table needs re-baselining. 5fd961a0 was the right control when written, but two
    perf PRs have landed between it and this branch — 9ccdaf28 (perf(programs): build the ethrex guest with thin LTO #861, guest thin LTO, whose own commit
    message measures −2.28%/−2.37% guest cycles on the ethrex fixtures; confirmed present in the
    treatment's guest manifest and absent at 5fd961a0) and d83b4d9e (perf(prover): halve GPU continuation proving time  #863, GPU continuation
    proving). Roughly 2 points of the −44.1% belong to perf(programs): build the ethrex guest with thin LTO #861.

    More importantly the fixture is not representative: 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 against a real block's 2.28, so the work
    this PR accelerates is ~4× denser there. The prove-time 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 the new table: a marginal always-on AIR measures ~5 MB (from the
    feat/dma memcpy #874-vs-Perf/dma tail wide memset #896 stacked pair, with epoch count held fixed). Epochs are capped in guest cycles, so
    cutting cycles packed the same crypto into 11 epochs instead of 13, and peak heap moves in ~1 GiB
    quanta from power-of-two table padding.

  • FIXED_TABLE_COUNT coordination with feat/dma memcpy #874. Both take it 10 → 11 at byte-identical lines (86,
    546, 621), and Perf/dma tail wide memset #896 stacks on feat/dma memcpy #874 while Perf/ecsm affine selector #879 stacks on this branch. Whichever merges second must
    resolve to 12. A length assertion exists (prove_elfs_tests.rs:2825) 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 them differently, which surfaces only as a prove/verify failure.
    ~10 lines zipping the lists by name would close that permanently.

  • /profile_recursion was never run on this PR (the workflow exists and is one comment). An
    always-on table adds per-sub-proof cost to the recursion verifier in guest cycles and ~160–230 KB
    per epoch proof; unmeasured across feat/dma memcpy #874/Feat/hint ecall #876/Perf/dma tail wide memset #896 alike.

  • make lint does not reach crypto/ethrex-crypto (own [workspace]), and it is drifting —
    cargo fmt --check there exits 1 with 86 diff lines at the merge-base. That is what the "test
    churn" in ecrecover_tests.rs/ecsm_tests.rs/keccak_tests.rs actually is: pure reformat, no new
    content.

  • The rust-ELF cache key omits crypto/ethrex-crypto/**, though it is a path dep of the ethrex guest
    and now carries the hint safety argument. This PR is safe (it touches keyed paths), but a future PR
    touching only that crate would restore a stale ethrex.elf. The recursion-ELF key two jobs down
    already includes crypto/**/src/**.

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 changed the title Review follow-ups for #876: out_addr range-check, the CI gap, stale soundness docs, selector coverage Review follow-ups for #876: the CI gap, three stale soundness comments, and three test gaps Aug 5, 2026
@MauroToscano MauroToscano mentioned this pull request Aug 5, 2026
@jotabulacios
jotabulacios merged commit a0c52f1 into feat/hint-ecall Aug 6, 2026
20 checks passed
@jotabulacios
jotabulacios deleted the review/hint-fixes branch August 6, 2026 13:16
@MauroToscano

Copy link
Copy Markdown
Contributor Author

Added one more commit (d1a5ffe3) after a late finding on the CI step this PR introduces.

The hint tests were running under a different k256 field implementation than the guest ships. k256 0.13.4 swaps FieldElement on debug_assertions (arithmetic/field.rs:17-31): debug selects the magnitude-tracking field_impl wrapper, release selects the raw FieldElement5x52. The guest ELF is built --release, and test-ethrex-crypto was cargo test with no profile flag — the only test step in pr_main.yaml without --release.

So every test of the verify-then-fallback logic exercised code the guest never compiles. That matters here specifically because the two builds differ on exactly the operation these tests turn on: ConstantTimeEq compares the magnitude and normalized tags alongside the limbs in debug (field_impl.rs:163-169), and compares limbs only in release (field_5x52.rs:483-491). A magnitude-contract violation would panic loudly in the tested build and compute a silently wrong value in the shipped one.

Incidentally this also means the code comment in field_inv_with_oracle explaining why ct_eq is unusable — it cites the tag comparison — is describing debug-only behaviour. The conclusion still holds in release, but for a different reason (mul output is weak-normalized).

The target now runs both profiles. Release because it is what ships; debug retained because its magnitude debug_asserts turn a contract violation into a panic rather than a wrong answer. 24/24 in release, verified locally.

Also worth recording, as a coverage limit rather than a defect: on the host ecsm_lincomb2 is the cfg(not(riscv64)) stub returning None, so field_inv is not reachable from a host-side ecsm_ecrecover — the composition tests cover scalar_inv and decompress_r, not the field inverse. Its unit coverage via field_inv_with_oracle is unaffected. Not fixed here; noting it so nobody assumes the end-to-end sweep covered all three seams.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants