fix(eip8130): surface mid-call database errors as fatal, not reverts#3748
Conversation
EIP-8130 calls are dispatched as depth-0 top-level EVM frames via `run_call`. revm only converts a frame's recorded database error into an `Err` when a child frame's outcome is folded into its parent (`EthFrame::return_result` -> `take_error`); the root frame is returned directly by `run_exec_loop` without that check. As a result, a node-local DB failure raised mid-call (e.g. an `SLOAD` against a missing trie node) halted the interpreter with `FatalExternalError` and came back as a non-ok `InterpreterResult`, which `execute_calls` then folded into the deterministic "phase reverted" path. That is consensus-unsafe: a node hitting the DB failure would include the transaction as reverted (nonce consumed, fee paid) while a healthy node would execute it normally, forking the chain. Mirror the mainnet `Handler::execution_result` guard by calling `take_error` after `run_exec_loop` in `run_call`, so a database error propagates as a fatal `EVMError::Database` instead of a call revert. Add `db_failure_during_call_propagates_as_error_not_revert`, which runs a call whose `SLOAD` is refused by a wrapping database and asserts the transaction fails with `EVMError::Database` rather than being included as a revert.
✅ Heimdall Review Status
|
…ror teardown Two EVM-equivalence / robustness fixes to the phased-call executor, which bypasses the mainnet single-frame handler and so misses two of its steps. 1. Pre-call account warming (EIP-3651 / EIP-2929 equivalence). The 8130 path skips the handler's `load_accounts`, so dispatched calls ran against the journal's default spec id with a cold coinbase and an unwarmed precompile set — charging cold access (2600) where a call in a normal transaction is charged warm (100). Add `warm_pre_call_accounts`, mirroring `pre_execution::load_accounts` (minus access-list handling, which 8130 has no analogue for): set the journal EVM spec id, warm the precompile addresses, and warm the coinbase from Shanghai onward, invoked right before `execute_calls`. 2. Centralized post-error teardown. The `execute_calls` / `settle_fees` error paths previously only reverted the checkpoint and cleared the L1 cost. A database error inside a nested subcall surfaces while the parent frame is still on the stack, so without draining it stale frame/local state could leak into the next transaction reusing the same `BaseEvm`. Add `teardown_after_error`, mirroring the mainnet `catch_error` cleanup (checkpoint revert + clear L1 cost + `local_mut().clear()` + `frame_stack().clear()`), and use it on both error paths. Add `call_warms_coinbase_per_eip3651`: a `BALANCE(coinbase)` call must be strictly cheaper than a byte-identical `BALANCE` of an untouched address, which only holds when the coinbase is pre-warmed.
| /// a cold coinbase / precompile set — charging cold-access gas (2600) where a | ||
| /// call in a normal transaction is charged warm (100) and otherwise drifting | ||
| /// from EVM equivalence on the same chain. | ||
| fn warm_pre_call_accounts<DB, I, P>(evm: &mut BaseEvm<DB, I, P>) |
There was a problem hiding this comment.
We may also add the account config contract here.
Review SummaryThis PR fixes three correctness issues in the EIP-8130 phased-call executor. I reviewed the diff thoroughly against the mainnet handler patterns it mirrors. Assessment: No blocking issues foundFix 1 — Fix 2 — Fix 3 — Tests: Both new tests are well-designed — they verify the fix by testing the exact failure mode (DB error surfacing as |
✅ base-std fork tests: all 616 passedbase/base is fully in sync with the base-std spec.
|
Summary
Follow-up to #3696. Three fixes to the EIP-8130 phased-call executor, which runs
callsdirectly and bypasses the mainnet single-frame handler — and with it several of the handler's steps.1. Mid-call DB errors must be fatal, not reverts (consensus-critical)
EIP-8130 calls are dispatched as depth-0 top-level frames via
run_call. revm only converts a frame's recorded DB error into anErrwhen a child frame's outcome is folded into its parent (EthFrame::return_result→take_error); the root frame is returned directly byrun_exec_loopwithout that check. So a DB failure during a call (e.g. anSLOADagainst a missing trie node) halted the interpreter withFatalExternalErrorand came back as a non-okInterpreterResult, whichexecute_callsfolded into thephase_revertedpath.This is consensus-unsafe: a node hitting the DB failure would include the tx as reverted (nonce consumed, fee paid), while a healthy node would execute it normally — a fork. Every other tx type is protected via
Handler::execution_result'stake_errorguard; the 8130 path bypasses it.Fix: call
take_errorafterrun_exec_loopinrun_call.2. Warm coinbase + precompiles + set journal spec before calls (EVM equivalence)
The 8130 path skips the handler's
load_accounts, so dispatched calls ran against the journal's default spec id with a cold coinbase and an unwarmed precompile set — charging cold access (2600) where a call in a normal tx is charged warm (100). Deterministic, but a gas/EVM-equivalence bug.Fix:
warm_pre_call_accountsmirrorspre_execution::load_accounts(minus access-list handling, which 8130 has no analogue for): sets the journal EVM spec id, warms precompiles, and warms the coinbase (Shanghai+), invoked right beforeexecute_calls.3. Centralized post-error teardown (defensive)
The
execute_calls/settle_feeserror paths only reverted the checkpoint and cleared the L1 cost. A DB error inside a nested subcall surfaces while the parent frame is still on the stack, so stale frame/local state could leak into the next tx reusing the sameBaseEvm.Fix:
teardown_after_errormirrors the mainnetcatch_errorcleanup (checkpoint revert + clear L1 cost +local_mut().clear()+frame_stack().clear()), used on both error paths.Tests
db_failure_during_call_propagates_as_error_not_revert— a call whoseSLOADis refused by a wrapping DB must fail withEVMError::Database, not be included as a revert. (Verified it fails without fix RPC Placeholders for flashblocks #1.)call_warms_coinbase_per_eip3651—BALANCE(coinbase)must be strictly cheaper than a byte-identicalBALANCEof an untouched address, which only holds when the coinbase is pre-warmed. (Verified it fails without fix Setup CI #2.)Test plan
cargo test -p base-common-evm --features std eip8130(13 passed)cargo clippy -p base-common-evm --features std --all-targetscleancargo check -p base-common-evm --no-default-features(no_std) compiles