Remove discard_tx inner call that would cause to drop outer journal frames

Checklist

  • Provide automatic testing.
  • Declare the changelog entry — this is a kernel change, so the entry goes to etherlink/CHANGES_TEZOSX.md (the etherlink/.changes/node/ fragment mechanism does not exist on etherlink-ganesha).
  • Add a page in the doc — N/A, bug fix with no user-visible API change.

What

Two fixes in etherlink/kernel_latest/revm:

  1. Journal::discard_tx becomes a no-op.
  2. The credit checkpoint installed for inbound cross-runtime (NAC) calls is now reverted on every error path out of run_transaction, not only on the "executed but failed" one.

Together they fix a state-corruption bug where an inbound same-runtime (EVM → EVM) NAC whose sub-transaction fails validation wiped the outer EVM transaction's journal — reverting state the outer frame had already written, and minting funds for the sender.

Why

Inbound NAC sub-executions reuse the outer EVM transaction's JournalInner: they are nested run_transaction calls on the same journal, not on a fresh one. Journal::commit_tx and Journal::take_logs were already overridden (no-op / clone-instead-of-drain) for exactly that reason. discard_tx was the remaining asymmetry — it still forwarded to JournalInner::discard_tx, which is tx-global:

journal.drain(..).rev().for_each(|entry| entry.revert(state, None, ...));
transient_storage.clear();
*depth = 0;
logs.clear();
*transaction_id += 1;

revm calls it from Handler::catch_error, i.e. whenever Handler::run returns Err. For an inbound NAC that includes a validation failure of the sub-transaction — e.g. GasFloorMoreThanGasLimit, when the gas forwarded by the gateway falls below the intrinsic cost. That error is classified as a 400, i.e. a catchable revert: the gateway precompile hands it back to the caller, the outer transaction keeps running and is eventually committed — on top of a journal that has just been drained.

Concretely, on an EVM contract that does count++ (SSTORE) before the gateway call and wraps the call in try/catch:

  • the pre-call count = 1 write is reverted mid-flight, so the catch branch lands on count = 1 instead of 2 — a partial commit of the outer transaction;
  • the outer transaction's own upfront gas deduction is a journal entry too, so it is reverted as well; post-execution reimburse_caller then credits the unused gas back on top. The sender's durable balance ends up higher than before the transaction (observed: ~+2855 mutez on the sweep below). That is a mint.

Independently, the credit balance increment applied for inbound cross-runtime calls was installed behind a checkpoint that was only unwound on the "executed but failed" path; the ? on inspect_run / execute_transaction and the take_deferred_error early return bypassed it and leaked the credit.

This configuration is only reachable with no Michelson interposition (EVM → gateway → EVM). With a Michelson hop in between, the inner EVM execution runs on its own journal, which is why it was not caught by the existing cross-runtime coverage.

How

revm/src/journal.rs — discard_tx is now a no-op, consistent with the existing commit_tx / take_logs overrides. Nothing is lost:

  • for a sub-execution, cleanup is the parent CALL frame's checkpoint_revert, which is correctly scoped to the sub-call;
  • for a top-level transaction, the journal is fresh per transaction (apply.rs) and is only committed when run_transaction returns Ok, so a failed transaction's journal is simply dropped. revm explicitly allows this: "the journal … should be cleared by calling JournalTr::discard_tx method or dropped" (revm-handler, Handler::run docs).

revm/src/lib.rs — the two ? on inspect_run / execute_transaction and the two take_deferred_error early returns now checkpoint_revert the credit checkpoint before propagating, so the inbound credit is unwound on every exit path. Both branches of the has_tracer() split are covered.

Manually testing the MR

crac_http_call_evm.sol gains runCatchWithGasLimit / _doCallWithGas, which let the caller cap the gas forwarded to the gateway. Since gas::convert(Ethereum, Ethereum, ·) is the identity, that gas limit is the single knob that walks the inbound call across the validation boundary.

The new test in etherlink/tezt/tests/cross_runtime.ml sweeps it:

forwarded gas inbound outcome A.count
50_000, 35_000 succeeds, value lands on the target EOA 3
30_000, 29_000 clean OOG / revert, caught 2
28_000 validate-400 — the arm that corrupted the outer journal 2 (was 1)

Two assertions run on every arm and fail if either fix is reverted:

  • dSender <= 0 — the sender's balance must never increase (catches the mint);
  • A.count >= 2 — the outer frame's pre-call SSTORE must survive (catches the shared-journal wipe).
dune exec etherlink/tezt/tests/main.exe -- c381evm
Edited by Aurélien Foucault

Merge request reports

Loading
Loading