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(theetherlink/.changes/node/fragment mechanism does not exist onetherlink-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:
Journal::discard_txbecomes a no-op.- The
creditcheckpoint installed for inbound cross-runtime (NAC) calls is now reverted on every error path out ofrun_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 = 1write is reverted mid-flight, so thecatchbranch lands oncount = 1instead of2— 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_callerthen 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
CALLframe'scheckpoint_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 whenrun_transactionreturnsOk, so a failed transaction's journal is simply dropped. revm explicitly allows this: "the journal … should be cleared by callingJournalTr::discard_txmethod or dropped" (revm-handler,Handler::rundocs).
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