ethrex_levm/vm.rs
1use crate::{
2 TransientStorage,
3 account::LevmAccount,
4 call_frame::{CallFrame, Stack},
5 db::gen_db::GeneralizedDatabase,
6 debug::DebugMode,
7 environment::Environment,
8 errors::{
9 ContextResult, ExceptionalHalt, ExecutionReport, InternalError, OpcodeResult, TxResult,
10 VMError,
11 },
12 gas_cost::{
13 STATE_BYTES_PER_AUTH_BASE, STATE_BYTES_PER_NEW_ACCOUNT, STATE_BYTES_PER_STORAGE_SET,
14 cost_per_state_byte as compute_cost_per_state_byte,
15 },
16 hooks::{
17 backup_hook::BackupHook,
18 hook::{Hook, get_hooks},
19 },
20 memory::{CallData, Memory},
21 opcode_tracer::LevmOpcodeTracer,
22 opcodes::OpCodeFn,
23 precompiles::{
24 self, SIZE_PRECOMPILES_CANCUN, SIZE_PRECOMPILES_PRAGUE, SIZE_PRECOMPILES_PRE_CANCUN,
25 },
26 tracing::LevmCallTracer,
27 validation_observer::ValidationObserver,
28};
29use bytes::Bytes;
30use ethrex_common::seeded_hash::{SeededHashMap, SeededHashSet};
31use ethrex_common::{
32 Address, BigEndianHash, H256, U256,
33 tracing::CallType,
34 types::{
35 AccessListEntry, Code, Fork, Frame, FrameMode, InitcodeBuffer, Log, Transaction, TxType,
36 block_access_list::BlockAccessListCheckpoint, fee_config::FeeConfig,
37 },
38};
39use ethrex_crypto::Crypto;
40use rustc_hash::{FxHashMap, FxHashSet};
41use std::{
42 cell::{OnceCell, RefCell},
43 collections::{BTreeMap, BTreeSet},
44 mem,
45 rc::Rc,
46};
47
48/// Storage mapping from slot key to value.
49pub type Storage = FxHashMap<U256, H256>;
50
51/// Addresses accessed by a transaction (EIP-2929). Seeded: contracts choose which
52/// addresses they touch.
53pub type AccessedAddresses = SeededHashSet<Address>;
54/// Storage slots accessed by a transaction, per address (EIP-2929). Seeded: contracts
55/// choose the slot keys.
56pub type AccessedStorageSlots = SeededHashMap<Address, SeededHashSet<H256>>;
57
58/// Specifies whether the VM operates in L1 or L2 mode.
59#[derive(Debug, Clone, Copy, Default)]
60pub enum VMType {
61 /// Standard Ethereum L1 execution.
62 #[default]
63 L1,
64 /// L2 rollup execution with additional fee handling.
65 L2(FeeConfig),
66}
67
68/// Execution substate that tracks changes during transaction execution.
69///
70/// The substate maintains all information that may need to be reverted if a
71/// call fails, including:
72/// - Self-destructed accounts
73/// - Accessed addresses and storage slots (for EIP-2929 gas accounting)
74/// - Created accounts
75/// - Gas refunds
76/// - Transient storage (EIP-1153)
77/// - Event logs
78///
79/// # Backup Mechanism
80///
81/// The substate supports checkpointing via [`push_backup`] and restoration via
82/// [`revert_backup`] or commitment via [`commit_backup`]. This is used to handle
83/// nested calls where inner calls may fail and need to be reverted.
84///
85/// Most fields are private by design. The backup mechanism only works correctly
86/// if data modifications are append-only.
87#[derive(Debug, Default)]
88pub struct Substate {
89 /// Parent checkpoint for reverting on failure.
90 parent: Option<Box<Self>>,
91 /// Fork of the enclosing transaction. Lets the warmth helpers treat precompile addresses as
92 /// always-warm without occupying a hashset slot (EIP-2929). Constant for a tx, so it is
93 /// carried forward across `push_backup` checkpoints.
94 fork: Fork,
95 /// Accounts marked for self-destruction (deleted at end of transaction).
96 selfdestruct_set: FxHashSet<Address>,
97 /// Addresses accessed during execution (for EIP-2929 warm/cold gas costs).
98 /// Precompiles are NOT stored here; they are warm by construction (see `is_warm_precompile`).
99 accessed_addresses: AccessedAddresses,
100 /// Storage slots accessed per address (for EIP-2929 warm/cold gas costs).
101 accessed_storage_slots: AccessedStorageSlots,
102 /// Accounts created during this transaction.
103 created_accounts: FxHashSet<Address>,
104 /// Accumulated gas refund (e.g., from storage clears).
105 pub refunded_gas: u64,
106 /// Transient storage (EIP-1153), cleared at end of transaction.
107 transient_storage: TransientStorage,
108 /// Event logs emitted during execution.
109 logs: Vec<Log>,
110}
111
112impl Substate {
113 pub fn from_accesses(
114 fork: Fork,
115 accessed_addresses: AccessedAddresses,
116 accessed_storage_slots: AccessedStorageSlots,
117 ) -> Self {
118 Self {
119 parent: None,
120 fork,
121 selfdestruct_set: FxHashSet::default(),
122 accessed_addresses,
123 accessed_storage_slots,
124 created_accounts: FxHashSet::default(),
125 refunded_gas: 0,
126 transient_storage: TransientStorage::default(),
127 logs: Vec::new(),
128 }
129 }
130
131 /// Whether `address` is a precompile that the EVM treats as warm from the start of the tx
132 /// (EIP-2929), exactly matching the addresses `Substate::initialize` used to pre-seed.
133 ///
134 /// Replicates the pre-seed *precisely* — the contiguous range `0x01..=max_for_fork` plus the
135 /// post-Osaka P256VERIFY address `0x100` — and is intentionally `vm_type`-independent, since
136 /// the old pre-seed was too. (Using `precompiles::is_precompile`, which gates `0x100` on L2
137 /// for any fork, would change L2 pre-Osaka warmth — a consensus difference, not an opt.)
138 #[inline]
139 fn is_warm_precompile(&self, address: &Address) -> bool {
140 // Fast reject: every pre-seeded precompile has 18 leading zero bytes (max is `0x01_00`),
141 // so real contract/EOA addresses bail out here, off the hot warmth path.
142 if address.0[..18] != [0u8; 18] {
143 return false;
144 }
145 let n = u16::from_be_bytes([address.0[18], address.0[19]]);
146 let max_contiguous: u64 = match self.fork {
147 f if f >= Fork::Prague => SIZE_PRECOMPILES_PRAGUE,
148 f if f >= Fork::Cancun => SIZE_PRECOMPILES_CANCUN,
149 _ => SIZE_PRECOMPILES_PRE_CANCUN,
150 };
151 (n >= 1 && u64::from(n) <= max_contiguous) || (n == 0x100 && self.fork >= Fork::Osaka)
152 }
153
154 /// Push a checkpoint that can be either reverted or committed. All data up to this point is
155 /// still accessible.
156 pub fn push_backup(&mut self) {
157 let parent = mem::take(self);
158 self.refunded_gas = parent.refunded_gas;
159 // Carry the fork forward so child checkpoints keep the same precompile-warmth view.
160 self.fork = parent.fork;
161 self.parent = Some(Box::new(parent));
162 }
163
164 /// Pop and merge with the last backup.
165 ///
166 /// Does nothing if the substate has no backup.
167 pub fn commit_backup(&mut self) {
168 if let Some(parent) = self.parent.as_mut() {
169 let mut delta = mem::take(parent);
170 mem::swap(self, &mut delta);
171
172 self.selfdestruct_set.extend(delta.selfdestruct_set);
173 self.accessed_addresses.extend(delta.accessed_addresses);
174 for (address, slot_set) in delta.accessed_storage_slots {
175 self.accessed_storage_slots
176 .entry(address)
177 .or_default()
178 .extend(slot_set);
179 }
180 self.created_accounts.extend(delta.created_accounts);
181 self.refunded_gas = delta.refunded_gas;
182 self.transient_storage.extend(delta.transient_storage);
183 self.logs.extend(delta.logs);
184 }
185 }
186
187 /// Discard current changes and revert to last backup.
188 ///
189 /// Does nothing if the substate has no backup.
190 pub fn revert_backup(&mut self) {
191 if let Some(parent) = self.parent.as_mut() {
192 *self = mem::take(parent);
193 }
194 }
195
196 /// Return an iterator over all selfdestruct addresses.
197 pub fn iter_selfdestruct(&self) -> impl Iterator<Item = &Address> {
198 struct Iter<'a> {
199 parent: Option<&'a Substate>,
200 iter: std::collections::hash_set::Iter<'a, Address>,
201 }
202
203 impl<'a> Iterator for Iter<'a> {
204 type Item = &'a Address;
205
206 fn next(&mut self) -> Option<Self::Item> {
207 let next_item = self.iter.next();
208 if next_item.is_none()
209 && let Some(parent) = self.parent
210 {
211 self.parent = parent.parent.as_deref();
212 self.iter = parent.selfdestruct_set.iter();
213
214 return self.next();
215 }
216
217 next_item
218 }
219 }
220
221 Iter {
222 parent: self.parent.as_deref(),
223 iter: self.selfdestruct_set.iter(),
224 }
225 }
226
227 /// Mark an address as selfdestructed and return whether is was already marked.
228 pub fn add_selfdestruct(&mut self, address: Address) -> bool {
229 if self.selfdestruct_set.contains(&address) {
230 return true;
231 }
232
233 let is_present = self
234 .parent
235 .as_ref()
236 .map(|parent| parent.is_selfdestruct(&address))
237 .unwrap_or_default();
238
239 is_present || !self.selfdestruct_set.insert(address)
240 }
241
242 /// Return whether an address is already marked as selfdestructed.
243 pub fn is_selfdestruct(&self, address: &Address) -> bool {
244 self.selfdestruct_set.contains(address)
245 || self
246 .parent
247 .as_ref()
248 .map(|parent| parent.is_selfdestruct(address))
249 .unwrap_or_default()
250 }
251
252 /// Build an access list from all accessed storage slots.
253 pub fn make_access_list(&self) -> Vec<AccessListEntry> {
254 let mut entries = BTreeMap::<Address, BTreeSet<H256>>::new();
255
256 let mut current = self;
257 loop {
258 for (address, slot_set) in ¤t.accessed_storage_slots {
259 entries
260 .entry(*address)
261 .or_default()
262 .extend(slot_set.iter().copied());
263 }
264
265 current = match current.parent.as_deref() {
266 Some(x) => x,
267 None => break,
268 };
269 }
270
271 entries
272 .into_iter()
273 .map(|(address, storage_keys)| AccessListEntry {
274 address,
275 storage_keys: storage_keys.into_iter().collect(),
276 })
277 .collect()
278 }
279
280 /// Mark an address as accessed and return whether the slot was cold.
281 pub fn add_accessed_slot(&mut self, address: Address, key: H256) -> bool {
282 if self
283 .accessed_storage_slots
284 .get(&address)
285 .is_some_and(|set| set.contains(&key))
286 {
287 return false;
288 }
289
290 let is_present = self
291 .parent
292 .as_ref()
293 .map(|parent| parent.is_slot_accessed(&address, &key))
294 .unwrap_or_default();
295
296 // Note: Do not simplify this expression, it uses `||` to avoid executing the right hand
297 // expression if not necessary.
298 #[expect(clippy::nonminimal_bool, reason = "order of evaluation matters")]
299 !(is_present
300 || !self
301 .accessed_storage_slots
302 .entry(address)
303 .or_default()
304 .insert(key))
305 }
306
307 /// Return whether an address has already been accessed.
308 pub fn is_slot_accessed(&self, address: &Address, key: &H256) -> bool {
309 self.accessed_storage_slots
310 .get(address)
311 .map(|slot_set| slot_set.contains(key))
312 .unwrap_or_default()
313 || self
314 .parent
315 .as_ref()
316 .map(|parent| parent.is_slot_accessed(address, key))
317 .unwrap_or_default()
318 }
319
320 /// Returns all accessed storage slots for a given address.
321 /// Used by SELFDESTRUCT to record storage reads in BAL per EIP-7928:
322 /// "SELFDESTRUCT: Include modified/read storage keys as storage_read"
323 pub fn get_accessed_storage_slots(&self, address: &Address) -> BTreeSet<H256> {
324 let mut slots = BTreeSet::new();
325
326 // Collect from current substate
327 if let Some(slot_set) = self.accessed_storage_slots.get(address) {
328 slots.extend(slot_set.iter().copied());
329 }
330
331 // Collect from parent substates recursively
332 if let Some(parent) = self.parent.as_ref() {
333 slots.extend(parent.get_accessed_storage_slots(address));
334 }
335
336 slots
337 }
338
339 /// Mark an address as accessed and return whether the address was cold.
340 pub fn add_accessed_address(&mut self, address: Address) -> bool {
341 // Precompiles are warm from tx start (EIP-2929) without occupying a hashset slot. Returns
342 // `false` (not cold) so cold-access gas is never charged — identical to the old pre-seed.
343 if self.is_warm_precompile(&address) {
344 return false;
345 }
346
347 if self.accessed_addresses.contains(&address) {
348 return false;
349 }
350
351 let is_present = self
352 .parent
353 .as_ref()
354 .map(|parent| parent.is_address_accessed(&address))
355 .unwrap_or_default();
356
357 // Note: Do not simplify this expression, it uses `||` to avoid executing the right hand
358 // expression if not necessary.
359 #[expect(clippy::nonminimal_bool, reason = "order of evaluation matters")]
360 !(is_present || !self.accessed_addresses.insert(address))
361 }
362
363 /// Return whether an address has already been accessed.
364 pub fn is_address_accessed(&self, address: &Address) -> bool {
365 // Precompiles are always warm; the chain shares one `fork`, so this is consistent across
366 // sub-frame substates.
367 self.is_warm_precompile(address)
368 || self.accessed_addresses.contains(address)
369 || self
370 .parent
371 .as_ref()
372 .map(|parent| parent.is_address_accessed(address))
373 .unwrap_or_default()
374 }
375
376 /// Mark an address as a new account and return whether is was already marked.
377 pub fn add_created_account(&mut self, address: Address) -> bool {
378 if self.created_accounts.contains(&address) {
379 return true;
380 }
381
382 let is_present = self
383 .parent
384 .as_ref()
385 .map(|parent| parent.is_account_created(&address))
386 .unwrap_or_default();
387
388 is_present || !self.created_accounts.insert(address)
389 }
390
391 /// Return whether an address has already been marked as a new account.
392 pub fn is_account_created(&self, address: &Address) -> bool {
393 self.created_accounts.contains(address)
394 || self
395 .parent
396 .as_ref()
397 .map(|parent| parent.is_account_created(address))
398 .unwrap_or_default()
399 }
400
401 /// Return the data associated with a transient storage entry, or zero if not present.
402 pub fn get_transient(&self, to: &Address, key: &U256) -> U256 {
403 self.transient_storage
404 .get(&(*to, *key))
405 .copied()
406 .unwrap_or_else(|| {
407 self.parent
408 .as_ref()
409 .map(|parent| parent.get_transient(to, key))
410 .unwrap_or_default()
411 })
412 }
413
414 /// Return the data associated with a transient storage entry, or zero if not present.
415 pub fn set_transient(&mut self, to: &Address, key: &U256, value: U256) {
416 self.transient_storage.insert((*to, *key), value);
417 }
418
419 /// Clear all transient storage (used between frames in frame transactions).
420 pub fn clear_transient_storage(&mut self) {
421 self.transient_storage.clear();
422 }
423
424 /// Extract all logs in order.
425 pub fn extract_logs(&self) -> Vec<Log> {
426 fn inner(substrate: &Substate, target: &mut Vec<Log>) {
427 if let Some(parent) = substrate.parent.as_deref() {
428 inner(parent, target);
429 }
430
431 target.extend_from_slice(&substrate.logs);
432 }
433
434 let mut logs = Vec::new();
435 inner(self, &mut logs);
436
437 logs
438 }
439
440 /// Return a clone of the current sub-substate's logs only, excluding parent logs.
441 /// Used by EIP-8141 frame execution to capture per-frame log deltas for
442 /// `frame_receipts[i].logs`. Must be called after `push_backup()` and before
443 /// `commit_backup()` to return only the logs emitted during the current scope.
444 pub fn current_logs(&self) -> Vec<Log> {
445 self.logs.clone()
446 }
447
448 /// Number of logs in the current scope, without cloning them. Used to slice
449 /// out a single frame's logs after `run_execution` has already committed the
450 /// frame's backup up into this scope.
451 pub fn logs_len(&self) -> usize {
452 self.logs.len()
453 }
454
455 /// Push a log record.
456 pub fn add_log(&mut self, log: Log) {
457 self.logs.push(log);
458 }
459}
460
461/// The LEVM (Lambda EVM) execution engine.
462///
463/// The VM executes Ethereum transactions by processing EVM bytecode. It maintains
464/// a call stack, memory, and tracks all state changes during execution.
465///
466/// # Execution Model
467///
468/// 1. Transaction is validated (nonce, balance, gas limit)
469/// 2. Initial call frame is created with transaction data
470/// 3. Opcodes are executed sequentially until completion or error
471/// 4. State changes are committed or reverted based on success
472///
473/// # Call Stack
474///
475/// Nested calls (CALL, DELEGATECALL, etc.) push new frames onto `call_frames`.
476/// Each frame has its own memory, stack, and execution context. The `current_call_frame`
477/// is always the active frame being executed.
478///
479/// # Hooks
480///
481/// The VM supports hooks for extending functionality (e.g., tracing, debugging).
482/// Hooks are called at various points during execution and implement pre/post-execution
483/// logic. L2-specific behavior (such as fee handling) is implemented via hooks.
484///
485/// # Example
486///
487/// ```ignore
488/// let mut vm = VM::new(env, db, &tx, tracer, vm_type, &NativeCrypto);
489/// let report = vm.execute()?;
490/// if report.is_success() {
491/// println!("Gas used: {}, Output: {:?}", report.gas_used, report.output);
492/// } else {
493/// println!("Transaction reverted");
494/// }
495/// ```
496/// EIP-8141 §Behavior: the top-level `frame.value` transfer
497/// reverts the frame if the sender's balance is strictly less than the
498/// amount being sent. Factored out so the decision can be unit-tested
499/// without bringing up a full VM state.
500pub fn frame_value_exceeds_balance(sender_balance: U256, frame_value: U256) -> bool {
501 sender_balance < frame_value
502}
503
504/// Context for frame transaction (EIP-8141) execution.
505/// This is set when executing a frame transaction and is used by
506/// APPROVE, TXPARAM, FRAMEDATALOAD, and FRAMEDATACOPY opcodes.
507#[derive(Debug, Clone)]
508pub struct FrameTxContext {
509 /// Whether the sender has approved (APPROVE scope `APPROVE_EXECUTION` or
510 /// `APPROVE_EXECUTION_AND_PAYMENT`).
511 pub sender_approved: bool,
512 /// The address that approved payment, set by `APPROVE_PAYMENT` or
513 /// `APPROVE_EXECUTION_AND_PAYMENT`. Per the latest EIP-8141 spec this is the
514 /// single source of truth for whether payment has been approved: when this
515 /// is `Some(_)`, the transaction has a `payer`; when `None`, it does not.
516 pub payer_address: Option<Address>,
517 /// Per-frame execution results (status, gas_used, logs).
518 /// `status` is a `FRAME_RECEIPT_STATUS_*` code (0 = failure, 1 = success,
519 /// 3 = skipped due to failed atomic batch).
520 pub frame_results: Vec<(u8, u64, Vec<Log>)>,
521 /// Index of the currently executing frame
522 pub current_frame_index: usize,
523 /// The sig_hash of the frame transaction
524 pub sig_hash: H256,
525 /// The full frame transaction (for TXPARAM access)
526 pub tx: ethrex_common::types::FrameTransaction,
527 /// Whether APPROVE was called in the current frame
528 pub approve_called_in_current_frame: bool,
529 /// Cached `FrameTransaction::max_gas()`. Computing it re-encodes every frame
530 /// and signature, so it must not run per-opcode (TXPARAM 0x06,
531 /// compute_tx_max_cost). Computed once at tx entry.
532 pub max_gas: u64,
533 /// The block's EIP-4844 blob base fee, captured at tx entry. `max_cost`
534 /// collects the blob fee at this rate, not at `max_fee_per_blob_gas`
535 /// (EIP-8141 §Gas accounting), and `load_tx_param` has no `Environment`
536 /// handle to read it from.
537 pub blob_base_fee: U256,
538}
539
540impl FrameTxContext {
541 /// Capture the approval state at atomic-batch entry. A batch revert rolls
542 /// back the payer's balance deduction and the sender nonce increment, so
543 /// approvals granted inside the batch must be rolled back with it —
544 /// otherwise a reverted APPROVE would leave the transaction authorized
545 /// by a frame whose effects no longer exist.
546 pub fn approval_snapshot(&self) -> (bool, Option<Address>) {
547 (self.sender_approved, self.payer_address)
548 }
549
550 /// Restore the approval state captured by `approval_snapshot` when the
551 /// enclosing atomic batch reverts. Approvals granted before the batch
552 /// are unaffected (the snapshot includes them).
553 pub fn restore_approvals(&mut self, snapshot: (bool, Option<Address>)) {
554 let (sender_approved, payer_address) = snapshot;
555 self.sender_approved = sender_approved;
556 self.payer_address = payer_address;
557 }
558}
559
560/// Snapshot of `call_frame_backup`'s key-space taken by [`VM::enter_prepare_region`]
561/// at the start of the atomic prepare region (EIP-8037 auth + prepare-dispatch
562/// charges). `original_accounts_info` / `original_account_storage_slots` are
563/// first-write-wins maps keyed by address, so a plain entry-count marker can't
564/// tell which keys are region-added; recording the pre-region key sets lets
565/// [`VM::fail_prepare_region`] revert exactly the region's writes while leaving
566/// the earlier sender nonce-bump / fee-deduction backup entries intact.
567/// `inserted_code_hashes` is an append-only `Vec`, so its pre-region length is
568/// enough to identify the region-added tail.
569#[derive(Debug, Default)]
570pub struct PrepareRegionBackupMarker {
571 /// Addresses already backed up in `original_accounts_info` before the region.
572 accounts: SeededHashSet<Address>,
573 /// Addresses already backed up in `original_account_storage_slots` before the region.
574 storage: SeededHashSet<Address>,
575 /// Length of `inserted_code_hashes` before the region.
576 code_hashes_len: usize,
577 /// Region-entry state of every account already backed up before the region
578 /// (notably the sender, post-fee/post-nonce). The marker excludes these from
579 /// the region rollback to preserve their pre-region writes, but first-write-wins
580 /// backup means an in-region write to one of them (e.g. a self-sponsored EIP-7702
581 /// authorization on the sender) leaves no new backup entry. `fail_prepare_region`
582 /// restores these to the captured state, reverting the in-region write while
583 /// keeping the pre-region nonce bump / fee deduction.
584 entry_state: SeededHashMap<Address, LevmAccount>,
585 /// BAL recorder checkpoint at region entry, so an applied-then-reverted
586 /// delegation's code/nonce changes are discarded (demoted to an access-only
587 /// touch) rather than leaking into the block access list.
588 bal_checkpoint: Option<BlockAccessListCheckpoint>,
589}
590
591/// Result of [`VM::simulate_validation_prefix`] (EIP-8141 mempool simulation).
592#[derive(Debug, Clone)]
593pub struct PrefixSimResult {
594 /// Whether any prefix frame reverted (fatal for validation).
595 pub any_revert: bool,
596 /// The payer established by the prefix, if any.
597 pub payer_address: Option<Address>,
598 /// Whether the sender was approved by a verify/pay frame.
599 pub sender_approved: bool,
600 /// Total simulated gas used across the prefix frames.
601 pub total_gas_used: u64,
602}
603
604pub struct VM<'a> {
605 /// Stack of parent call frames (for nested calls).
606 pub call_frames: Vec<CallFrame>,
607 /// The currently executing call frame.
608 pub current_call_frame: CallFrame,
609 /// Block and transaction environment.
610 pub env: Environment,
611 /// Execution substate (accessed addresses, logs, refunds, etc.).
612 pub substate: Substate,
613 /// Database for reading/writing account state.
614 pub db: &'a mut GeneralizedDatabase,
615 /// The transaction being executed. Borrowed for the VM's lifetime (the caller owns it for at
616 /// least that long), avoiding a per-tx deep clone of the access/authorization lists.
617 pub tx: &'a Transaction,
618 /// Execution hooks for tracing and debugging.
619 pub hooks: Vec<Rc<RefCell<dyn Hook>>>,
620 /// Original storage values before transaction (for SSTORE gas calculation),
621 /// keyed first by account to avoid hashing the full tuple on each access.
622 /// Seeded: contracts choose the keys at both levels.
623 pub storage_original_values: SeededHashMap<Address, SeededHashMap<H256, U256>>,
624 /// Call tracer for execution tracing.
625 pub tracer: LevmCallTracer,
626 /// Opcode (EIP-3155) tracer. Disabled by default; zero overhead when inactive.
627 pub opcode_tracer: LevmOpcodeTracer,
628 /// EIP-8141 mempool validation-trace observer. Disabled by default; active
629 /// only during `simulate_frame_validation_prefix`. Read only behind
630 /// `if self.validation_observer.active`, so an inactive observer adds one
631 /// branch to the dispatch loop and nothing more (mirrors `opcode_tracer`).
632 pub validation_observer: ValidationObserver,
633 /// Debug mode for development diagnostics.
634 pub debug_mode: DebugMode,
635 /// Pool of reusable stacks to reduce allocations.
636 pub stack_pool: Vec<Stack>,
637 /// Pool of reusable initcode buffers for CREATE frames. A buffer returns here when its
638 /// frame ends, so a contract that issues many CREATEs reuses one allocation instead of
639 /// copying its initcode into a fresh one each time. Inside zkVM guests whose bump
640 /// allocators never free, those fresh copies are never reclaimed and exhaust the heap.
641 pub initcode_pool: Vec<InitcodeBuffer>,
642 /// VM type (L1 or L2 with fee config).
643 pub vm_type: VMType,
644 /// Frame transaction context (EIP-8141). Set when executing a frame tx.
645 pub frame_tx_context: Option<FrameTxContext>,
646
647 /// Whether the top-level call-frame backup must be PRESERVED (deep-cloned) on the
648 /// revert / invalid-tx paths because a `BackupHook` will read it in `finalize_execution`
649 /// to build the tx-level undo snapshot. Derived from the installed `hooks` (via
650 /// [`Hook::reads_top_level_backup`]) rather than from `vm_type`, so it stays correct if
651 /// hook wiring changes; `add_hook` keeps it in sync for the `BackupHook` that
652 /// `stateless_execute` installs after construction. False for normal L1 block execution
653 /// (no `BackupHook`), where the backup is dead once the cache is restored and can be moved
654 /// out instead of cloned.
655 pub(crate) preserve_top_level_backup: bool,
656 /// EIP-8037: Accumulated state gas for this transaction (Amsterdam+).
657 /// Signed: goes negative when inline refunds exceed gross charges in the local frame
658 /// (e.g. SSTORE 0→x→0 restoration matching an ancestor's charge).
659 pub state_gas_used: i64,
660 /// EIP-8037: State gas reservoir pre-funded from excess gas_limit (Amsterdam+).
661 pub state_gas_reservoir: u64,
662 /// EIP-8037: Initial reservoir at tx start (before any execution). Captured in
663 /// add_intrinsic_gas so block-dimensional regular gas can be computed
664 /// independently of mid-tx reservoir activity (auth refunds, SSTORE credits).
665 pub state_gas_reservoir_initial: u64,
666 /// EIP-8037: Cumulative state gas that spilled to regular gas during execution
667 /// (when reservoir was insufficient). Subtracted when computing dimensional
668 /// regular gas for block accounting — EELS charge_state_gas spills don't
669 /// increment regular_gas_used.
670 pub state_gas_spill: u64,
671 /// EIP-8037: Dynamic cost per state byte (computed from block_gas_limit, Amsterdam+).
672 pub cost_per_state_byte: u64,
673 /// EIP-8037: State gas for new account creation (STATE_BYTES_PER_NEW_ACCOUNT * cost_per_state_byte).
674 pub state_gas_new_account: u64,
675 /// EIP-8037: State gas for storage slot creation (STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte).
676 pub state_gas_storage_set: u64,
677 /// EIP-8037: State gas for the 23-byte EIP-7702 delegation indicator
678 /// (STATE_BYTES_PER_AUTH_BASE * cost_per_state_byte). Charged in-region by
679 /// `eip7702_set_access_code` (EELS `set_delegation`) once per authority when a
680 /// net-new delegation indicator is written, and never credited back.
681 pub state_gas_auth_base: u64,
682 /// EIP-8037: intrinsic state gas (`tx_env.intrinsic_state_gas` in EELS). Captured at
683 /// `add_intrinsic_gas` time. ethrex lumps intrinsic + execution into `state_gas_used`,
684 /// so on top-level error this field is what we leave behind when refunding the
685 /// execution portion to the reservoir — block accounting then bills the intrinsic
686 /// (matches EELS `tx_state_gas = intrinsic_state_gas + tx_output.state_gas_used`).
687 pub intrinsic_state_gas: u64,
688 /// EIP-8037: set by `prepare_execution` when the in-region value-to-not-alive
689 /// `NEW_ACCOUNT` charge fires (a value-bearing call to a not-yet-alive
690 /// recipient). Consumed once, at the top of `run_execution`, to decide
691 /// whether an Amsterdam precompile-halt must roll the charge back (the
692 /// recipient never materializes on halt).
693 pub value_new_account_charged: bool,
694 /// EIP-8037: `current_call_frame.state_gas_used_at_entry` captured by
695 /// `enter_prepare_region` right before the atomic prepare region (EIP-7702
696 /// auth + prepare-dispatch charges) begins. `fail_prepare_region` rewinds the
697 /// frame's `state_gas_used` back to this pre-region baseline on an internal OOG,
698 /// mirroring EELS `restore_tx_state(prep_snapshot)`.
699 pub prep_baseline_state_gas: i64,
700 /// EIP-8037: `state_gas_reservoir` captured by `enter_prepare_region`. The
701 /// `set_delegation` auth lock-in zeroes the frame spill, so `fail_prepare_region`
702 /// cannot reconstruct the pre-region reservoir from `refill_frame_state_gas`
703 /// arithmetic; it restores this captured value directly (EELS
704 /// `message.state_gas_reservoir = prep_reservoir`).
705 pub prep_baseline_reservoir: u64,
706 /// EIP-8037: cumulative `state_gas_spill` captured by `enter_prepare_region`, so
707 /// `fail_prepare_region` restores block-accounting spill to its pre-region value
708 /// (the region's spilled state gas is fully rolled back / burned as regular).
709 pub prep_baseline_state_gas_spill: u64,
710 /// EIP-8037: pre-region key-space snapshot of `call_frame_backup`, recorded by
711 /// `enter_prepare_region`. Lets `fail_prepare_region` revert only region-added
712 /// writes, leaving the sender nonce-bump / fee-deduction entries intact.
713 pub prep_region_backup_marker: PrepareRegionBackupMarker,
714 /// EIP-8037: set by `fail_prepare_region` when an internal OOG rolls back the
715 /// atomic prepare region. Consumed by `run_execution`, which turns it into a
716 /// full-gas revert `ContextResult` (mirrors EELS depth-0
717 /// `except ExceptionalHalt: evm.regular_gas_used += evm.gas_left; evm.gas_left = 0`)
718 /// instead of a tx-level rejection `Err` that would wrongly invalidate the block.
719 pub pending_prep_oog: bool,
720 /// The opcode table mapping opcodes to opcode handlers for fast lookup.
721 /// A shared `&'static` reference to a per-fork table that is `const`-built once for the
722 /// whole process (immutable), so each VM holds only a pointer instead of a 2 KB inline copy.
723 pub(crate) opcode_table: &'static [OpCodeFn; 256],
724 /// Crypto provider for cryptographic operations.
725 pub crypto: &'a dyn Crypto,
726 /// Optional stateless validator for the EXECUTE precompile.
727 /// `None` on VMs that never dispatch EXECUTE (guest program, witness
728 /// generation, the stateless validator itself) — those paths can't supply
729 /// one without a dependency cycle or recursion.
730 pub stateless_validator: Option<&'a dyn crate::StatelessValidator>,
731}
732
733/// secp256k1 group order `n`.
734const SECP256K1_N: [u8; 32] = [
735 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe,
736 0xba, 0xae, 0xdc, 0xe6, 0xaf, 0x48, 0xa0, 0x3b, 0xbf, 0xd2, 0x5e, 0x8c, 0xd0, 0x36, 0x41, 0x41,
737];
738/// secp256k1 `n / 2`.
739const SECP256K1_N_HALF: [u8; 32] = [
740 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
741 0x5d, 0x57, 0x6e, 0x73, 0x57, 0xa4, 0x50, 0x1d, 0xdf, 0xe9, 0x2f, 0x46, 0x68, 0x1b, 0x20, 0xa0,
742];
743/// P-256 (secp256r1) group order `n`.
744const SECP256R1_N: [u8; 32] = [
745 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
746 0xbc, 0xe6, 0xfa, 0xad, 0xa7, 0x17, 0x9e, 0x84, 0xf3, 0xb9, 0xca, 0xc2, 0xfc, 0x63, 0x25, 0x51,
747];
748/// P-256 (secp256r1) `n / 2`.
749const SECP256R1_N_HALF: [u8; 32] = [
750 0x7f, 0xff, 0xff, 0xff, 0x80, 0x00, 0x00, 0x00, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
751 0xde, 0x73, 0x7d, 0x56, 0xd3, 0x8b, 0xcf, 0x42, 0x79, 0xdc, 0xe5, 0x61, 0x7e, 0x31, 0x92, 0xa8,
752];
753
754/// True when the big-endian 32-byte scalar is in `(0, upper)`.
755fn scalar_below(scalar: &[u8], upper: &[u8; 32]) -> bool {
756 scalar.iter().any(|&b| b != 0) && scalar < &upper[..]
757}
758
759/// True when the big-endian 32-byte scalar is in `(0, upper]`.
760fn scalar_at_most(scalar: &[u8], upper: &[u8; 32]) -> bool {
761 scalar.iter().any(|&b| b != 0) && scalar <= &upper[..]
762}
763
764/// Validate every EIP-8141 outer signature against the canonical `sig_hash`.
765/// Returns false if any signature is malformed or invalid. Verification gas is
766/// intrinsic (already in `standard_gas_limit`), so a scratch budget is used for the
767/// crypto precompiles and their deduction is ignored.
768#[expect(
769 clippy::indexing_slicing,
770 reason = "signature length is checked before each fixed-offset slice"
771)]
772pub fn validate_frame_signatures(
773 signatures: &[ethrex_common::types::FrameSignature],
774 sig_hash: ethrex_common::H256,
775 sender: ethrex_common::Address,
776 fork: Fork,
777 crypto: &dyn Crypto,
778) -> bool {
779 use ethrex_common::types::{
780 FRAME_SIG_SCHEME_ARBITRARY, FRAME_SIG_SCHEME_P256, FRAME_SIG_SCHEME_SECP256K1,
781 };
782 for sig in signatures {
783 // Resolve the signed message.
784 let msg: [u8; 32] = match sig.msg.len() {
785 0 => sig_hash.0,
786 32 => {
787 let mut m = [0u8; 32];
788 m.copy_from_slice(&sig.msg);
789 if m == [0u8; 32] {
790 return false;
791 }
792 m
793 }
794 _ => return false,
795 };
796 let mut scratch_gas = u64::MAX;
797 match sig.scheme {
798 FRAME_SIG_SCHEME_SECP256K1 => {
799 if sig.signature.len() != 65 {
800 return false;
801 }
802 let v = sig.signature[0];
803 let r = &sig.signature[1..33];
804 let s = &sig.signature[33..65];
805 // EIP-8141 requires a canonical signature: `v` is a bare recovery
806 // id (0 or 1), `0 < r < n`, and low-s per EIP-2 (`0 < s <= n/2`),
807 // so each signature has exactly one encoding.
808 if v > 1 || !scalar_below(r, &SECP256K1_N) || !scalar_at_most(s, &SECP256K1_N_HALF)
809 {
810 return false;
811 }
812 // The ecrecover precompile takes `v` in EVM form (27 or 28).
813 let mut calldata = vec![0u8; 128];
814 calldata[..32].copy_from_slice(&msg);
815 calldata[63] = v.saturating_add(27);
816 calldata[64..96].copy_from_slice(r);
817 calldata[96..128].copy_from_slice(s);
818 let Ok(result) =
819 crate::precompiles::ecrecover(&calldata, &mut scratch_gas, fork, crypto)
820 else {
821 return false;
822 };
823 if result.len() != 32 {
824 return false;
825 }
826 let recovered = ethrex_common::Address::from_slice(&result[12..]);
827 if recovered == ethrex_common::Address::zero() {
828 return false;
829 }
830 // Per EIP-8141, an absent signer resolves to tx.sender (used for
831 // introspection too); the recovered key must equal the resolved signer.
832 if recovered != sig.signer.unwrap_or(sender) {
833 return false;
834 }
835 }
836 FRAME_SIG_SCHEME_P256 => {
837 if sig.signature.len() != 128 {
838 return false;
839 }
840 let r = &sig.signature[0..32];
841 let s = &sig.signature[32..64];
842 let qx = &sig.signature[64..96];
843 let qy = &sig.signature[96..128];
844 // EIP-8141 requires `0 < r < n` and low-s (`0 < s <= n/2`) so each
845 // signature has one encoding. P256VERIFY itself accepts high-s, so
846 // signers must normalize `s` to `n - s` before use.
847 if !scalar_below(r, &SECP256R1_N) || !scalar_at_most(s, &SECP256R1_N_HALF) {
848 return false;
849 }
850 // signer = keccak256(qx || qy)[12:] (NO domain separator)
851 let mut pk = Vec::with_capacity(64);
852 pk.extend_from_slice(qx);
853 pk.extend_from_slice(qy);
854 let h = ethrex_crypto::keccak::keccak_hash(&pk);
855 // Per EIP-8141, an absent signer resolves to tx.sender (used for
856 // introspection too); the derived key must equal the resolved signer.
857 if ethrex_common::Address::from_slice(&h[12..]) != sig.signer.unwrap_or(sender) {
858 return false;
859 }
860 let mut calldata = vec![0u8; 160];
861 calldata[..32].copy_from_slice(&msg);
862 calldata[32..64].copy_from_slice(r);
863 calldata[64..96].copy_from_slice(s);
864 calldata[96..128].copy_from_slice(qx);
865 calldata[128..160].copy_from_slice(qy);
866 let Ok(result) =
867 crate::precompiles::p_256_verify(&calldata, &mut scratch_gas, fork, crypto)
868 else {
869 return false;
870 };
871 if result.len() != 32 || result[31] != 1 {
872 return false;
873 }
874 }
875 FRAME_SIG_SCHEME_ARBITRARY => {
876 // ARBITRARY: no protocol crypto; the signer must be empty. A
877 // custom verifier reads the raw bytes via SIGPARAM 0x04.
878 if sig.signer.is_some() {
879 return false;
880 }
881 }
882 _ => return false,
883 }
884 }
885 true
886}
887
888/// Find the end of the atomic batch containing `failed_idx`, per EIP-8141:
889/// a batch is a maximal contiguous run of frames whose ATOMIC_BATCH_FLAG is
890/// set, terminated by the first frame without the flag. Static validation
891/// guarantees no VERIFY frame carries the flag or terminates a batch, so every
892/// frame in the returned range is DEFAULT or SENDER. Returns the index of the
893/// batch's terminating frame.
894///
895/// Exposed (hidden) for the `ethrex-test` crate's frame-batch unit tests; not
896/// part of the stable public API.
897#[doc(hidden)]
898pub fn find_batch_end(frames: &[Frame], failed_idx: usize) -> usize {
899 frames
900 .get(failed_idx..)
901 .and_then(|rest| rest.iter().position(|f| !f.is_atomic_batch()))
902 .map(|offset| failed_idx.saturating_add(offset))
903 .unwrap_or(failed_idx)
904}
905
906impl<'a> VM<'a> {
907 /// Constructs a VM, allocating a fresh 32 KB root call-frame stack.
908 ///
909 /// Hot block execution should prefer [`VM::new_pooled`], which draws the root stack from a
910 /// reusable pool instead of allocating + zeroing one per transaction.
911 pub fn new(
912 env: Environment,
913 db: &'a mut GeneralizedDatabase,
914 tx: &'a Transaction,
915 tracer: LevmCallTracer,
916 vm_type: VMType,
917 crypto: &'a dyn Crypto,
918 stateless_validator: Option<&'a dyn crate::StatelessValidator>,
919 ) -> Result<Self, VMError> {
920 Self::new_with_root_stack(
921 env,
922 db,
923 tx,
924 tracer,
925 vm_type,
926 crypto,
927 stateless_validator,
928 Stack::default(),
929 Memory::default(),
930 )
931 }
932
933 /// Like [`VM::new`], but draws the root call-frame stack from `stack_pool` (falling back to a
934 /// fresh `Stack::default()` only when the pool is empty) and adopts the remaining pooled
935 /// stacks for sub-call frames. This avoids the per-tx 32 KB stack alloc+zero on a warm pool —
936 /// the dominant allocation for transfer-heavy blocks, where the root frame is the only frame.
937 ///
938 /// It also adopts `initcode_pool` as the VM's pool of CREATE initcode buffers, so a
939 /// transaction reuses the buffers an earlier one left.
940 ///
941 /// Pair with [`VM::reclaim_into`] after execution to return every stack (root + sub-frame)
942 /// to `stack_pool`, the root memory buffer to `memory_pool` and the initcode buffers to
943 /// `initcode_pool`, so the next tx reuses them.
944 #[allow(clippy::too_many_arguments)]
945 pub fn new_pooled(
946 env: Environment,
947 db: &'a mut GeneralizedDatabase,
948 tx: &'a Transaction,
949 tracer: LevmCallTracer,
950 vm_type: VMType,
951 crypto: &'a dyn Crypto,
952 stateless_validator: Option<&'a dyn crate::StatelessValidator>,
953 stack_pool: &mut Vec<Stack>,
954 memory_pool: &mut Vec<Memory>,
955 initcode_pool: &mut Vec<InitcodeBuffer>,
956 ) -> Result<Self, VMError> {
957 // Reuse a pooled stack for the root frame. `clear()` only resets the offset (no zeroing),
958 // which is sound because the EVM never reads stack slots it didn't write — the same
959 // invariant that already makes sub-frame pooling safe.
960 let mut root_stack = stack_pool.pop().unwrap_or_default();
961 root_stack.clear();
962 // Reuse a pooled root memory buffer (capacity retained from a prior tx, contents dropped).
963 // `reclaim_into` truncates it to length 0, so `resize`'s zero-fill invariant holds. Only
964 // the root buffer is pooled: sub-frame memories are `Rc` clones of it (`next_memory`).
965 let mut root_memory = memory_pool.pop().unwrap_or_default();
966 root_memory.reset_for_reuse();
967 let mut vm = Self::new_with_root_stack(
968 env,
969 db,
970 tx,
971 tracer,
972 vm_type,
973 crypto,
974 stateless_validator,
975 root_stack,
976 root_memory,
977 )?;
978 // Adopt the caller's pooled stacks for sub-frames; returned via `reclaim_into`.
979 mem::swap(&mut vm.stack_pool, stack_pool);
980 // Likewise the initcode buffers. A pool that started empty with every transaction would
981 // make each one allocate its buffers afresh and drop them with its VM, which a zkVM
982 // guest's bump allocator never reclaims.
983 mem::swap(&mut vm.initcode_pool, initcode_pool);
984 Ok(vm)
985 }
986
987 /// Returns this VM's reusable buffers to the caller's pools so the next transaction reuses
988 /// them instead of allocating: every stack (root call-frame stack plus any sub-frame stacks
989 /// still pooled internally) to `stack_pool`, the root memory buffer to `memory_pool`, and the
990 /// pooled initcode buffers to `initcode_pool`.
991 /// Must run on both the success and error paths of [`VM::execute`].
992 pub fn reclaim_into(
993 mut self,
994 stack_pool: &mut Vec<Stack>,
995 memory_pool: &mut Vec<Memory>,
996 initcode_pool: &mut Vec<InitcodeBuffer>,
997 ) {
998 // Hand the internal sub-frame pool back to the caller first.
999 mem::swap(&mut self.stack_pool, stack_pool);
1000 mem::swap(&mut self.initcode_pool, initcode_pool);
1001 // Then reclaim the root frame's stack. Moving it out by value (VM/CallFrame have no Drop)
1002 // avoids leaving a fresh 32 KB `Stack::default()` placeholder behind — which a
1003 // `mem::take`/`mem::replace` against an empty pool would force, defeating the win on
1004 // exactly the transfer-only blocks (no sub-frames ever seed the pool) we target.
1005 let mut root_stack = self.current_call_frame.stack;
1006 root_stack.clear();
1007 stack_pool.push(root_stack);
1008 // Reclaim the root memory buffer with its grown capacity. `reset_for_reuse` truncates it
1009 // to length 0 (capacity kept) so the next tx's `resize` zero-fills correctly.
1010 //
1011 // Every call frame shares the same `Rc<RefCell<Vec<u8>>>` buffer, so on the error path the
1012 // ancestor frames left in `call_frames` (error propagation unwinds out of `execute` without
1013 // popping them) still hold clones. Drop them first so the buffer is `Rc`-unique on BOTH
1014 // paths before we clear it — otherwise the clear would propagate to a frame still holding a
1015 // reference. `CallFrame` has no `Drop` and these frames are never read again, so dropping
1016 // them early is free. The frame left current on the error path may be a callee whose
1017 // arguments are a view of the buffer (`CallData`), which holds a clone as well.
1018 self.call_frames.clear();
1019 self.current_call_frame.calldata = CallData::default();
1020 let mut root_memory = self.current_call_frame.memory;
1021 debug_assert_eq!(
1022 Rc::strong_count(&root_memory.buffer),
1023 1,
1024 "root memory buffer must be Rc-unique at reclaim; a frame is still holding it and \
1025 would observe the reset_for_reuse clear",
1026 );
1027 root_memory.reset_for_reuse();
1028 memory_pool.push(root_memory);
1029 }
1030
1031 #[allow(clippy::too_many_arguments)]
1032 fn new_with_root_stack(
1033 env: Environment,
1034 db: &'a mut GeneralizedDatabase,
1035 tx: &'a Transaction,
1036 tracer: LevmCallTracer,
1037 vm_type: VMType,
1038 crypto: &'a dyn Crypto,
1039 stateless_validator: Option<&'a dyn crate::StatelessValidator>,
1040 root_stack: Stack,
1041 root_memory: Memory,
1042 ) -> Result<Self, VMError> {
1043 db.tx_backup = None; // If BackupHook is enabled, it will contain backup at the end of tx execution.
1044
1045 let mut substate = Substate::initialize(&env, tx)?;
1046
1047 let (callee, is_create) = Self::get_tx_callee(tx, db, &env, &mut substate)?;
1048
1049 let fork = env.config.fork;
1050
1051 #[expect(
1052 clippy::arithmetic_side_effects,
1053 reason = "byte-count constants are small (<200) and cpsb is bounded by block_gas_limit/year formula"
1054 )]
1055 let (cpsb, state_gas_new_account, state_gas_storage_set, state_gas_auth_base) =
1056 if fork >= Fork::Amsterdam {
1057 let cpsb = compute_cost_per_state_byte(env.block_gas_limit);
1058 (
1059 cpsb,
1060 STATE_BYTES_PER_NEW_ACCOUNT * cpsb,
1061 STATE_BYTES_PER_STORAGE_SET * cpsb,
1062 STATE_BYTES_PER_AUTH_BASE * cpsb,
1063 )
1064 } else {
1065 (0, 0, 0, 0)
1066 };
1067
1068 // Derive whether the top-level backup must be preserved from the installed hooks rather
1069 // than from `vm_type`. The flag's real meaning is "a hook reads the top-level backup in
1070 // `finalize_execution`," which today is the `BackupHook` on L2 / stateless. Deriving it
1071 // keeps the flag correct if hook wiring ever changes (e.g. a future `vm_type` that adds
1072 // `BackupHook`, or L2 dropping it), and `add_hook` keeps it in sync for the `BackupHook`
1073 // that `stateless_execute` installs after construction. L1 block execution installs no
1074 // `BackupHook` (see `l1_hooks`), so the backup is dead once the cache is restored.
1075 let hooks = get_hooks(&vm_type);
1076 let preserve_top_level_backup = hooks
1077 .iter()
1078 .any(|hook| hook.borrow().reads_top_level_backup());
1079
1080 let mut vm = Self {
1081 call_frames: Vec::new(),
1082 substate,
1083 db,
1084 tx,
1085 hooks,
1086 storage_original_values: Default::default(),
1087 tracer,
1088 opcode_tracer: LevmOpcodeTracer::disabled(),
1089 validation_observer: ValidationObserver::disabled(),
1090 debug_mode: DebugMode::disabled(),
1091 stack_pool: Vec::new(),
1092 initcode_pool: Vec::new(),
1093 vm_type,
1094 preserve_top_level_backup,
1095 state_gas_used: 0,
1096 state_gas_reservoir: 0,
1097 state_gas_reservoir_initial: 0,
1098 state_gas_spill: 0,
1099 cost_per_state_byte: cpsb,
1100 state_gas_new_account,
1101 state_gas_storage_set,
1102 state_gas_auth_base,
1103 intrinsic_state_gas: 0,
1104 value_new_account_charged: false,
1105 prep_baseline_state_gas: 0,
1106 prep_baseline_reservoir: 0,
1107 prep_baseline_state_gas_spill: 0,
1108 prep_region_backup_marker: PrepareRegionBackupMarker::default(),
1109 pending_prep_oog: false,
1110 current_call_frame: CallFrame::new(
1111 env.origin,
1112 callee,
1113 Address::default(), // Will be assigned at the end of prepare_execution
1114 Code::default(), // Will be assigned at the end of prepare_execution
1115 tx.value(),
1116 tx.data().clone().into(),
1117 false,
1118 env.gas_limit,
1119 0,
1120 true,
1121 is_create,
1122 0,
1123 0,
1124 root_stack,
1125 root_memory,
1126 ),
1127 env,
1128 frame_tx_context: None,
1129 opcode_table: VM::build_opcode_table(fork),
1130 crypto,
1131 stateless_validator,
1132 };
1133
1134 let call_type = if is_create {
1135 CallType::CREATE
1136 } else {
1137 CallType::CALL
1138 };
1139 vm.tracer.enter(
1140 call_type,
1141 vm.env.origin,
1142 callee,
1143 vm.tx.value(),
1144 vm.env.gas_limit,
1145 &vm.tx.data().clone().into(),
1146 )?;
1147
1148 #[cfg(feature = "debug")]
1149 {
1150 // Enable debug mode for printing in Solidity contracts.
1151 vm.debug_mode.enabled = true;
1152 }
1153
1154 Ok(vm)
1155 }
1156
1157 fn add_hook(&mut self, hook: impl Hook + 'static) {
1158 // Keep `preserve_top_level_backup` in sync: a hook added after construction (e.g. the
1159 // `BackupHook` in `stateless_execute`) may read the top-level backup in `finalize_execution`.
1160 self.preserve_top_level_backup |= hook.reads_top_level_backup();
1161 self.hooks.push(Rc::new(RefCell::new(hook)));
1162 }
1163
1164 /// EIP-8037: Charge state gas, drawing from reservoir first, spilling to gas_remaining if exhausted.
1165 ///
1166 /// Must only be called for Amsterdam+ forks. All call sites must guard with
1167 /// `fork >= Fork::Amsterdam` before invoking this method.
1168 #[expect(
1169 clippy::arithmetic_side_effects,
1170 reason = "arithmetic proven safe by min()"
1171 )]
1172 pub fn increase_state_gas(&mut self, gas: u64) -> Result<(), VMError> {
1173 debug_assert!(
1174 self.env.config.fork >= Fork::Amsterdam,
1175 "increase_state_gas called pre-Amsterdam"
1176 );
1177 // Draw from reservoir first; only spill to gas_remaining if reservoir exhausted
1178 let from_reservoir = self.state_gas_reservoir.min(gas);
1179 // Safe: from_reservoir <= gas
1180 let spill = gas - from_reservoir;
1181 if spill > 0 {
1182 // Charge spill from gas_remaining first — if OOG, return early
1183 // without mutating reservoir or state_gas_used (matches EELS behavior)
1184 self.current_call_frame.increase_consumed_gas(spill)?;
1185 }
1186 // Safe: from_reservoir = min(reservoir, gas) so reservoir >= from_reservoir
1187 self.state_gas_reservoir -= from_reservoir;
1188 // Only increment state_gas_used AFTER the charge succeeds.
1189 // state_gas_used is i64; tx gas_limit caps charges well below i64::MAX.
1190 self.state_gas_used = self
1191 .state_gas_used
1192 .checked_add(i64::try_from(gas).map_err(|_| InternalError::Overflow)?)
1193 .ok_or(InternalError::Overflow)?;
1194 // Track the spill for block-accounting: EELS charge_state_gas spills
1195 // don't count toward regular_gas_used for the regular dimension.
1196 self.state_gas_spill = self
1197 .state_gas_spill
1198 .checked_add(spill)
1199 .ok_or(InternalError::Overflow)?;
1200 // Per-frame spill: EELS charge_state_gas does `frame_state_gas_spilled += remainder`.
1201 // LIFO refund source; propagated to parent on child success.
1202 self.current_call_frame.frame_state_gas_spilled = self
1203 .current_call_frame
1204 .frame_state_gas_spilled
1205 .checked_add(spill)
1206 .ok_or(InternalError::Overflow)?;
1207 Ok(())
1208 }
1209
1210 /// EIP-8037 `credit_state_gas_refund`: refund `amount` LIFO, mirroring EELS. The portion
1211 /// spilled past the reservoir into this frame's `gas_remaining` (`frame_state_gas_spilled`)
1212 /// is returned to `gas_remaining` first; only the remainder flows to the shared reservoir.
1213 /// `state_gas_used` drops by the full `amount` (may go negative when the matching charge lives
1214 /// in an ancestor frame). Block accounting: both spill counters drop by the
1215 /// `gas_remaining`-credited portion only, never the full `amount`. Amsterdam+ only.
1216 #[expect(
1217 clippy::arithmetic_side_effects,
1218 reason = "subtractions proven safe by min()"
1219 )]
1220 pub fn credit_state_gas_refund(&mut self, amount: u64) -> Result<(), VMError> {
1221 debug_assert!(
1222 self.env.config.fork >= Fork::Amsterdam,
1223 "credit_state_gas_refund called pre-Amsterdam"
1224 );
1225 // LIFO: drain the frame's spill (gas borrowed from gas_remaining) first.
1226 let from_gas_left = self.current_call_frame.frame_state_gas_spilled.min(amount);
1227 // Return the spilled portion to gas_remaining (i64).
1228 self.current_call_frame.gas_remaining = self
1229 .current_call_frame
1230 .gas_remaining
1231 .checked_add(i64::try_from(from_gas_left).map_err(|_| InternalError::Overflow)?)
1232 .ok_or(InternalError::Overflow)?;
1233 // Safe: from_gas_left = min(spill, amount) <= frame_state_gas_spilled.
1234 self.current_call_frame.frame_state_gas_spilled -= from_gas_left;
1235 // Block accounting: the refilled spill is no longer regular gas.
1236 self.state_gas_spill = self
1237 .state_gas_spill
1238 .checked_sub(from_gas_left)
1239 .ok_or(InternalError::Underflow)?;
1240 // The remainder of the refund flows into the shared reservoir.
1241 // Safe: from_gas_left = min(spill, amount) <= amount.
1242 let to_reservoir = amount - from_gas_left;
1243 self.state_gas_reservoir = self
1244 .state_gas_reservoir
1245 .checked_add(to_reservoir)
1246 .ok_or(InternalError::Overflow)?;
1247 // state_gas_used always drops by the full amount (may go negative).
1248 self.state_gas_used = self
1249 .state_gas_used
1250 .checked_sub(i64::try_from(amount).map_err(|_| InternalError::Overflow)?)
1251 .ok_or(InternalError::Overflow)?;
1252 Ok(())
1253 }
1254
1255 /// Refund the EIP-8037 new-account state gas when `charged` is true. Used by the
1256 /// CALL paths where a value-bearing call to an empty account charged the new-account
1257 /// state gas but no account ends up created (insufficient balance, max depth, child
1258 /// revert / failed precompile).
1259 #[inline]
1260 pub fn refund_new_account_state_gas(&mut self, charged: bool) -> Result<(), VMError> {
1261 if charged {
1262 self.credit_state_gas_refund(self.state_gas_new_account)?;
1263 }
1264 Ok(())
1265 }
1266
1267 /// EIP-8037 `repay_state_gas_spill` (EELS `incorporate_child`, success arm): once a
1268 /// successful child's gas has merged into this frame, the reservoir repays the spill
1269 /// still outstanding, up to what the reservoir holds.
1270 ///
1271 /// A refund can land in a different frame than the charge it undoes: the refunding
1272 /// frame's spill may be smaller than the refund, so the excess credits the reservoir
1273 /// even though the charge drew from `gas_remaining`. The merge is where the claim
1274 /// (this frame's spill) and the credit (the reservoir) first share a frame, so that
1275 /// is where the two pools settle between themselves.
1276 ///
1277 /// No state creation is undone, so `state_gas_used` does not move: the gas only
1278 /// crosses back between the pools it drifted across. Both settlements stay
1279 /// unchanged, which is what makes this repayment billing-neutral:
1280 /// - user gas (`gas_limit - gas_remaining - state_gas_reservoir`) is unchanged
1281 /// because `gas_remaining` rises by exactly what the reservoir loses;
1282 /// - the EIP-7778 regular dimension subtracts the cumulative `state_gas_spill`, so
1283 /// that counter drops by the repayment too — the reservoir (the state dimension)
1284 /// covered the charge after all, so it must stop being billed as regular gas.
1285 ///
1286 /// A failed child repays nothing: its rollback already restored the state whose
1287 /// removal any refund credited.
1288 #[expect(
1289 clippy::arithmetic_side_effects,
1290 reason = "subtractions proven safe by min()"
1291 )]
1292 pub fn repay_state_gas_spill(&mut self) -> Result<(), VMError> {
1293 debug_assert!(
1294 self.env.config.fork >= Fork::Amsterdam,
1295 "repay_state_gas_spill called pre-Amsterdam"
1296 );
1297 let repayment = self
1298 .state_gas_reservoir
1299 .min(self.current_call_frame.frame_state_gas_spilled);
1300 if repayment == 0 {
1301 return Ok(());
1302 }
1303 self.current_call_frame.gas_remaining = self
1304 .current_call_frame
1305 .gas_remaining
1306 .checked_add(i64::try_from(repayment).map_err(|_| InternalError::Overflow)?)
1307 .ok_or(InternalError::Overflow)?;
1308 // Safe: repayment = min(reservoir, frame spill) <= both.
1309 self.state_gas_reservoir -= repayment;
1310 self.current_call_frame.frame_state_gas_spilled -= repayment;
1311 // Block accounting: every frame spill is mirrored in the cumulative counter, so
1312 // it covers this repayment (`checked_sub` guards the invariant regardless).
1313 self.state_gas_spill = self
1314 .state_gas_spill
1315 .checked_sub(repayment)
1316 .ok_or(InternalError::Underflow)?;
1317 // EELS asserts the claim and the credit cannot both survive a repayment.
1318 debug_assert!(
1319 self.state_gas_reservoir == 0 || self.current_call_frame.frame_state_gas_spilled == 0
1320 );
1321 Ok(())
1322 }
1323
1324 /// EIP-8037 `refill_frame_state_gas`: roll back this frame's state gas in LIFO
1325 /// order on revert or exceptional halt, mirroring EELS `refill_frame_state_gas`.
1326 ///
1327 /// `entry` is the value of `state_gas_used` when this frame began executing
1328 /// (`current_call_frame.state_gas_used_at_entry`). The frame's net charge is
1329 /// `frame_used = state_gas_used - entry`. Of that, `frame_state_gas_spilled` was
1330 /// drawn from `gas_remaining` (spilled past the reservoir) and the remainder came
1331 /// from the reservoir. LIFO refill returns the spilled portion to `gas_remaining`
1332 /// first and the rest to the reservoir, restoring the exact pools the charges drew
1333 /// from. `state_gas_used` is rolled back to `entry` and the per-frame spill counter
1334 /// is cleared.
1335 ///
1336 /// Revert-vs-halt equivalence (load-bearing): on revert, the spilled gas returns to
1337 /// `gas_remaining` (raising the sender refund / lowering raw_consumed) while
1338 /// `state_gas_spill` drops by the same amount, so the regular dimension in
1339 /// `refund_sender` (default_hook) drops by exactly the refilled spill. On exceptional
1340 /// halt the caller subsequently sets `gas_remaining = 0` and burns it to the regular
1341 /// dimension — but `state_gas_spill` was already decremented here, so the spilled gas
1342 /// stays counted as regular. Both paths are correct.
1343 ///
1344 /// Must only be called for Amsterdam+ forks.
1345 pub fn refill_frame_state_gas(&mut self, entry: i64) -> Result<(), VMError> {
1346 debug_assert!(
1347 self.env.config.fork >= Fork::Amsterdam,
1348 "refill_frame_state_gas called pre-Amsterdam"
1349 );
1350 // The frame's net state-gas charge since it began executing. May be
1351 // negative when the frame's inline refunds (e.g. an SSTORE clearing a
1352 // slot an ancestor set) exceeded its own gross charges.
1353 let frame_used = self
1354 .state_gas_used
1355 .checked_sub(entry)
1356 .ok_or(InternalError::Underflow)?;
1357 let spilled = self.current_call_frame.frame_state_gas_spilled;
1358 // LIFO invariant: any remaining spill is undrained own-charge, so it
1359 // implies frame_used >= 0. A net-negative frame_used only arises after
1360 // credit_state_gas_refund has already drained all spill (spilled == 0).
1361 debug_assert!(
1362 frame_used >= 0 || spilled == 0,
1363 "negative frame_used with positive spill violates LIFO invariant \
1364 (frame_used={frame_used}, spilled={spilled})"
1365 );
1366 // LIFO: return the spilled portion (borrowed from gas_remaining) first.
1367 self.current_call_frame.gas_remaining = self
1368 .current_call_frame
1369 .gas_remaining
1370 .checked_add(i64::try_from(spilled).map_err(|_| InternalError::Overflow)?)
1371 .ok_or(InternalError::Overflow)?;
1372 // The remainder (drawn from the reservoir) flows back to the reservoir.
1373 let to_reservoir = frame_used
1374 .checked_sub(i64::try_from(spilled).map_err(|_| InternalError::Overflow)?)
1375 .ok_or(InternalError::Overflow)?;
1376 // `to_reservoir` is negative in the cross-ancestor refund case
1377 // (frame_used < 0); clamp so the reservoir never goes negative.
1378 let reservoir_signed =
1379 i64::try_from(self.state_gas_reservoir).map_err(|_| InternalError::Overflow)?;
1380 self.state_gas_reservoir = u64::try_from(
1381 reservoir_signed
1382 .checked_add(to_reservoir)
1383 .ok_or(InternalError::Overflow)?
1384 .max(0),
1385 )
1386 .map_err(|_| InternalError::Overflow)?;
1387 // Roll back state_gas_used to the frame's entry baseline.
1388 self.state_gas_used = entry;
1389 // Block accounting: the refilled spill is no longer regular gas.
1390 self.state_gas_spill = self
1391 .state_gas_spill
1392 .checked_sub(spilled)
1393 .ok_or(InternalError::Underflow)?;
1394 self.current_call_frame.frame_state_gas_spilled = 0;
1395 Ok(())
1396 }
1397
1398 /// EIP-8037: enters the atomic prepare region (EELS `interpreter.py`'s depth-0
1399 /// `try` around `set_delegation` + `prepare_dispatch`, `interpreter.py:353-375`).
1400 /// Must be called in `prepare_execution`, after the sender nonce bump and fee
1401 /// deduction (those survive a region rollback) but before the type-4 auth
1402 /// handling (the first region charge). Captures the pre-region state-gas
1403 /// baseline and the `call_frame_backup` key-space so `fail_prepare_region` can
1404 /// later undo exactly the region's writes.
1405 pub fn enter_prepare_region(&mut self) {
1406 self.prep_baseline_state_gas = self.current_call_frame.state_gas_used_at_entry;
1407 self.prep_baseline_reservoir = self.state_gas_reservoir;
1408 self.prep_baseline_state_gas_spill = self.state_gas_spill;
1409 let backup = &self.current_call_frame.call_frame_backup;
1410 let accounts: SeededHashSet<Address> =
1411 backup.original_accounts_info.keys().copied().collect();
1412 let storage: SeededHashSet<Address> = backup
1413 .original_account_storage_slots
1414 .keys()
1415 .copied()
1416 .collect();
1417 let code_hashes_len = backup.inserted_code_hashes.len();
1418 // Capture the region-entry state of every already-backed-up account so a
1419 // self-sponsored EIP-7702 authorization (or any in-region write to a
1420 // pre-region-touched account) can be reverted without disturbing its
1421 // pre-region nonce/fee writes. See `PrepareRegionBackupMarker::entry_state`.
1422 let entry_state: SeededHashMap<Address, LevmAccount> = accounts
1423 .iter()
1424 .filter_map(|address| {
1425 self.db
1426 .current_accounts_state
1427 .get(address)
1428 .map(|account| (*address, account.clone()))
1429 })
1430 .collect();
1431 let bal_checkpoint = self.db.bal_recorder.as_ref().map(|r| r.checkpoint());
1432 self.prep_region_backup_marker = PrepareRegionBackupMarker {
1433 accounts,
1434 storage,
1435 code_hashes_len,
1436 entry_state,
1437 bal_checkpoint,
1438 };
1439 }
1440
1441 /// EIP-8037: rolls back the atomic prepare region on an internal OOG from any of
1442 /// its charges (7702 auth, CREATE/value `NEW_ACCOUNT`, recipient delegation-resolve).
1443 /// Restores every `call_frame_backup` entry added since `enter_prepare_region`
1444 /// (leaving the earlier sender nonce-bump / fee-deduction entries untouched),
1445 /// refills the frame's state gas back to the pre-region baseline, and sets
1446 /// `pending_prep_oog` so `run_execution` turns this into a full-gas revert
1447 /// instead of a tx-level rejection `Err` (which would wrongly invalidate the
1448 /// block). Mirrors EELS `restore_tx_state(prep_snapshot)` + `refill_frame_state_gas`
1449 /// (`interpreter.py:366-374`).
1450 pub fn fail_prepare_region(&mut self) {
1451 let marker = std::mem::take(&mut self.prep_region_backup_marker);
1452 let mut backup = std::mem::take(&mut self.current_call_frame.call_frame_backup);
1453
1454 // Restore + drop every account backup entry added during the region.
1455 let region_accounts: Vec<Address> = backup
1456 .original_accounts_info
1457 .keys()
1458 .filter(|address| !marker.accounts.contains(*address))
1459 .copied()
1460 .collect();
1461 for address in region_accounts {
1462 if let Some(account) = backup.original_accounts_info.remove(&address)
1463 && let Some(current_account) = self.db.current_accounts_state.get_mut(&address)
1464 {
1465 current_account.info = account.info;
1466 current_account.status = account.status;
1467 current_account.has_storage = account.has_storage;
1468 current_account.exists = account.exists;
1469 }
1470 }
1471
1472 // Restore + drop every storage backup entry added during the region.
1473 let region_storage: Vec<Address> = backup
1474 .original_account_storage_slots
1475 .keys()
1476 .filter(|address| !marker.storage.contains(*address))
1477 .copied()
1478 .collect();
1479 for address in region_storage {
1480 if let Some(slots) = backup.original_account_storage_slots.remove(&address)
1481 && let Some(current_account) = self.db.current_accounts_state.get_mut(&address)
1482 {
1483 for (key, value) in slots {
1484 current_account.storage.insert(key, value);
1485 }
1486 }
1487 }
1488
1489 // Evict codes the region inserted, mirroring `restore_cache_state`: a stale
1490 // by-hash cache entry would hide a later read of the same hash from the store.
1491 for code_hash in backup
1492 .inserted_code_hashes
1493 .split_off(marker.code_hashes_len)
1494 {
1495 self.db.codes.remove(&code_hash);
1496 }
1497
1498 self.current_call_frame.call_frame_backup = backup;
1499
1500 // Revert in-region writes to accounts that were already backed up before the
1501 // region (the marker filter above leaves these untouched to preserve their
1502 // pre-region nonce/fee writes, but first-write-wins backup means an in-region
1503 // write — e.g. a self-sponsored EIP-7702 authorization writing the sender's
1504 // delegation code + auth nonce bump — created no new backup entry). Restoring
1505 // to the region-entry snapshot undoes the in-region write while keeping the
1506 // pre-region writes baked into that snapshot. Mirrors EELS `restore_tx_state`.
1507 for (address, entry_account) in marker.entry_state {
1508 if let Some(current) = self.db.current_accounts_state.get_mut(&address) {
1509 current.info = entry_account.info;
1510 current.status = entry_account.status;
1511 current.has_storage = entry_account.has_storage;
1512 current.exists = entry_account.exists;
1513 }
1514 }
1515
1516 // Roll back the BAL recorder to region entry so an applied-then-reverted
1517 // delegation's code/nonce changes are discarded (demoted to an access-only
1518 // touch, matching EELS `restore_tx_state`). `restore` preserves touched
1519 // addresses — including the pre-charge authority touches recorded during
1520 // `set_delegation` — so a reverted authority still appears as an access.
1521 if let Some(checkpoint) = marker.bal_checkpoint
1522 && let Some(recorder) = self.db.bal_recorder.as_mut()
1523 {
1524 recorder.restore(checkpoint);
1525 }
1526
1527 // Rewind the state-gas machinery to the pre-region snapshot. The
1528 // `set_delegation` auth lock-in re-seeds `state_gas_used_at_entry` and zeroes
1529 // the frame spill, so `refill_frame_state_gas` (which derives the reservoir
1530 // credit from the frame spill) would leave the reservoir over-credited by the
1531 // locked-in auth state gas. Restore `state_gas_used`, the reservoir, and the
1532 // cumulative spill to the values captured by `enter_prepare_region`, and clear
1533 // the frame spill — this fully reverts the region's state charges (auth +
1534 // prepare-dispatch) regardless of the lock-in. Mirrors EELS
1535 // `restore_tx_state(prep_snapshot)` + `message.state_gas_reservoir = prep_reservoir`
1536 // (`interpreter.py:365-369`). `gas_remaining` is left as-is: the failing charge
1537 // already drove it negative, so `run_execution` burns all gas (EELS
1538 // `evm.regular_gas_used += evm.gas_left; evm.gas_left = 0`).
1539 self.state_gas_used = self.prep_baseline_state_gas;
1540 self.state_gas_reservoir = self.prep_baseline_reservoir;
1541 self.state_gas_spill = self.prep_baseline_state_gas_spill;
1542 self.current_call_frame.frame_state_gas_spilled = 0;
1543 self.pending_prep_oog = true;
1544 }
1545
1546 /// Executes a whole external transaction. Performing validations at the beginning.
1547 pub fn execute(&mut self) -> Result<ExecutionReport, VMError> {
1548 // Detect frame transaction and branch to specialized execution
1549 if self.tx.tx_type() == TxType::Frame {
1550 return self.execute_frame_tx();
1551 }
1552
1553 if let Err(e) = self.prepare_execution() {
1554 // Restore cache to state previous to this Tx execution because this Tx is invalid.
1555 // Consume the backup unless a `BackupHook` will read it (L2 / stateless); on L1 it
1556 // is dead once the cache is restored.
1557 if self.preserve_top_level_backup {
1558 self.restore_cache_state()?;
1559 } else {
1560 self.restore_cache_state_consuming()?;
1561 }
1562 return Err(e);
1563 }
1564
1565 // Clear callframe backup so that changes made in prepare_execution are written in stone.
1566 // We want to apply these changes even if the Tx reverts. E.g. Incrementing sender nonce
1567 self.current_call_frame.call_frame_backup.clear();
1568
1569 // Empty bytecode would only execute STOP; skip the dispatch loop.
1570 // The BAL checkpoint below is intentionally skipped: a codeless transfer cannot
1571 // fail past this point and has no inner calls, so there's nothing to roll back.
1572 if self.is_simple_transfer_fast_path() {
1573 // EIP-8037: no `refill_frame_state_gas` needed here — a codeless transfer always
1574 // succeeds, runs no opcodes, and charges no execution state gas, so the frame's
1575 // `frame_state_gas_spilled` is 0 and `state_gas_used` equals its entry baseline.
1576 #[expect(clippy::as_conversions, reason = "gas_remaining is non-negative here")]
1577 let gas_used = self
1578 .current_call_frame
1579 .gas_limit
1580 .checked_sub(self.current_call_frame.gas_remaining as u64)
1581 .ok_or(InternalError::Underflow)?;
1582 let context_result = ContextResult {
1583 result: TxResult::Success,
1584 gas_used,
1585 gas_spent: gas_used,
1586 output: Bytes::new(),
1587 };
1588 return self.finalize_execution(context_result);
1589 }
1590
1591 // EIP-7928: Take a BAL checkpoint AFTER clearing the backup. This captures the state
1592 // after prepare_execution (nonce increment, etc.) but before actual execution.
1593 // When the top-level call fails, we restore to this checkpoint so that inner call
1594 // state changes (like value transfers) are reverted from the BAL.
1595 self.current_call_frame.call_frame_backup.bal_checkpoint =
1596 self.db.bal_recorder.as_ref().map(|r| r.checkpoint());
1597
1598 // EIP-8037: skip the CREATE nonce bump / endowment / collision check when the
1599 // atomic prepare region OOG'd (`pending_prep_oog`). EELS raises in
1600 // `prepare_dispatch` before `process_create_message` runs, so the contract is
1601 // never created — `run_execution` below emits the full-gas revert. Bumping the
1602 // nonce here would leave a phantom nonce=1 the region rollback can't reach.
1603 if !self.pending_prep_oog && self.is_create()? {
1604 // Create contract, reverting the Tx if address is already occupied.
1605 if let Some(context_result) = self.handle_create_transaction()? {
1606 let report = self.finalize_execution(context_result)?;
1607 return Ok(report);
1608 }
1609 }
1610
1611 self.substate.push_backup();
1612 let context_result = self.run_execution()?;
1613
1614 let report = self.finalize_execution(context_result)?;
1615
1616 Ok(report)
1617 }
1618
1619 /// Execute a frame transaction (EIP-8141).
1620 /// This bypasses the normal prepare/finalize hooks and orchestrates per-frame execution.
1621 fn execute_frame_tx(&mut self) -> Result<ExecutionReport, VMError> {
1622 use crate::errors::TxResult;
1623
1624 // EIP-8141 fork gating: reject frame transactions observed in a block or
1625 // submitted to any non-mempool entry point before Hegota activates.
1626 if self.env.config.fork < Fork::Hegota {
1627 return Err(VMError::TxValidation(
1628 crate::errors::TxValidationError::FrameTxPreFork,
1629 ));
1630 }
1631
1632 let frame_tx = match &self.tx {
1633 Transaction::FrameTransaction(ft) => ft.clone(),
1634 _ => unreachable!(),
1635 };
1636
1637 // Simplified validation (skip balance deduction, nonce increment, value transfer, EOA check)
1638 // Keep: gas limit checks, fee validation, nonce mismatch check.
1639 // The EOA-check skip is required by EIP-8141 §Transaction origination:
1640 // EIP-3607 must not apply to frame transactions, so the sender may have
1641 // contract code (SENDER frames legitimately originate from contract
1642 // accounts).
1643 let sender = frame_tx.sender;
1644
1645 // Validate static constraints (frame count, reserved modes, atomic batch flags)
1646 if let Err(_e) = frame_tx.validate_static_constraints() {
1647 return Err(VMError::TxValidation(
1648 crate::errors::TxValidationError::InvalidFrameTransaction,
1649 ));
1650 }
1651
1652 // Check nonce matches
1653 let sender_info = self.db.get_account(sender)?.info.clone();
1654 if sender_info.nonce != frame_tx.nonce {
1655 return Err(VMError::TxValidation(
1656 crate::errors::TxValidationError::NonceMismatch {
1657 expected: sender_info.nonce,
1658 actual: frame_tx.nonce,
1659 },
1660 ));
1661 }
1662
1663 // Check priority fee <= max fee
1664 if frame_tx.max_priority_fee_per_gas > frame_tx.max_fee_per_gas {
1665 return Err(VMError::TxValidation(
1666 crate::errors::TxValidationError::PriorityGreaterThanMaxFeePerGas {
1667 priority_fee: U256::from(frame_tx.max_priority_fee_per_gas),
1668 max_fee_per_gas: U256::from(frame_tx.max_fee_per_gas),
1669 },
1670 ));
1671 }
1672
1673 // Check max_fee >= base_fee
1674 if U256::from(frame_tx.max_fee_per_gas) < self.env.base_fee_per_gas {
1675 return Err(VMError::TxValidation(
1676 crate::errors::TxValidationError::InsufficientMaxFeePerGas,
1677 ));
1678 }
1679
1680 // EIP-4844 INSUFFICIENT_MAX_FEE_PER_BLOB_GAS: a blob-carrying tx whose
1681 // max_fee_per_blob_gas is below the block's base blob fee is invalid. The
1682 // frame path is self-contained and never runs the default hook's
1683 // validate_max_fee_per_blob_gas, so it is enforced here. APPROVE charges
1684 // the blob fee at the base rate unconditionally, so without this a frame tx
1685 // below the base blob fee would execute and silently overpay rather than be
1686 // rejected — a divergence from a conformant client that rejects it.
1687 if !frame_tx.blob_versioned_hashes.is_empty()
1688 && frame_tx.max_fee_per_blob_gas < self.env.base_blob_fee_per_gas
1689 {
1690 return Err(VMError::TxValidation(
1691 crate::errors::TxValidationError::InsufficientMaxFeePerBlobGas {
1692 base_fee_per_blob_gas: self.env.base_blob_fee_per_gas,
1693 tx_max_fee_per_blob_gas: frame_tx.max_fee_per_blob_gas,
1694 },
1695 ));
1696 }
1697
1698 // Initialize FrameTxContext
1699 let sig_hash = frame_tx.compute_sig_hash();
1700 let max_gas = frame_tx.max_gas();
1701 self.frame_tx_context = Some(FrameTxContext {
1702 sender_approved: false,
1703 payer_address: None,
1704 frame_results: Vec::new(),
1705 current_frame_index: 0,
1706 sig_hash,
1707 tx: frame_tx.clone(),
1708 approve_called_in_current_frame: false,
1709 max_gas,
1710 blob_base_fee: self.env.base_blob_fee_per_gas,
1711 });
1712
1713 // EIP-8141: every outer signature must validate
1714 // before any frame executes; otherwise the whole transaction is invalid.
1715 if !validate_frame_signatures(
1716 &frame_tx.signatures,
1717 sig_hash,
1718 frame_tx.sender,
1719 self.env.config.fork,
1720 self.crypto,
1721 ) {
1722 return Err(VMError::TxValidation(
1723 crate::errors::TxValidationError::InvalidFrameTransaction,
1724 ));
1725 }
1726
1727 // Tx-level rollback accumulator: if the tx is later declared invalid
1728 // after frames committed, restore `db.current_accounts_state` so the
1729 // payload builder (which reuses the shared db across txs) sees no
1730 // residue — same Err contract as non-frame `execute()`. The per-frame
1731 // backup is cleared between independent frames (and at batch entry), so
1732 // each frame's original values are absorbed here (first-seen-wins)
1733 // before that clear destroys them. Substate is per-VM and discarded on
1734 // Err, so it needs no snapshot.
1735 let mut tx_level_backup = crate::call_frame::CallFrameBackup {
1736 bal_checkpoint: self.db.bal_recorder.as_ref().map(|r| r.checkpoint()),
1737 ..Default::default()
1738 };
1739
1740 // ENTRY_POINT address used as caller for DEFAULT/VERIFY frames
1741 let entry_point = ethrex_common::types::frame_tx_entry_point();
1742
1743 let mut all_logs: Vec<Log> = Vec::new();
1744 let sum_frame_gas_limits: u64 = frame_tx
1745 .frames
1746 .iter()
1747 .map(|f| f.gas_limit)
1748 .fold(0u64, |acc, g| acc.saturating_add(g));
1749 // The non-frame part of `standard_gas_limit`, not of `max_gas`: when the
1750 // calldata floor dominates, the extra reservation is not intrinsic gas and
1751 // is applied once at settlement instead.
1752 let intrinsic_gas = frame_tx
1753 .mandatory_gas()
1754 .saturating_add(frame_tx.data_cost());
1755 let mut total_gas_used: u64 = intrinsic_gas;
1756 let mut tx_invalid = false;
1757
1758 // Atomic batching state: track whether we're inside a batch and
1759 // which frames belong to it so we can revert them all on failure.
1760 let mut in_atomic_batch = false;
1761 let mut batch_start_idx: usize = 0;
1762 let mut batch_logs_start: usize = 0;
1763 let mut batch_approval_snapshot: (bool, Option<Address>) = (false, None);
1764 let mut batch_bal_checkpoint: Option<BlockAccessListCheckpoint> = None;
1765 // EIP-8037: snapshot the shared `state_gas_used` at batch entry so a batch
1766 // revert (which unrolls every in-batch frame's state) also drops the state
1767 // gas those frames accumulated.
1768 let mut state_gas_used_at_batch_entry: i64 = 0;
1769 let mut skip_until_batch_end: Option<usize> = None; // skip remaining frames in a failed batch
1770
1771 // Execute frames sequentially
1772 for (frame_idx, frame) in frame_tx.frames.iter().enumerate() {
1773 // If we're skipping frames due to an atomic batch revert, record
1774 // the frame with status SKIPPED. Per EIP-8141, the
1775 // gas allotted to skipped frames is refunded at the end of the
1776 // transaction, so we record `gas_used = 0` and do NOT add the
1777 // frame's `gas_limit` to `total_gas_used`.
1778 //
1779 // Note (EIP-8141): an expiry verifier frame has flags
1780 // == 0, so it can only be a batch TERMINATOR, never a flagged
1781 // member. A failed batch therefore skips a trailing expiry frame
1782 // and its deadline is not checked at execution time. This is
1783 // benign, not a bypass: `compute_sig_hash` commits to the full
1784 // frame layout (modes, flags incl. the atomic-batch flag, targets,
1785 // and the expiry deadline), and the VERIFY signature is recovered
1786 // over that hash — so no relayer/proposer can wrap a signed tx's
1787 // expiry frame in a failing batch. Only the sender can build this
1788 // layout, and doing so merely disarms their own expiry. The normal
1789 // pattern (a standalone expiry VERIFY frame, not batched) is fully
1790 // enforced. The skip itself is spec-mandated batch semantics; do
1791 // not change it unilaterally — that would be a consensus divergence.
1792 if let Some(end_idx) = skip_until_batch_end {
1793 if frame_idx <= end_idx {
1794 let ctx = self.frame_tx_context.as_mut().ok_or(VMError::Internal(
1795 InternalError::Custom("missing frame tx context".to_string()),
1796 ))?;
1797 ctx.current_frame_index = frame_idx;
1798 ctx.frame_results.push((
1799 ethrex_common::types::FRAME_RECEIPT_STATUS_SKIPPED,
1800 0,
1801 Vec::new(),
1802 ));
1803 if frame_idx == end_idx {
1804 skip_until_batch_end = None;
1805 in_atomic_batch = false;
1806 }
1807 continue;
1808 }
1809 skip_until_batch_end = None;
1810 in_atomic_batch = false;
1811 }
1812
1813 // Clear the outer call-frame backup at the start of each independent
1814 // frame so that a later frame's failure-path `restore_cache_state()`
1815 // only reverts that frame's own effects — not APPROVE/state deltas
1816 // produced by earlier, already-successful frames. Inside an open
1817 // atomic batch we keep accumulating, since a batch revert needs to
1818 // undo every in-batch frame's effects together.
1819 if !in_atomic_batch {
1820 // Absorb this frame's originals into the tx-level accumulator
1821 // before clearing, so an invalid-tx exit can still roll back
1822 // every committed frame's state (see `tx_level_backup`).
1823 tx_level_backup.absorb(&self.current_call_frame.call_frame_backup);
1824 self.current_call_frame.call_frame_backup.clear();
1825 }
1826
1827 // Start a new atomic batch if this frame has the batch flag
1828 // and we're not already in one.
1829 if !in_atomic_batch && frame.is_atomic_batch() {
1830 self.substate.push_backup(); // batch-level snapshot
1831 // EIP-7928: the batch's state changes must leave the block access
1832 // list as well when the batch unrolls, or the builder records
1833 // writes the block does not contain and the block fails its own
1834 // BAL validation on re-execution.
1835 batch_bal_checkpoint = self.db.bal_recorder.as_ref().map(|r| r.checkpoint());
1836 // The outer call-frame backup is already empty here: the
1837 // `!in_atomic_batch` block above absorbed it into
1838 // `tx_level_backup` and cleared it on entry to this frame, so
1839 // the batch starts accumulating a clean, self-contained set of
1840 // state changes that batch-revert can undo wholesale.
1841 in_atomic_batch = true;
1842 batch_start_idx = frame_idx;
1843 batch_logs_start = all_logs.len();
1844 state_gas_used_at_batch_entry = self.state_gas_used;
1845 // Snapshot approvals at batch entry: a batch revert must also
1846 // roll back approvals granted inside the batch (their balance
1847 // and nonce effects are reverted with the substate).
1848 batch_approval_snapshot = self
1849 .frame_tx_context
1850 .as_ref()
1851 .map(|c| c.approval_snapshot())
1852 .unwrap_or((false, None));
1853 }
1854
1855 let ctx =
1856 self.frame_tx_context
1857 .as_mut()
1858 .ok_or(VMError::Internal(InternalError::Custom(
1859 "missing frame tx context".to_string(),
1860 )))?;
1861 ctx.current_frame_index = frame_idx;
1862 ctx.approve_called_in_current_frame = false;
1863
1864 let target = frame.target.unwrap_or(sender);
1865
1866 // Determine caller and static mode per frame mode
1867 let (caller, is_static) = match frame.execution_mode() {
1868 FrameMode::Default => (entry_point, false),
1869 FrameMode::Verify => (entry_point, true),
1870 FrameMode::Sender => {
1871 // SENDER mode requires sender_approved
1872 let ctx = self.frame_tx_context.as_ref().ok_or(VMError::Internal(
1873 InternalError::Custom("missing frame tx context".to_string()),
1874 ))?;
1875 if !ctx.sender_approved {
1876 tx_invalid = true;
1877 break;
1878 }
1879 (sender, false)
1880 }
1881 };
1882
1883 // Set env.origin for this frame (ORIGIN opcode reads this)
1884 self.env.origin = caller;
1885
1886 // Resolve any EIP-7702 delegation at the resolved target. For a non-delegated
1887 // target this is equivalent to `db.get_account_code(target)`; for a delegated
1888 // target it follows the 0xef0100 || addr indicator and returns the delegatee's
1889 // bytecode plus the resolved code_address. EIP-8141 §Execution step 1 requires
1890 // delegated targets to execute the delegatee's code while keeping ADDRESS/storage
1891 // tied to the delegator — which is why `to` below stays `target` but the
1892 // CallFrame receives the resolved `code_address`. Mirrors the pattern used at
1893 // top-level tx entry in default_hook::set_bytecode_and_code_address.
1894 //
1895 // access_cost is intentionally discarded: this frame entry is analogous to a
1896 // top-level tx entry (a call from 0xaa / tx.sender, not a CALL opcode), and
1897 // default_hook.rs drops the same cost there. EIP-8141 §Execution is silent on
1898 // billing the 7702 access cost for `resolved_target`, so we keep frame-entry
1899 // behavior consistent with tx-entry behavior.
1900 let (is_delegation_7702, _access_cost, code_address, bytecode) =
1901 crate::utils::eip7702_get_code(
1902 self.db,
1903 &mut self.substate,
1904 target,
1905 self.env.config.fork,
1906 )?;
1907
1908 // Mirror default_hook::set_bytecode_and_code_address: when delegation was
1909 // followed, record the delegatee (code_address) as touched in BAL so EIP-7928
1910 // reconstructors see the cross-address read.
1911 if is_delegation_7702 && let Some(recorder) = self.db.bal_recorder.as_mut() {
1912 recorder.record_touched_address(code_address);
1913 }
1914
1915 // Log count of the scope this frame's backup will be committed into.
1916 // The CallFrame branch below relies on `run_execution` having already
1917 // committed the frame (merging its logs up into this scope), then
1918 // slices `[substate_logs_before..]` to recover exactly this frame's
1919 // logs without re-committing.
1920 let substate_logs_before = self.substate.logs_len();
1921
1922 // Push substate backup for per-frame state isolation
1923 self.substate.push_backup();
1924
1925 // EIP-8141 top-level value transfer: the outer
1926 // frame call owns CALLVALUE delivery. We only CHECK affordability
1927 // here; the actual transfer runs inside whichever branch executes
1928 // the frame, so it is recorded in the backup that branch restores on
1929 // failure (fixes the value-leak where a reverting contract-target
1930 // SENDER frame kept the funds). Static validation guarantees only
1931 // SENDER frames reach here with a non-zero value.
1932 let value_transfer_reverted = if !frame.value.is_zero() {
1933 let sender_balance = self.db.get_account(sender)?.info.balance;
1934 frame_value_exceeds_balance(sender_balance, frame.value)
1935 } else {
1936 false
1937 };
1938
1939 // Performs the deferred SENDER-frame value transfer + EIP-7708 log.
1940 // Invoked in BOTH execution branches so the transfer is recorded in
1941 // the call_frame_backup that branch's failure path restores.
1942 macro_rules! do_frame_value_transfer {
1943 () => {
1944 if !frame.value.is_zero() && !value_transfer_reverted {
1945 self.transfer(sender, target, frame.value)?;
1946 // EIP-7708 log parity with default_hook::transfer_value:
1947 // only Amsterdam+ and only when sender != target.
1948 if self.env.config.fork >= Fork::Amsterdam && sender != target {
1949 let log =
1950 crate::utils::create_eth_transfer_log(sender, target, frame.value);
1951 self.substate.add_log(log);
1952 }
1953 }
1954 };
1955 }
1956
1957 // EIP-8037: capture state-gas accounting before the frame runs so a
1958 // reverted frame (which commits no state) can be rolled back to
1959 // contribute zero state gas. The reservoir/spill are captured too:
1960 // each frame is gas-isolated, so an inline state-gas refund (e.g. an
1961 // SSTORE 0->N->0 clear, which credits the reservoir) inside one frame
1962 // must NOT carry over to a later frame's charges — the reservoir is
1963 // reset to this entry value after the frame completes.
1964 let state_gas_used_at_frame_entry = self.state_gas_used;
1965 let state_gas_reservoir_at_frame_entry = self.state_gas_reservoir;
1966 let state_gas_spill_at_frame_entry = self.state_gas_spill;
1967
1968 let (frame_success, frame_gas_used, frame_logs) = if value_transfer_reverted {
1969 self.substate.revert_backup();
1970 self.restore_cache_state()?;
1971 (false, frame.gas_limit, Vec::new())
1972 } else if bytecode.is_empty() && !is_delegation_7702 {
1973 // Default code runs only when the target has NEITHER code NOR a delegation
1974 // indicator (EIP-8141 §Execution). After eip7702_get_code,
1975 // bytecode is the delegatee's code when delegated, so a delegation to an
1976 // empty delegatee still falls into the CallFrame branch below and returns
1977 // success without executing anything — NOT into the default-code path.
1978 // current_call_frame is the OUTER frame here; its backup is the
1979 // one this branch's failure path restores, so the deferred
1980 // transfer is correctly undone on a default-code revert.
1981 do_frame_value_transfer!();
1982 use crate::opcode_handlers::frame_tx::execute_default_code;
1983 match execute_default_code(self, frame, target) {
1984 Ok((success, gas_used, logs)) => {
1985 if success {
1986 // Capture this frame's substate logs (incl. the
1987 // EIP-7708 transfer log added by
1988 // do_frame_value_transfer!) BEFORE commit_backup
1989 // merges them into the parent — mirrors the
1990 // CallFrame branch. execute_default_code returns its
1991 // own logs separately, so include both.
1992 let mut this_frame_logs = self.substate.current_logs();
1993 this_frame_logs.extend(logs);
1994 self.substate.commit_backup();
1995 (true, gas_used, this_frame_logs)
1996 } else {
1997 self.substate.revert_backup();
1998 self.restore_cache_state()?;
1999 (false, gas_used, Vec::new())
2000 }
2001 }
2002 Err(_) => {
2003 self.substate.revert_backup();
2004 self.restore_cache_state()?;
2005 (false, frame.gas_limit, Vec::new())
2006 }
2007 }
2008 } else {
2009 // Normal code execution via CallFrame. msg_value carries
2010 // `frame.value` so the contract sees the correct CALLVALUE
2011 // (EIP-8141 §Behavior), but `should_transfer_value` stays
2012 // false because the deferred `do_frame_value_transfer!()` below
2013 // (invoked after the frame swap) owns the transfer — the inner
2014 // CALL machinery must not move the funds a second time.
2015 let call_frame = CallFrame::new(
2016 caller, // msg_sender
2017 target, // to (delegator; ADDRESS/storage)
2018 code_address, // code_address (delegatee when 7702)
2019 bytecode, // bytecode (delegatee's code when 7702)
2020 frame.value, // msg_value -- CALLVALUE
2021 frame.data.clone().into(), // calldata
2022 is_static, // is_static
2023 frame.gas_limit, // gas_limit
2024 0, // depth
2025 false, // should_transfer_value (do_frame_value_transfer! handles it)
2026 false, // is_create
2027 0, // ret_offset
2028 0, // ret_size
2029 self.stack_pool.pop().unwrap_or_default(), // stack
2030 Memory::default(), // memory
2031 );
2032
2033 let saved_call_frame = mem::replace(&mut self.current_call_frame, call_frame);
2034 let saved_call_frames = mem::take(&mut self.call_frames);
2035
2036 // current_call_frame is now the INNER frame, so the deferred
2037 // transfer records into the inner backup that the revert failure
2038 // path (self.substate.revert_backup + restore_cache_state)
2039 // restores — fixing the value-leak on a reverting SENDER frame.
2040 do_frame_value_transfer!();
2041
2042 let frame_result = self.run_execution();
2043
2044 let result = match frame_result {
2045 Ok(ctx_result) => {
2046 let gas_used = ctx_result.gas_used;
2047 let success = ctx_result.is_success();
2048
2049 if success {
2050 // The inner frame is the initial call frame (call_frames
2051 // was emptied above), so `run_execution` already ran
2052 // `handle_state_backup` and committed this frame's backup,
2053 // merging its logs up into the scope measured by
2054 // `substate_logs_before`. Recover exactly this frame's logs
2055 // by slicing off everything that predated it — do NOT commit
2056 // again (a second commit would collapse an extra backup
2057 // level, breaking atomic-batch rollback) and do NOT read
2058 // `current_logs()` wholesale (it now also holds prior frames'
2059 // logs, which would duplicate them into frame_receipts[i]).
2060 let mut merged_logs = self.substate.current_logs();
2061 let this_frame_logs = merged_logs.split_off(substate_logs_before);
2062 (true, gas_used, this_frame_logs)
2063 } else {
2064 // A normal EVM revert reaches `handle_state_backup` inside
2065 // `run_execution`, which already reverted the backup and
2066 // restored the cache for this frame; repeating it here would
2067 // revert an extra level.
2068 (false, gas_used, Vec::new())
2069 }
2070 }
2071 Err(_e) => {
2072 // A `VMError` propagates out of `run_execution` before it reaches
2073 // `handle_state_backup`, so this frame's backup is still live and
2074 // must be reverted (and the cache restored) here.
2075 self.substate.revert_backup();
2076 self.restore_cache_state()?;
2077 (false, frame.gas_limit, Vec::new())
2078 }
2079 };
2080
2081 // Restore call frame state
2082 let finished_frame = mem::replace(&mut self.current_call_frame, saved_call_frame);
2083 self.call_frames = saved_call_frames;
2084
2085 // When a frame succeeds inside an atomic batch, its state
2086 // changes must remain revertable at batch-revert time. Merge
2087 // the finished frame's backup into the outer call-frame backup
2088 // so that `restore_cache_state()` invoked by batch-revert can
2089 // undo them — and so the next clear-and-absorb folds them into
2090 // `tx_level_backup` too. Outside a batch, the finished frame's
2091 // backup never reaches the outer call frame, so absorb it
2092 // directly into `tx_level_backup` here; otherwise an invalid-tx
2093 // exit could not roll back this committed frame's state.
2094 if result.0 {
2095 if in_atomic_batch {
2096 self.merge_call_frame_backup_with_parent(
2097 &finished_frame.call_frame_backup,
2098 )?;
2099 } else {
2100 tx_level_backup.absorb(&finished_frame.call_frame_backup);
2101 }
2102 }
2103
2104 self.stack_pool.push(finished_frame.stack);
2105
2106 result
2107 };
2108
2109 // EIP-8037: a failed frame's state changes were reverted above, so it
2110 // creates no state and must contribute zero state gas. Roll the shared
2111 // `state_gas_used` back to this frame's entry value (the cache/substate
2112 // were already restored in the failure arms). Successful frames keep
2113 // their accumulated state gas.
2114 if !frame_success {
2115 self.state_gas_used = state_gas_used_at_frame_entry;
2116 }
2117 // EIP-8037: frames are gas-isolated, so the state-gas reservoir/spill
2118 // must not leak across the frame boundary. A reservoir credit from an
2119 // inline refund inside this frame (or a reverted frame's inflated
2120 // reservoir) would otherwise subsidize the next frame's state charges
2121 // (drawn before any spill to gas_remaining). Reset both to this frame's
2122 // entry value unconditionally — a successful frame already folded its
2123 // inline refund into `state_gas_used`, so the leftover reservoir credit
2124 // is spent and must be dropped.
2125 self.state_gas_reservoir = state_gas_reservoir_at_frame_entry;
2126 self.state_gas_spill = state_gas_spill_at_frame_entry;
2127
2128 total_gas_used = total_gas_used
2129 .checked_add(frame_gas_used)
2130 .ok_or(VMError::Internal(InternalError::Overflow))?;
2131 all_logs.extend(frame_logs.clone());
2132
2133 // Store frame result in context
2134 let ctx =
2135 self.frame_tx_context
2136 .as_mut()
2137 .ok_or(VMError::Internal(InternalError::Custom(
2138 "missing frame tx context".to_string(),
2139 )))?;
2140 let status_code = if frame_success {
2141 ethrex_common::types::FRAME_RECEIPT_STATUS_SUCCESS
2142 } else {
2143 ethrex_common::types::FRAME_RECEIPT_STATUS_FAILURE
2144 };
2145 ctx.frame_results
2146 .push((status_code, frame_gas_used, frame_logs));
2147
2148 // Atomic batch: if a frame in the batch reverted, revert the
2149 // batch-level snapshot and skip remaining frames in the batch.
2150 if in_atomic_batch && !frame_success {
2151 self.substate.revert_backup(); // revert batch-level snapshot
2152 self.restore_cache_state()?;
2153 // EIP-7928: drop the batch's recorded changes. `restore` re-files a
2154 // freshly-written slot as a read and leaves `touched_addresses`
2155 // alone, so the accesses the batch made still appear -- only the
2156 // reverted changes go.
2157 if let Some(checkpoint) = batch_bal_checkpoint.take()
2158 && let Some(recorder) = self.db.bal_recorder.as_mut()
2159 {
2160 recorder.restore(checkpoint);
2161 }
2162 // EIP-8037: the whole batch unrolled, so none of its frames created
2163 // state — drop the state gas accumulated since batch entry.
2164 self.state_gas_used = state_gas_used_at_batch_entry;
2165
2166 // EIP-8141: frames that executed before the failure retain their
2167 // execution status and gas used; only their logs are discarded,
2168 // together with the state changes the unroll drops. `total_gas_used`
2169 // therefore stands as executed, and the failing frame keeps the gas
2170 // the single-frame path already charged it (actual `gas_used` for a
2171 // `REVERT`, the full `gas_limit` for an exceptional halt).
2172 let ctx = self.frame_tx_context.as_mut().ok_or(VMError::Internal(
2173 InternalError::Custom("missing frame tx context".to_string()),
2174 ))?;
2175 for result in ctx
2176 .frame_results
2177 .get_mut(batch_start_idx..=frame_idx)
2178 .into_iter()
2179 .flatten()
2180 {
2181 result.2 = Vec::new();
2182 }
2183 // Roll back approvals granted inside the reverted batch.
2184 ctx.restore_approvals(batch_approval_snapshot);
2185 // Remove only logs from the batch, preserving pre-batch logs
2186 all_logs.truncate(batch_logs_start);
2187
2188 // Spec: a reverted VERIFY frame invalidates the transaction even
2189 // inside an atomic batch. The batch unroll above already rolled
2190 // back state/approvals; validity is a tx-level decision. (The
2191 // failing `frame` here is the one that triggered the revert.)
2192 if frame.execution_mode() == FrameMode::Verify {
2193 tx_invalid = true;
2194 break;
2195 }
2196
2197 // Find the end of this batch (the first frame at or after the
2198 // failing one without the flag)
2199 let batch_end = find_batch_end(&frame_tx.frames, frame_idx);
2200
2201 if batch_end > frame_idx {
2202 skip_until_batch_end = Some(batch_end);
2203 } else {
2204 in_atomic_batch = false;
2205 }
2206 self.substate.clear_transient_storage();
2207 continue;
2208 }
2209
2210 // If this is the last frame of a batch (a frame without the flag), commit the batch
2211 if in_atomic_batch && !frame.is_atomic_batch() {
2212 self.substate.commit_backup(); // commit batch-level snapshot
2213 in_atomic_batch = false;
2214 }
2215
2216 // VERIFY frame enforcement: a reverted
2217 // VERIFY frame invalidates the transaction. A VERIFY frame that
2218 // succeeds WITHOUT calling APPROVE is valid (e.g. the expiry
2219 // verifier frame). A reverted VERIFY frame invalidates the tx;
2220 // batched VERIFY reverts are handled in the atomic-batch-revert
2221 // branch above (which also sets tx_invalid).
2222 if frame.execution_mode() == FrameMode::Verify && !frame_success {
2223 tx_invalid = true;
2224 break;
2225 }
2226
2227 // Clear transient storage between frames
2228 self.substate.clear_transient_storage();
2229 }
2230
2231 // Post-execution, per EIP-8141: "verify that `payer` has been set
2232 // (i.e. `payer != None`). If `payer` is set, refund any unpaid gas to
2233 // the payer. If it is not, the whole transaction is invalid."
2234 let ctx =
2235 self.frame_tx_context
2236 .as_ref()
2237 .ok_or(VMError::Internal(InternalError::Custom(
2238 "missing frame tx context".to_string(),
2239 )))?;
2240 if ctx.payer_address.is_none() {
2241 tx_invalid = true;
2242 }
2243
2244 if tx_invalid {
2245 // TX is invalid — Err must leave `db.current_accounts_state`
2246 // unchanged from before the tx (same contract as non-frame
2247 // `execute()`). Absorb the last live frame's backup (it has not
2248 // been cleared yet), then restore every absorbed frame's effects.
2249 // Substate is per-VM and discarded when this VM drops, so no
2250 // substate revert is needed.
2251 tx_level_backup.absorb(&self.current_call_frame.call_frame_backup);
2252 crate::utils::restore_cache_state(self.db, tx_level_backup)?;
2253 return Err(VMError::TxValidation(
2254 crate::errors::TxValidationError::InvalidFrameTransaction,
2255 ));
2256 }
2257
2258 // Take ownership of frame context
2259 let ctx = self
2260 .frame_tx_context
2261 .take()
2262 .ok_or(VMError::Internal(InternalError::Custom(
2263 "missing frame tx context".to_string(),
2264 )))?;
2265 let payer = ctx.payer_address.unwrap_or(sender);
2266
2267 // EIP-8141 gas finalization. EIP-3529 storage refunds accumulate into a
2268 // single transaction-scoped counter across frames — `substate.refunded_gas`,
2269 // which a reverted frame or an unrolled atomic batch drops along with its
2270 // state changes — and are applied once here, capped at a fifth of the gas
2271 // used before refunds. The EIP-7623 calldata floor then applies to the frame
2272 // and signature data: the mandatory costs are always charged, and the data
2273 // cost is floored against what execution actually consumed.
2274 let mandatory_gas = frame_tx.mandatory_gas();
2275 let applied_refund = self
2276 .substate
2277 .refunded_gas
2278 .min(total_gas_used / crate::hooks::default_hook::MAX_REFUND_QUOTIENT);
2279 let data_and_execution = total_gas_used
2280 .saturating_sub(applied_refund)
2281 .saturating_sub(mandatory_gas);
2282 let total_gas_used =
2283 mandatory_gas.saturating_add(data_and_execution.max(frame_tx.calldata_floor_gas()));
2284
2285 // Gas refunds: the payer was debited the transaction's `max_cost` at
2286 // APPROVE (`max_gas` at `max_fee_per_gas`, plus the blob cost already at
2287 // the base rate, `compute_tx_max_cost`, §Gas accounting). What the payer
2288 // owes is the effective-rate cost of the gas actually used plus the same
2289 // base-rate blob burn, so the blob terms cancel and the burn is collected
2290 // exactly once and never refunded. Everything above that is returned here.
2291 // Intrinsic gas is inside `total_gas_used`, so it stays non-refundable.
2292 // When max_fee == effective_gas_price this reduces exactly to the unused
2293 // -frame-gas refund: max·T + B − e·U − B = e·(T − U).
2294 let effective_gas_price = self.env.gas_price;
2295 let charged = crate::opcode_handlers::frame_tx::compute_tx_max_cost(&ctx)
2296 .map_err(|_| VMError::Internal(InternalError::Overflow))?;
2297 let blob_burn = crate::utils::calculate_blob_gas_cost(
2298 &ctx.tx.blob_versioned_hashes,
2299 self.env.base_blob_fee_per_gas,
2300 )?;
2301 let owed = effective_gas_price
2302 .checked_mul(U256::from(total_gas_used))
2303 .and_then(|gas_owed| gas_owed.checked_add(blob_burn))
2304 .ok_or(VMError::Internal(InternalError::Overflow))?;
2305 // charged >= owed always: effective <= max_fee (by construction of the
2306 // effective price), the blob terms are identical, and total_gas_used <=
2307 // max_gas (frames are bounded by their limits, and the floor is charged
2308 // on both sides).
2309 let refund_amount = charged
2310 .checked_sub(owed)
2311 .ok_or(VMError::Internal(InternalError::Underflow))?;
2312
2313 self.increase_account_balance(payer, refund_amount)?;
2314
2315 // Pay coinbase. Mirror `pay_coinbase` on the normal-tx path so a frame
2316 // transaction has the same coinbase BlockAccessList / witness footprint:
2317 // EIP-7928 requires the coinbase to appear in the BAL for any user tx even
2318 // when the priority fee is zero, and EELS reads the coinbase account
2319 // unconditionally during fee transfer (so EIP-8025 witnesses record its
2320 // trie path even at zero fee). A frame tx is always a user tx (non-zero
2321 // effective gas price), unlike a system call.
2322 let priority_fee = effective_gas_price.saturating_sub(self.env.base_fee_per_gas);
2323 let coinbase_fee = priority_fee
2324 .checked_mul(U256::from(total_gas_used))
2325 .ok_or(VMError::Internal(InternalError::Overflow))?;
2326 if !effective_gas_price.is_zero()
2327 && let Some(recorder) = self.db.bal_recorder.as_mut()
2328 {
2329 recorder.record_touched_address(self.env.coinbase);
2330 }
2331 if coinbase_fee.is_zero() {
2332 // Zero priority fee: still read the coinbase so its witness/BAL trie
2333 // path (incl. an exclusion proof if absent) is recorded.
2334 self.db.get_account(self.env.coinbase)?;
2335 } else {
2336 self.increase_account_balance(self.env.coinbase, coinbase_fee)?;
2337 }
2338
2339 // EIP-8141: finalize self-destructs at tx end, mirroring the default
2340 // finalize hook ordering (refund -> coinbase -> delete). SELFDESTRUCT is
2341 // unrestricted during frame execution (the banned-opcode set only applies
2342 // to the mempool validation prefix), so a frame may mark same-tx-created
2343 // accounts for deletion (EIP-6780). Without this they leak into the
2344 // post-state. `iter_selfdestruct` walks the full substate chain, so a
2345 // single call here covers every committed frame. Any EIP-7708 burn logs
2346 // emitted (rare: only when a destroyed account later received ETH in the
2347 // same tx) are appended to the tx-level aggregate `all_logs`: the per-frame
2348 // consensus receipts have no slot for end-of-tx logs, but the header and
2349 // receipt blooms are derived from these aggregate logs (receipt.rs,
2350 // payload.rs), so they must be recorded there.
2351 //
2352 // KNOWN SPEC GAP (cross-client interop): these tx-end burn logs are in the
2353 // aggregate bloom but NOT in any per-frame consensus receipt, so a client
2354 // that re-derives the type-0x06 logs_bloom purely from the canonical
2355 // per-frame logs would compute a different bloom (part of the block hash).
2356 // ethrex producer and validator agree (both execute over the live
2357 // aggregate), so this is not an intra-client split; it only bites
2358 // cross-client and only for this rare destroy-after-receiving-ETH case.
2359 // EIP-7708 is silent on receipt placement and EIP-8141's frame receipt has
2360 // no tx-level logs slot, so the canonical home is genuinely undefined; do
2361 // not pin one (e.g. attribute to the last frame) unilaterally — confirm the
2362 // bloom-derivation rule against another client / the devnet first.
2363 let logs_before = self.substate.current_logs().len();
2364 crate::hooks::default_hook::delete_self_destruct_accounts(self)?;
2365 let mut logs_after = self.substate.current_logs();
2366 if logs_after.len() > logs_before {
2367 all_logs.extend(logs_after.split_off(logs_before));
2368 }
2369
2370 // Frame txs short-circuit the hook/finalize path, so the BackupHook never
2371 // runs and `db.tx_backup` stays None on the success path. A block builder
2372 // can execute a frame tx successfully and then reject it (e.g. the EIP-8037
2373 // 2D-gas overflow post-check, or an L2 invalid cross-chain message), calling
2374 // `undo_last_tx` — which needs the top-level backup to roll the committed
2375 // state back; otherwise the excluded tx's mutations leak into later txs and
2376 // the built block's state root diverges from re-execution. Populate it here
2377 // exactly as `BackupHook::finalize_execution` would: `tx_level_backup` holds
2378 // every committed frame's originals, and the outer call-frame backup now also
2379 // holds the refund/coinbase/self-destruct originals. Gated on
2380 // `preserve_top_level_backup` so it is set only when a hook reads it (L2);
2381 // on L1 the field is never consulted.
2382 if self.preserve_top_level_backup {
2383 tx_level_backup.absorb(&self.current_call_frame.call_frame_backup);
2384 self.db.tx_backup = Some(tx_level_backup);
2385 }
2386
2387 // Derive top-level status from ALL frames: the transaction succeeded
2388 // only if every executed frame succeeded; a reverted or skipped frame of
2389 // ANY mode (SENDER, DEFAULT, VERIFY) yields a failed top-level status
2390 // (analogous to status 0 in standard transactions). This MUST match the
2391 // consensus-receipt derivation in `Receipt` decoding (receipt.rs), which
2392 // re-derives `succeeded` from the per-frame statuses ALONE — the
2393 // consensus `frame_receipt` carries no frame mode, so an all-frames rule
2394 // is the only definition the encode side (here) and a wire/trie decode
2395 // can compute identically. Deriving from SENDER frames only would make a
2396 // freshly-executed receipt's `status` disagree with the same receipt
2397 // decoded from consensus bytes. (A reverted VERIFY frame already
2398 // invalidated the tx above via `tx_invalid`; this additionally covers
2399 // reverted SENDER/DEFAULT frames, which do not.)
2400 let any_frame_reverted = ctx
2401 .frame_results
2402 .iter()
2403 .any(|(status, _, _)| *status != ethrex_common::types::FRAME_RECEIPT_STATUS_SUCCESS);
2404
2405 let result = if any_frame_reverted {
2406 TxResult::Revert(VMError::RevertOpcode)
2407 } else {
2408 TxResult::Success
2409 };
2410
2411 // EIP-8037: report the transaction's net state gas (same formula as the
2412 // normal-tx path in default_hook). `total_gas_used` already includes the
2413 // state gas — it spilled into each frame's gas_remaining, exactly as for any
2414 // sub-TX_MAX_GAS_LIMIT transaction whose reservoir is 0 — so reporting
2415 // `state_gas_used` here lets the block-level regular/state split
2416 // (regular = gas_used - state_gas_used) attribute it to the state dimension
2417 // instead of billing the whole amount as regular gas.
2418 let state_gas_used =
2419 u64::try_from(self.state_gas_used.max(0)).map_err(|_| InternalError::Overflow)?;
2420
2421 // Unused frame gas in GAS UNITS for the report — distinct from the wei
2422 // refund above (which also returns the max-vs-effective fee delta).
2423 let frame_gas_used = total_gas_used.saturating_sub(intrinsic_gas);
2424 let gas_refund = sum_frame_gas_limits.saturating_sub(frame_gas_used);
2425
2426 let report = ExecutionReport {
2427 result,
2428 gas_used: total_gas_used,
2429 gas_spent: total_gas_used,
2430 gas_refunded: gas_refund,
2431 state_gas_used,
2432 output: Bytes::new(),
2433 logs: all_logs,
2434 payer_address: ctx.payer_address,
2435 frame_results: Some(ctx.frame_results),
2436 };
2437
2438 Ok(report)
2439 }
2440
2441 /// EIP-8141 mempool entry point: set up the frame-tx context and observer,
2442 /// then simulate the validation prefix.
2443 ///
2444 /// Performs the frame-tx preamble (static constraints, nonce, fee sanity,
2445 /// `FrameTxContext` init, outer-signature validation) — the same checks
2446 /// `execute_frame_tx` runs before any frame — then activates the
2447 /// [`ValidationObserver`](crate::validation_observer::ValidationObserver) for
2448 /// `sender` with the prefix's `deploy_index`, runs the prefix via
2449 /// [`VM::simulate_validation_prefix`], and returns the raw simulation
2450 /// result. Does NOT charge or refund gas. `canonical_paymaster_pay_frame`
2451 /// is the index of a canonical paymaster's pay frame (always `None` today,
2452 /// OQ1); when set, the access-restriction skip fires for that frame.
2453 pub fn run_frame_validation_prefix(
2454 &mut self,
2455 frame_indices: &[usize],
2456 deploy_index: Option<usize>,
2457 canonical_paymaster_pay_frame: Option<usize>,
2458 ) -> Result<PrefixSimResult, VMError> {
2459 use crate::validation_observer::ValidationObserver;
2460
2461 if self.env.config.fork < Fork::Hegota {
2462 return Err(VMError::TxValidation(
2463 crate::errors::TxValidationError::FrameTxPreFork,
2464 ));
2465 }
2466
2467 let frame_tx = match &self.tx {
2468 Transaction::FrameTransaction(ft) => ft.clone(),
2469 _ => {
2470 return Err(VMError::Internal(InternalError::Custom(
2471 "run_frame_validation_prefix called on non-frame tx".to_string(),
2472 )));
2473 }
2474 };
2475
2476 let sender = frame_tx.sender;
2477
2478 if frame_tx.validate_static_constraints().is_err() {
2479 return Err(VMError::TxValidation(
2480 crate::errors::TxValidationError::InvalidFrameTransaction,
2481 ));
2482 }
2483
2484 let sender_info = self.db.get_account(sender)?.info.clone();
2485 if sender_info.nonce != frame_tx.nonce {
2486 return Err(VMError::TxValidation(
2487 crate::errors::TxValidationError::NonceMismatch {
2488 expected: sender_info.nonce,
2489 actual: frame_tx.nonce,
2490 },
2491 ));
2492 }
2493
2494 if frame_tx.max_priority_fee_per_gas > frame_tx.max_fee_per_gas {
2495 return Err(VMError::TxValidation(
2496 crate::errors::TxValidationError::PriorityGreaterThanMaxFeePerGas {
2497 priority_fee: U256::from(frame_tx.max_priority_fee_per_gas),
2498 max_fee_per_gas: U256::from(frame_tx.max_fee_per_gas),
2499 },
2500 ));
2501 }
2502
2503 if U256::from(frame_tx.max_fee_per_gas) < self.env.base_fee_per_gas {
2504 return Err(VMError::TxValidation(
2505 crate::errors::TxValidationError::InsufficientMaxFeePerGas,
2506 ));
2507 }
2508
2509 let sig_hash = frame_tx.compute_sig_hash();
2510 let max_gas = frame_tx.max_gas();
2511 self.frame_tx_context = Some(FrameTxContext {
2512 sender_approved: false,
2513 payer_address: None,
2514 frame_results: Vec::new(),
2515 current_frame_index: 0,
2516 sig_hash,
2517 tx: frame_tx.clone(),
2518 approve_called_in_current_frame: false,
2519 max_gas,
2520 blob_base_fee: self.env.base_blob_fee_per_gas,
2521 });
2522
2523 if !validate_frame_signatures(
2524 &frame_tx.signatures,
2525 sig_hash,
2526 frame_tx.sender,
2527 self.env.config.fork,
2528 self.crypto,
2529 ) {
2530 return Err(VMError::TxValidation(
2531 crate::errors::TxValidationError::InvalidFrameTransaction,
2532 ));
2533 }
2534
2535 let expiry_verifier = ethrex_common::types::frame_tx_expiry_verifier();
2536 let mut observer = ValidationObserver::new(sender, deploy_index, expiry_verifier);
2537 observer.canonical_paymaster_pay_frame = canonical_paymaster_pay_frame;
2538 self.validation_observer = observer;
2539
2540 self.simulate_validation_prefix(frame_indices)
2541 }
2542
2543 /// EIP-8141 mempool validation-prefix simulation (local peer policy).
2544 ///
2545 /// Runs ONLY the validation-prefix frames (the verify/pay/deploy frames that
2546 /// must execute before the transaction's payer is established) of a frame
2547 /// transaction, under an active [`ValidationObserver`](crate::validation_observer::ValidationObserver),
2548 /// then stops as soon as the payer has been set. Reuses the real frame
2549 /// execution primitives (`eip7702_get_code`, `execute_default_code`,
2550 /// `run_execution`, substate backups, value transfer) over the DEFAULT/VERIFY
2551 /// subset that prefixes are restricted to (Phase 1 structural rules forbid
2552 /// SENDER and atomic-batch frames in the prefix), so it dispatches real
2553 /// opcodes through the real handlers — not a separate mini-EVM.
2554 ///
2555 /// `frame_indices` are the prefix frame indices (in order), as identified by
2556 /// `FrameTransaction::validation_prefix`; expiry-verifier frames interleaved
2557 /// in the prefix are run too (they may appear between prefix frames). The
2558 /// caller must have set `frame_tx_context` and activated the observer.
2559 ///
2560 /// Returns [`PrefixSimResult`] describing the outcome. Does NOT charge fees
2561 /// or refund gas (mempool simulation only); state changes accumulate in the
2562 /// shared `db` and are discarded by the caller (a fresh simulation database).
2563 pub fn simulate_validation_prefix(
2564 &mut self,
2565 frame_indices: &[usize],
2566 ) -> Result<PrefixSimResult, VMError> {
2567 let frame_tx = match &self.tx {
2568 Transaction::FrameTransaction(ft) => ft.clone(),
2569 _ => {
2570 return Err(VMError::Internal(InternalError::Custom(
2571 "simulate_validation_prefix called on non-frame tx".to_string(),
2572 )));
2573 }
2574 };
2575
2576 let sender = frame_tx.sender;
2577 let entry_point = ethrex_common::types::frame_tx_entry_point();
2578
2579 let mut total_gas_used: u64 = 0;
2580 let mut any_revert = false;
2581 // The highest prefix-frame index we must run before stopping. We run the
2582 // prefix in source order, executing every frame from 0 up to and
2583 // including the last prefix index (covering interleaved expiry frames).
2584 let last_prefix_idx = frame_indices.iter().copied().max();
2585
2586 for (frame_idx, frame) in frame_tx.frames.iter().enumerate() {
2587 // Stop once the whole prefix has run (and the payer break below).
2588 if let Some(stop) = last_prefix_idx {
2589 if frame_idx > stop {
2590 break;
2591 }
2592 } else {
2593 break;
2594 }
2595
2596 // Each independent prefix frame starts with a clean call-frame backup
2597 // so a later frame's failure only reverts its own effects.
2598 self.current_call_frame.call_frame_backup.clear();
2599
2600 let ctx =
2601 self.frame_tx_context
2602 .as_mut()
2603 .ok_or(VMError::Internal(InternalError::Custom(
2604 "missing frame tx context".to_string(),
2605 )))?;
2606 ctx.current_frame_index = frame_idx;
2607 ctx.approve_called_in_current_frame = false;
2608
2609 let target = frame.target.unwrap_or(sender);
2610
2611 // Sync observer per-frame fields before the frame runs.
2612 self.validation_observer.current_frame_index = frame_idx;
2613 self.validation_observer.current_frame_mode = frame.mode;
2614
2615 // Prefix frames are DEFAULT (deploy) or VERIFY only; both run with
2616 // ENTRY_POINT as caller (DEFAULT not static, VERIFY static).
2617 let (caller, is_static) = match frame.execution_mode() {
2618 FrameMode::Default => (entry_point, false),
2619 FrameMode::Verify => (entry_point, true),
2620 FrameMode::Sender => {
2621 // Structural rules exclude SENDER frames from the prefix.
2622 return Err(VMError::Internal(InternalError::Custom(
2623 "SENDER frame in validation prefix".to_string(),
2624 )));
2625 }
2626 };
2627
2628 self.env.origin = caller;
2629
2630 let (is_delegation_7702, _access_cost, code_address, bytecode) =
2631 crate::utils::eip7702_get_code(
2632 self.db,
2633 &mut self.substate,
2634 target,
2635 self.env.config.fork,
2636 )?;
2637
2638 self.substate.push_backup();
2639
2640 let value_transfer_reverted = if !frame.value.is_zero() {
2641 let sender_balance = self.db.get_account(sender)?.info.balance;
2642 frame_value_exceeds_balance(sender_balance, frame.value)
2643 } else {
2644 false
2645 };
2646
2647 let (frame_success, frame_gas_used) = if value_transfer_reverted {
2648 self.substate.revert_backup();
2649 self.restore_cache_state()?;
2650 (false, frame.gas_limit)
2651 } else if bytecode.is_empty() && !is_delegation_7702 {
2652 // Default-code path (target has neither code nor a delegation).
2653 if !frame.value.is_zero() {
2654 self.transfer(sender, target, frame.value)?;
2655 }
2656 use crate::opcode_handlers::frame_tx::execute_default_code;
2657 match execute_default_code(self, frame, target) {
2658 Ok((success, gas_used, _logs)) => {
2659 if success {
2660 self.substate.commit_backup();
2661 (true, gas_used)
2662 } else {
2663 self.substate.revert_backup();
2664 self.restore_cache_state()?;
2665 (false, gas_used)
2666 }
2667 }
2668 Err(_) => {
2669 self.substate.revert_backup();
2670 self.restore_cache_state()?;
2671 (false, frame.gas_limit)
2672 }
2673 }
2674 } else {
2675 // Normal code execution via a child CallFrame.
2676 let call_frame = CallFrame::new(
2677 caller,
2678 target,
2679 code_address,
2680 bytecode,
2681 frame.value,
2682 frame.data.clone().into(),
2683 is_static,
2684 frame.gas_limit,
2685 0,
2686 false,
2687 false,
2688 0,
2689 0,
2690 self.stack_pool.pop().unwrap_or_default(),
2691 Memory::default(),
2692 );
2693
2694 let saved_call_frame = mem::replace(&mut self.current_call_frame, call_frame);
2695 let saved_call_frames = mem::take(&mut self.call_frames);
2696
2697 if !frame.value.is_zero() {
2698 self.transfer(sender, target, frame.value)?;
2699 }
2700
2701 let frame_result = self.run_execution();
2702
2703 let result = match frame_result {
2704 Ok(ctx_result) => {
2705 let gas_used = ctx_result.gas_used;
2706 // The inner frame is the initial call frame, so `run_execution`
2707 // already committed (success) or reverted + restored the cache
2708 // (revert) this frame's backup via `handle_state_backup`. Only a
2709 // `VMError` (the `Err` arm) leaves the backup live for us to undo.
2710 (ctx_result.is_success(), gas_used)
2711 }
2712 Err(_e) => {
2713 self.substate.revert_backup();
2714 self.restore_cache_state()?;
2715 (false, frame.gas_limit)
2716 }
2717 };
2718
2719 let finished_frame = mem::replace(&mut self.current_call_frame, saved_call_frame);
2720 self.call_frames = saved_call_frames;
2721 self.stack_pool.push(finished_frame.stack);
2722
2723 result
2724 };
2725
2726 total_gas_used = total_gas_used
2727 .checked_add(frame_gas_used)
2728 .ok_or(VMError::Internal(InternalError::Overflow))?;
2729
2730 if !frame_success {
2731 any_revert = true;
2732 }
2733
2734 // A reverted prefix frame is fatal: the transaction can never reach a
2735 // valid payer through a reverted verify/pay/deploy frame.
2736 if !frame_success {
2737 break;
2738 }
2739
2740 self.substate.clear_transient_storage();
2741
2742 // Stop as soon as the payer has been set (the prefix is complete).
2743 if self
2744 .frame_tx_context
2745 .as_ref()
2746 .and_then(|c| c.payer_address)
2747 .is_some()
2748 {
2749 break;
2750 }
2751 }
2752
2753 let ctx =
2754 self.frame_tx_context
2755 .as_ref()
2756 .ok_or(VMError::Internal(InternalError::Custom(
2757 "missing frame tx context".to_string(),
2758 )))?;
2759
2760 Ok(PrefixSimResult {
2761 any_revert,
2762 payer_address: ctx.payer_address,
2763 sender_approved: ctx.sender_approved,
2764 total_gas_used,
2765 })
2766 }
2767
2768 /// Must run after `prepare_execution` so EIP-7702 delegation is already resolved into
2769 /// `bytecode`.
2770 #[inline(always)]
2771 fn is_simple_transfer_fast_path(&self) -> bool {
2772 !self.current_call_frame.is_create
2773 && self.current_call_frame.bytecode.is_empty()
2774 // Privileged L2 txs can leave gas negative; let the slow path surface that as OOG.
2775 && self.current_call_frame.gas_remaining >= 0
2776 && self.tx.authorization_list().is_none()
2777 // Precompiles dispatch via run_execution even with empty bytecode.
2778 && !self.address_is_precompile(&self.current_call_frame.to)
2779 }
2780
2781 /// [`precompiles::is_precompile`], honoring simulation-only relocations from
2782 /// `movePrecompileToAddress`. Identical to the plain check on consensus paths,
2783 /// where `db.precompile_moves` is `None`.
2784 pub(crate) fn address_is_precompile(&self, address: &Address) -> bool {
2785 precompiles::is_precompile_with_moves(
2786 address,
2787 self.env.config.fork,
2788 self.vm_type,
2789 self.db.precompile_moves.as_deref(),
2790 )
2791 }
2792
2793 /// The address whose precompile implementation should execute for a call to
2794 /// `address` (identity mapping unless `address` is a relocation destination).
2795 pub(crate) fn effective_precompile_address(&self, address: Address) -> Address {
2796 precompiles::effective_precompile_address(address, self.db.precompile_moves.as_deref())
2797 }
2798
2799 /// Main execution loop.
2800 pub fn run_execution(&mut self) -> Result<ContextResult, VMError> {
2801 // If gas is already exhausted (negative), fail immediately.
2802 // This can happen when intrinsic gas exceeds the gas limit in privileged L2 transactions.
2803 // Without this check, casting negative gas_remaining to u64 would wrap to a huge value.
2804 if self.current_call_frame.gas_remaining < 0 {
2805 return Ok(ContextResult {
2806 result: TxResult::Revert(ExceptionalHalt::OutOfGas.into()),
2807 gas_used: self.current_call_frame.gas_limit,
2808 gas_spent: self.current_call_frame.gas_limit,
2809 output: Bytes::new(),
2810 });
2811 }
2812
2813 // EIP-8037: the atomic prepare region rolled back (one of its charges — 7702
2814 // auth, CREATE/value NEW_ACCOUNT, recipient delegation-resolve — OOG'd).
2815 // Burn all gas and revert the tx rather than rejecting it as invalid (which
2816 // would wrongly invalidate the block). Mirrors EELS depth-0
2817 // `except ExceptionalHalt: evm.regular_gas_used += evm.gas_left; evm.gas_left = 0`
2818 // (`interpreter.py:366-374`).
2819 if self.pending_prep_oog {
2820 return Ok(ContextResult {
2821 result: TxResult::Revert(ExceptionalHalt::OutOfGas.into()),
2822 gas_used: self.current_call_frame.gas_limit,
2823 gas_spent: self.current_call_frame.gas_limit,
2824 output: Bytes::new(),
2825 });
2826 }
2827
2828 // A charged value-to-not-alive NEW_ACCOUNT means the recipient was not yet alive
2829 // at tx start and the tx transfers value. If the recipient is a precompile that
2830 // then exceptionally halts/reverts, the account is never materialized, so the
2831 // charge is rolled back in the precompile branch below (mirrors EELS
2832 // `refill_frame_state_gas`). Set in-region by `prepare_execution`.
2833 let top_frame_new_account_charged = self.value_new_account_charged;
2834
2835 #[expect(clippy::as_conversions, reason = "remaining gas conversion")]
2836 if self.address_is_precompile(&self.current_call_frame.to) {
2837 // `execute_precompile` itself never touches state gas (it only mutates
2838 // `gas_remaining`; it takes `&self`, so it cannot change `state_gas_used` /
2839 // `state_gas_reservoir` / `state_gas_spill`) — the assert below guards that. The
2840 // in-region EIP-2780 value-to-not-alive NEW_ACCOUNT charge (`prepare_execution`),
2841 // however, IS frame state gas, and on an exceptional halt/revert it must be
2842 // rolled back (see below). `self` is borrowed by field rather than via
2843 // `&mut self.current_call_frame` so the refund call, which needs `&mut self`,
2844 // can run after `execute_precompile`.
2845 let state_gas_used_before_precompile = self.state_gas_used;
2846 let precompile_gas_limit = self.current_call_frame.gas_limit;
2847 let mut gas_remaining = self.current_call_frame.gas_remaining as u64;
2848 let result = self.execute_precompile(
2849 self.current_call_frame.code_address,
2850 &self.current_call_frame.calldata,
2851 precompile_gas_limit,
2852 &mut gas_remaining,
2853 );
2854
2855 debug_assert_eq!(
2856 self.state_gas_used, state_gas_used_before_precompile,
2857 "precompile execution must not mutate state_gas_used"
2858 );
2859
2860 // EIP-8037 Amsterdam 2D accounting recomputes `block_gas_used` from
2861 // `raw_consumed = gas_limit - gas_remaining` inside `refund_sender`. On a
2862 // top-level precompile exceptional halt, `handle_precompile_result` already
2863 // sets `ContextResult.gas_used = gas_limit`, but `gas_remaining` retains the
2864 // untouched forwarded amount — under Amsterdam that would make the block
2865 // report only the intrinsic portion. Zero it so the block matches the
2866 // `gas_used = gas_limit` contract from `handle_precompile_result`, and roll
2867 // back the in-region value NEW_ACCOUNT charge (the recipient is never materialized
2868 // on halt) so the burned gas counts entirely as regular gas, matching EELS
2869 // `refill_frame_state_gas`. Pre-Amsterdam reads `ctx_result.gas_used` directly
2870 // and is unaffected by this path either way.
2871 if self.env.config.fork >= Fork::Amsterdam
2872 && let Ok(ctx) = &result
2873 && !ctx.is_success()
2874 {
2875 gas_remaining = 0;
2876 self.refund_new_account_state_gas(top_frame_new_account_charged)?;
2877 }
2878
2879 self.current_call_frame.gas_remaining = gas_remaining as i64;
2880
2881 return result;
2882 }
2883
2884 // Specialize the dispatch loop on whether a struct-log tracer is active.
2885 // The `!TRACED` variant compiles out every tracer branch and capture call,
2886 // leaving a minimal hot loop (the common, non-traced case).
2887 match (self.opcode_tracer.active, self.validation_observer.active) {
2888 (false, false) => self.run_dispatch::<false, false>(),
2889 (false, true) => self.run_dispatch::<false, true>(),
2890 (true, false) => self.run_dispatch::<true, false>(),
2891 (true, true) => self.run_dispatch::<true, true>(),
2892 }
2893 }
2894
2895 /// Opcode dispatch loop, monomorphized over whether a struct-log tracer is
2896 /// active. With `TRACED = false` the compiler eliminates the tracer branches
2897 /// and the cold `trace_*_step` calls entirely, so the hot loop body stays
2898 /// minimal; the traced variant keeps the cold helpers out of line.
2899 fn run_dispatch<const TRACED: bool, const VALIDATING: bool>(
2900 &mut self,
2901 ) -> Result<ContextResult, VMError> {
2902 let mut error = OnceCell::<VMError>::new();
2903
2904 #[cfg(feature = "perf_opcode_timings")]
2905 let mut timings = crate::timings::OPCODE_TIMINGS.lock().expect("poison");
2906
2907 // Copy the `&'static` table pointer once; it doesn't borrow `self`, so dispatch can still
2908 // pass `self` mutably to the handler without reloading the pointer each iteration.
2909 let opcode_table = self.opcode_table;
2910
2911 loop {
2912 // Capture pc BEFORE advance_pc() — this is the address of the current opcode.
2913 let pc_of_current_op = self.current_call_frame.pc;
2914 let opcode = self.current_call_frame.next_opcode();
2915 self.advance_pc();
2916
2917 // EIP-8141 mempool validation-trace observer (single branch on the
2918 // fast path when inactive). Enforces the banned-opcode set and the
2919 // sequential `GAS`-before-`*CALL` rule before the handler runs.
2920 if VALIDATING {
2921 self.check_validation_banned_opcode(opcode);
2922 }
2923
2924 // Struct-log pre-step capture (compiled out entirely when !TRACED).
2925 let gas_before_op = if TRACED {
2926 self.trace_pre_step(opcode, pc_of_current_op)
2927 } else {
2928 0
2929 };
2930
2931 #[cfg(feature = "perf_opcode_timings")]
2932 let opcode_time_start = std::time::Instant::now();
2933
2934 #[allow(clippy::indexing_slicing, clippy::as_conversions)]
2935 let op_result = opcode_table[opcode as usize].call(self, &mut error);
2936
2937 #[cfg(feature = "perf_opcode_timings")]
2938 {
2939 let time = opcode_time_start.elapsed();
2940 timings.update(opcode, time);
2941 }
2942
2943 // Struct-log post-step (compiled out entirely when !TRACED).
2944 if TRACED {
2945 self.trace_post_step(gas_before_op, &error);
2946 }
2947
2948 let result = match op_result {
2949 OpcodeResult::Continue => continue,
2950 OpcodeResult::Halt => match error.take() {
2951 None => self.handle_opcode_result()?,
2952 Some(error) => self.handle_opcode_error(error)?,
2953 },
2954 };
2955
2956 // Return the ExecutionReport if the executed callframe was the first one.
2957 if self.is_initial_call_frame() {
2958 // Consume the backup (move it out) unless a `BackupHook` will read it afterward
2959 // to build the tx-level undo snapshot (L2 / stateless). On L1 nothing reads it
2960 // once the cache is restored, so cloning it would be dead work.
2961 self.handle_state_backup(&result, !self.preserve_top_level_backup)?;
2962 return Ok(result);
2963 }
2964
2965 // Handle interaction between child and parent callframe.
2966 self.handle_return(&result)?;
2967 }
2968 }
2969
2970 /// EIP-8141 validation-trace banned-opcode check (mempool simulation only).
2971 ///
2972 /// Called once per dispatch-loop iteration, AFTER the opcode is fetched and
2973 /// BEFORE the handler runs, gated by `self.validation_observer.active`. Byte
2974 /// values are pinned against `opcodes.rs`.
2975 ///
2976 /// Static bans: `ORIGIN`, `GASPRICE`, `BLOCKHASH`, `COINBASE`, `TIMESTAMP`
2977 /// (except when the current frame's target is EXPIRY_VERIFIER), `NUMBER`,
2978 /// `PREVRANDAO`, `GASLIMIT`, `BASEFEE`, `BLOBHASH`, `BLOBBASEFEE`, `INVALID`,
2979 /// `SELFDESTRUCT`, `BALANCE`, `SELFBALANCE`, `TLOAD`, `TSTORE`, and `CALLCODE`
2980 /// in non-deploy prefix frames (ERC-7562 bans CALLCODE in validation;
2981 /// DELEGATECALL is allowed subject to the CALL-family trace rules in the
2982 /// handlers). `SSTORE`/`CREATE`/`CREATE2` are allowed only inside the deploy
2983 /// frame and are enforced in their handlers (state-write rules), not here.
2984 ///
2985 /// Sequential `GAS` rule: `GAS` is allowed only immediately before a
2986 /// `*CALL` (`CALL`/`CALLCODE`/`DELEGATECALL`/`STATICCALL`). We detect this by
2987 /// remembering `last_opcode`: if the previous iteration was `GAS` and this
2988 /// opcode is NOT a `*CALL`, the prior `GAS` was illegal.
2989 pub fn check_validation_banned_opcode(&mut self, opcode: u8) {
2990 use crate::validation_observer::FrameSimViolation;
2991
2992 // Opcode bytes, pinned against `opcodes.rs`. The literal values are
2993 // asserted equal to the `Opcode` enum discriminants by
2994 // `validation_observer_opcode_byte_pins` below (avoids a `const`-context
2995 // `as` cast, which the workspace clippy config denies).
2996 const ORIGIN: u8 = 0x32;
2997 const GASPRICE: u8 = 0x3A;
2998 const BLOCKHASH: u8 = 0x40;
2999 const COINBASE: u8 = 0x41;
3000 const TIMESTAMP: u8 = 0x42;
3001 const NUMBER: u8 = 0x43;
3002 const PREVRANDAO: u8 = 0x44;
3003 const GASLIMIT: u8 = 0x45;
3004 const BASEFEE: u8 = 0x48;
3005 const BLOBHASH: u8 = 0x49;
3006 const BLOBBASEFEE: u8 = 0x4A;
3007 const INVALID: u8 = 0xFE;
3008 const SELFDESTRUCT: u8 = 0xFF;
3009 const BALANCE: u8 = 0x31;
3010 const SELFBALANCE: u8 = 0x47;
3011 const TLOAD: u8 = 0x5C;
3012 const TSTORE: u8 = 0x5D;
3013 const GAS: u8 = 0x5A;
3014 const CALL: u8 = 0xF1;
3015 const CALLCODE: u8 = 0xF2;
3016 const DELEGATECALL: u8 = 0xF4;
3017 const STATICCALL: u8 = 0xFA;
3018
3019 let is_call_family = matches!(opcode, CALL | CALLCODE | DELEGATECALL | STATICCALL);
3020
3021 // Sequential GAS rule: a `GAS` on the previous iteration is only legal if
3022 // THIS opcode is a `*CALL`. Evaluate before updating `last_opcode`.
3023 if self.validation_observer.last_opcode == GAS && !is_call_family {
3024 self.validation_observer
3025 .record_violation(FrameSimViolation::BannedOpcode(GAS));
3026 }
3027
3028 // Carry `GAS` forward for the next iteration's check; reset otherwise.
3029 self.validation_observer.last_opcode = if opcode == GAS { GAS } else { 0 };
3030
3031 let banned = match opcode {
3032 ORIGIN | GASPRICE | BLOCKHASH | COINBASE | NUMBER | PREVRANDAO | GASLIMIT | BASEFEE
3033 | BLOBHASH | BLOBBASEFEE | INVALID | SELFDESTRUCT | BALANCE | SELFBALANCE | TLOAD
3034 | TSTORE => true,
3035 // TIMESTAMP is permitted only when the currently executing contract
3036 // IS the EXPIRY_VERIFIER predeploy (checked by code_address so the
3037 // rule tracks the executing contract at every call depth, not just the
3038 // top-level frame target). A nested call FROM an expiry frame INTO
3039 // another contract is correctly banned; a nested call INTO the
3040 // predeploy from any frame is correctly allowed.
3041 TIMESTAMP => {
3042 self.current_call_frame.code_address != self.validation_observer.expiry_verifier
3043 }
3044 // CALLCODE is banned in non-deploy prefix frames (ERC-7562).
3045 CALLCODE => !self.validation_observer.in_deploy_frame(),
3046 _ => false,
3047 };
3048
3049 if banned {
3050 self.validation_observer
3051 .record_violation(FrameSimViolation::BannedOpcode(opcode));
3052 }
3053 }
3054
3055 /// EIP-8141 validation-trace `SLOAD` check (mempool simulation only).
3056 ///
3057 /// `SLOAD` is allowed only when the storage owner (`address`, the executing
3058 /// frame's `to`) is the transaction sender. Records the touched slot for the
3059 /// admission-time revalidation affected-set.
3060 pub fn validation_check_sload(&mut self, address: Address, slot: H256) {
3061 use crate::validation_observer::FrameSimViolation;
3062 if self.validation_observer.in_canonical_pay_frame() {
3063 return;
3064 }
3065 if address == self.validation_observer.sender {
3066 self.validation_observer.touched_sender_slots.push(slot);
3067 } else {
3068 self.validation_observer
3069 .record_violation(FrameSimViolation::StorageReadNonSender);
3070 }
3071 }
3072
3073 /// EIP-8141 validation-trace `SSTORE` check (mempool simulation only).
3074 ///
3075 /// `SSTORE` is allowed only inside the deploy frame AND only when the storage
3076 /// owner (`address`, the executing frame's `to`) is the transaction sender.
3077 pub fn validation_check_sstore(&mut self, address: Address, slot: H256) {
3078 use crate::validation_observer::FrameSimViolation;
3079 if self.validation_observer.in_canonical_pay_frame() {
3080 return;
3081 }
3082 if self.validation_observer.in_deploy_frame() && address == self.validation_observer.sender
3083 {
3084 self.validation_observer.touched_sender_slots.push(slot);
3085 } else {
3086 self.validation_observer
3087 .record_violation(FrameSimViolation::StateWriteOutsideDeploy);
3088 }
3089 }
3090
3091 /// EIP-8141 validation-trace state-creation check for `CREATE`/`CREATE2`
3092 /// (mempool simulation only). Contract creation is a state write permitted
3093 /// only inside the deploy frame.
3094 pub fn validation_check_create(&mut self) {
3095 use crate::validation_observer::FrameSimViolation;
3096 if self.validation_observer.in_canonical_pay_frame() {
3097 return;
3098 }
3099 if !self.validation_observer.in_deploy_frame() {
3100 self.validation_observer
3101 .record_violation(FrameSimViolation::StateWriteOutsideDeploy);
3102 }
3103 }
3104
3105 /// EIP-8141 validation-trace `CALL*`/`EXTCODE*` target check (mempool
3106 /// simulation only).
3107 ///
3108 /// The target must be an existing account or a precompile and must NOT be
3109 /// EIP-7702-delegated, except the sender running its own default code (the
3110 /// sender is exempt — its existence is a transaction precondition and it may
3111 /// have no code). `is_delegation_7702` is the flag already computed by
3112 /// `eip7702_get_code` in the CALL-family handlers, threaded in to avoid a
3113 /// second delegation resolution (and the `&mut VM` / `&mut db` borrow
3114 /// conflict a dispatch-loop stack-peek would create).
3115 pub fn validation_check_call_target(
3116 &mut self,
3117 target: Address,
3118 is_delegation_7702: bool,
3119 ) -> Result<(), VMError> {
3120 use crate::validation_observer::FrameSimViolation;
3121 if self.validation_observer.in_canonical_pay_frame() {
3122 return Ok(());
3123 }
3124 // The sender is always a legitimate target (its existence is a tx
3125 // precondition; it may legitimately have no code).
3126 if target == self.validation_observer.sender {
3127 return Ok(());
3128 }
3129 // A delegated target is disallowed in validation.
3130 if is_delegation_7702 {
3131 self.validation_observer
3132 .record_violation(FrameSimViolation::CallToNonexistentOrDelegated(target));
3133 return Ok(());
3134 }
3135 // Precompiles are always valid targets.
3136 if precompiles::is_precompile(&target, self.env.config.fork, self.vm_type) {
3137 return Ok(());
3138 }
3139 // Otherwise the target must be an existing (non-empty) account.
3140 if self.db.get_account(target)?.is_empty() {
3141 self.validation_observer
3142 .record_violation(FrameSimViolation::CallToNonexistentOrDelegated(target));
3143 }
3144 Ok(())
3145 }
3146
3147 /// EIP-8141 validation-trace `EXTCODE*` target check (mempool simulation
3148 /// only). Like [`VM::validation_check_call_target`], but resolves the
3149 /// EIP-7702 delegation flag itself (the EXTCODE handlers do not call
3150 /// `eip7702_get_code`). The `substate.add_accessed_address` warming the
3151 /// EXTCODE gas already performed has happened; resolving here only follows a
3152 /// delegation indicator to read its flag, mirroring the CALL-family path.
3153 pub fn validation_check_extcode_target(&mut self, target: Address) -> Result<(), VMError> {
3154 if self.validation_observer.in_canonical_pay_frame()
3155 || target == self.validation_observer.sender
3156 {
3157 return Ok(());
3158 }
3159 let (is_delegation_7702, _access_cost, _code_address, _bytecode) =
3160 crate::utils::eip7702_get_code(
3161 self.db,
3162 &mut self.substate,
3163 target,
3164 self.env.config.fork,
3165 )?;
3166 self.validation_check_call_target(target, is_delegation_7702)
3167 }
3168
3169 /// Struct-log pre-step capture, split out of the interpreter loop and kept
3170 /// cold + non-inlined so the hot dispatch loop stays small (this code is
3171 /// only reached when a struct-log tracer is active). Returns `gas_before`.
3172 #[cold]
3173 #[inline(never)]
3174 fn trace_pre_step(&mut self, opcode: u8, pc_of_current_op: usize) -> u64 {
3175 #[expect(
3176 clippy::as_conversions,
3177 reason = "gas_remaining is i64; clamp to 0 before converting to u64"
3178 )]
3179 let gas_before = self.current_call_frame.gas_remaining.max(0) as u64;
3180 #[expect(
3181 clippy::as_conversions,
3182 reason = "call depth bounded by STACK_LIMIT=1024, fits in u32"
3183 )]
3184 let depth = (self.call_frames.len() as u32).saturating_add(1);
3185 let refund = self.substate.refunded_gas;
3186 let stack_view = self.collect_stack_for_trace();
3187 let mem_view = self.collect_memory_for_trace();
3188 // mem_size always reflects actual memory size, regardless of enable_memory.
3189 #[expect(
3190 clippy::as_conversions,
3191 reason = "memory size is bounded by gas; fits in u64"
3192 )]
3193 let mem_size_for_trace = self.current_call_frame.memory.len() as u64;
3194 let storage_kv = self.read_storage_for_trace(opcode);
3195 let return_data = if self.opcode_tracer.cfg.enable_return_data {
3196 self.current_call_frame.sub_return_data.clone()
3197 } else {
3198 Bytes::new()
3199 };
3200 #[expect(
3201 clippy::as_conversions,
3202 reason = "pc is usize, fits in u64 on supported targets"
3203 )]
3204 let pc_u64 = pc_of_current_op as u64;
3205 self.opcode_tracer.pre_step_capture(
3206 pc_u64,
3207 opcode,
3208 gas_before,
3209 depth,
3210 refund,
3211 &stack_view,
3212 &mem_view,
3213 mem_size_for_trace,
3214 &return_data,
3215 storage_kv,
3216 );
3217 gas_before
3218 }
3219
3220 /// Struct-log post-step: patch gas_cost, refund-after-op, and error into the
3221 /// buffered entry. Cold + non-inlined for the same reason as `trace_pre_step`.
3222 #[cold]
3223 #[inline(never)]
3224 fn trace_post_step(&mut self, gas_before_op: u64, error: &OnceCell<VMError>) {
3225 #[expect(
3226 clippy::as_conversions,
3227 reason = "gas_remaining is i64; clamp to 0 before converting to u64"
3228 )]
3229 let gas_after = self.current_call_frame.gas_remaining.max(0) as u64;
3230 // Prefer the explicit opcode-overhead cost written by CALL/CREATE handlers;
3231 // fall back to the gas diff for all other opcodes.
3232 let gas_cost = self
3233 .opcode_tracer
3234 .last_opcode_gas_cost
3235 .take()
3236 .unwrap_or_else(|| gas_before_op.saturating_sub(gas_after));
3237 // refund-after-op matches geth's structLogger timing: for SSTORE and
3238 // (pre-London) SELFDESTRUCT, the refund counter shown is the value
3239 // *after* the opcode's accounting applied.
3240 let refund_after = self.substate.refunded_gas;
3241 let err_str = error.get().map(|e| e.to_string());
3242 self.opcode_tracer
3243 .finalize_step(gas_cost, refund_after, err_str.as_deref());
3244 }
3245
3246 /// Executes the precompile at `code_address` (a relocation destination resolves to the
3247 /// precompile it hosts) and handles the output that it returns, generating a report.
3248 /// The precompile reads `calldata` in place, even when it is a range of caller memory.
3249 pub fn execute_precompile(
3250 &self,
3251 code_address: Address,
3252 calldata: &CallData,
3253 gas_limit: u64,
3254 gas_remaining: &mut u64,
3255 ) -> Result<ContextResult, VMError> {
3256 let precompile_address = self.effective_precompile_address(code_address);
3257 let output = calldata.with_slice(|input| {
3258 precompiles::execute_precompile(
3259 precompile_address,
3260 input,
3261 gas_remaining,
3262 self.env.config.fork,
3263 self.db.store.precompile_cache(),
3264 self.crypto,
3265 self.stateless_validator,
3266 )
3267 })?;
3268 Self::handle_precompile_result(output, gas_limit, *gas_remaining)
3269 }
3270
3271 /// True if external transaction is a contract creation
3272 pub fn is_create(&self) -> Result<bool, InternalError> {
3273 Ok(self.current_call_frame.is_create)
3274 }
3275
3276 /// Executes without making changes to the cache.
3277 pub fn stateless_execute(&mut self) -> Result<ExecutionReport, VMError> {
3278 // Add backup hook to restore state after execution. `add_hook` flips
3279 // `preserve_top_level_backup` on via `Hook::reads_top_level_backup`, so the backup is
3280 // cloned (not moved out) on the revert paths even though this VM was built with L1 `vm_type`.
3281 self.add_hook(BackupHook::default());
3282 let report = self.execute()?;
3283 // Restore cache to the state before execution.
3284 self.db.undo_last_transaction()?;
3285 Ok(report)
3286 }
3287
3288 fn prepare_execution(&mut self) -> Result<(), VMError> {
3289 // Clone each hook's `Rc` (cheap refcount bump) so the borrow on `self.hooks` is released
3290 // and `self` can be passed mutably — without `self.hooks.clone()`'s per-tx `Vec` realloc.
3291 // `self.hooks` is not mutated during the loop, so `get(i)` is always `Some` in range.
3292 for i in 0..self.hooks.len() {
3293 if let Some(hook) = self.hooks.get(i).map(Rc::clone) {
3294 hook.borrow_mut().prepare_execution(self)?;
3295 }
3296 }
3297
3298 Ok(())
3299 }
3300
3301 fn finalize_execution(
3302 &mut self,
3303 mut ctx_result: ContextResult,
3304 ) -> Result<ExecutionReport, VMError> {
3305 // EIP-8037: On top-level tx failure (REVERT, ExceptionalHalt, or OOG), the
3306 // execution portion of state gas has already been refilled into the reservoir by
3307 // the top-frame `refill_frame_state_gas` (seeded at the post-intrinsic baseline in
3308 // `add_intrinsic_gas` and fired on revert/halt in `handle_opcode_error` /
3309 // `handle_opcode_result`). The intrinsic portion stays in `state_gas_used` so block
3310 // accounting bills it. No reservoir-move is performed here. A create-tx collision
3311 // rolls its own in-region `NEW_ACCOUNT` charge (if any) back to the frame's entry
3312 // baseline inside `handle_create_transaction`'s collision branch, so there is
3313 // nothing left to refill here either (see that function).
3314 //
3315 // EIP-8037: there is no longer a separate create-failure `NEW_ACCOUNT`
3316 // refund here. The charge itself (`prepare_execution`) is now
3317 // conditioned on `get_pre_state_account(created_addr) == EMPTY_ACCOUNT`, so it
3318 // simply never fires for an already-alive target (positive balance implies
3319 // non-empty pre-state) and is rolled back by `handle_create_transaction` on
3320 // collision — matching EELS v7, which drops the created-target-alive output flag
3321 // entirely because `prepare_dispatch` never runs for a colliding create.
3322
3323 // See `prepare_execution`: per-hook `Rc::clone` avoids the `self.hooks.clone()` realloc.
3324 for i in 0..self.hooks.len() {
3325 if let Some(hook) = self.hooks.get(i).map(Rc::clone) {
3326 hook.borrow_mut()
3327 .finalize_execution(self, &mut ctx_result)?;
3328 }
3329 }
3330
3331 self.tracer.exit_context(&ctx_result, true)?;
3332
3333 // Struct-log end-of-tx capture: record final output, gas used, and revert error.
3334 // gas matches geth's `executionResult.Gas` which is post-refund (`receipt.GasUsed`).
3335 if self.opcode_tracer.active {
3336 self.opcode_tracer.output = ctx_result.output.clone();
3337 self.opcode_tracer.gas_used = ctx_result.gas_spent;
3338 self.opcode_tracer.error = match ctx_result.result {
3339 TxResult::Revert(ref err) => Some(err.to_string()),
3340 _ => None,
3341 };
3342 }
3343
3344 // Only include logs if transaction succeeded. When a transaction reverts,
3345 // no logs should be emitted (including EIP-7708 Transfer logs).
3346 let logs = if ctx_result.is_success() {
3347 self.substate.extract_logs()
3348 } else {
3349 Vec::new()
3350 };
3351
3352 // EIP-8037: `state_gas_used` is already net (signed; credits decrement it
3353 // inline), so `tx_state_gas = intrinsic_state_gas + state_gas_used` exactly
3354 // (EELS `fork.py::process_transaction`, which no longer carries a separate
3355 // tx-level refund channel). Clamp at zero — `state_gas_used` may be negative when
3356 // inline refunds exceed gross charges.
3357 let net_state_gas_used: u64 =
3358 u64::try_from(self.state_gas_used.max(0)).map_err(|_| InternalError::Overflow)?;
3359
3360 let report = ExecutionReport {
3361 result: ctx_result.result.clone(),
3362 gas_used: ctx_result.gas_used,
3363 gas_spent: ctx_result.gas_spent,
3364 gas_refunded: self.substate.refunded_gas,
3365 state_gas_used: net_state_gas_used,
3366 output: std::mem::take(&mut ctx_result.output),
3367 logs,
3368 payer_address: None,
3369 frame_results: None,
3370 };
3371
3372 Ok(report)
3373 }
3374
3375 // ── Struct-log helper methods ─────────────────────────────────────────────
3376
3377 /// Collects the current stack in bottom-first order for struct-log emission.
3378 ///
3379 /// LEVM stack is top-first in memory (`values[offset]` = top), so we reverse
3380 /// the active slice to produce the bottom-first wire format geth uses.
3381 /// Returns an empty `Vec` when `cfg.disable_stack` is true.
3382 pub fn collect_stack_for_trace(&self) -> Vec<U256> {
3383 use crate::constants::STACK_LIMIT;
3384 if self.opcode_tracer.cfg.disable_stack {
3385 return Vec::new();
3386 }
3387 let s = &self.current_call_frame.stack;
3388 // offset <= STACK_LIMIT by stack invariant.
3389 s.values
3390 .get(s.offset..STACK_LIMIT)
3391 .map(|slice| slice.iter().rev().copied().collect())
3392 .unwrap_or_default()
3393 }
3394
3395 /// Collects the live memory bytes for the current frame.
3396 ///
3397 /// Returns an empty `Vec` when `cfg.enable_memory` is false or memory is empty.
3398 pub fn collect_memory_for_trace(&self) -> Vec<u8> {
3399 if !self.opcode_tracer.cfg.enable_memory {
3400 return Vec::new();
3401 }
3402 self.current_call_frame.memory.live_bytes()
3403 }
3404
3405 /// Pre-reads the storage key/value for the current SLOAD or SSTORE opcode.
3406 ///
3407 /// Returns `None` when:
3408 /// - `cfg.disable_storage` is set, or
3409 /// - `opcode` is not SLOAD (0x54) or SSTORE (0x55), or
3410 /// - the stack is empty (guard against underflow before the handler runs), or
3411 /// - the storage read fails for any reason (including `AccountNotFound` —
3412 /// the trace omits the entry rather than emitting an ambiguous zero).
3413 ///
3414 /// For SLOAD: key = `stack.top`; value = the *current* stored value read from the DB.
3415 /// For SSTORE: key = `stack.top`, value = `stack[top-1]` (the new value being written).
3416 pub fn read_storage_for_trace(&mut self, opcode: u8) -> Option<(H256, H256)> {
3417 const SLOAD: u8 = 0x54;
3418 const SSTORE: u8 = 0x55;
3419
3420 if self.opcode_tracer.cfg.disable_storage {
3421 return None;
3422 }
3423 if opcode != SLOAD && opcode != SSTORE {
3424 return None;
3425 }
3426
3427 // Need at least one element on stack for SLOAD, two for SSTORE.
3428 use crate::constants::STACK_LIMIT;
3429 let offset = self.current_call_frame.stack.offset;
3430 if offset >= STACK_LIMIT {
3431 return None; // stack empty
3432 }
3433
3434 // SLOAD/SSTORE operate on the call's storage context (`to`), not the code's
3435 // address. Under DELEGATECALL/CALLCODE these differ.
3436 let addr = self.current_call_frame.to;
3437
3438 let stack_values = &self.current_call_frame.stack.values;
3439 let key_u256 = *stack_values.get(offset)?;
3440 let key = BigEndianHash::from_uint(&key_u256);
3441
3442 if opcode == SLOAD {
3443 // Omit the entry on any read failure (incl. account not yet cached);
3444 // a zero value would be indistinguishable from a legitimate never-written slot.
3445 let v = self.get_storage_value(addr, key).ok()?;
3446 let value = BigEndianHash::from_uint(&v);
3447 Some((key, value))
3448 } else {
3449 // SSTORE: need two stack elements.
3450 let next_offset = offset.checked_add(1)?;
3451 if next_offset >= STACK_LIMIT {
3452 return None;
3453 }
3454 // values[offset+1] is the new value being written (second from top = stack[top-1]).
3455 let value_u256 = *self.current_call_frame.stack.values.get(next_offset)?;
3456 let value = BigEndianHash::from_uint(&value_u256);
3457 Some((key, value))
3458 }
3459 }
3460}
3461
3462impl Substate {
3463 /// Initializes the VM substate, mainly adding addresses to the "accessed_addresses" field and the same with storage slots
3464 pub fn initialize(env: &Environment, tx: &Transaction) -> Result<Substate, VMError> {
3465 let fork = env.config.fork;
3466
3467 // Add sender and recipient to accessed accounts [https://www.evm.codes/about#access_list]
3468 // Precompiles are NO LONGER inserted here — they are warm by construction (see
3469 // `is_warm_precompile`), removing the ~20-entry floor that used to dominate this set. The
3470 // remaining working set is small (sender + coinbase + recipient + access-list/touched
3471 // addresses; real p99 ~7), so a capacity of 8 covers most txs with little waste.
3472 let mut initial_accessed_addresses =
3473 AccessedAddresses::with_capacity_and_hasher(8, Default::default());
3474 // Storage slots are ~98% empty (p95 0, p99 4), so `default()` (alloc-free until first
3475 // insert) beats pre-sizing, which would tax the common empty case.
3476 let mut initial_accessed_storage_slots = AccessedStorageSlots::default();
3477
3478 // Add Tx sender to accessed accounts
3479 initial_accessed_addresses.insert(env.origin);
3480
3481 // [EIP-3651] - Add coinbase to accessed accounts after Shanghai
3482 if fork >= Fork::Shanghai {
3483 initial_accessed_addresses.insert(env.coinbase);
3484 }
3485
3486 // Add access lists contents to accessed accounts and accessed storage slots.
3487 // Iterate by reference (`Address`/`H256` are `Copy`); the old `.clone()` deep-copied
3488 // the whole `Vec<(Address, Vec<H256>)>` per tx just to read it.
3489 for (address, keys) in tx.access_list() {
3490 initial_accessed_addresses.insert(*address);
3491 // Access lists can have different entries even for the same address, that's why we check if there's an existing set instead of considering it empty
3492 let warm_slots = initial_accessed_storage_slots.entry(*address).or_default();
3493 for slot in keys {
3494 warm_slots.insert(*slot);
3495 }
3496 }
3497
3498 let substate = Substate::from_accesses(
3499 fork,
3500 initial_accessed_addresses,
3501 initial_accessed_storage_slots,
3502 );
3503
3504 Ok(substate)
3505 }
3506}
3507
3508// Test-support surface for the EIP-8037 state-gas reservoir/clamp-spill unit tests, which live
3509// in the `ethrex-test` crate (`test/tests/levm/eip8037_reservoir_tests.rs`) per the repo's
3510// test-location convention but must drive crate-private VM internals. Everything here is
3511// `#[doc(hidden)]` and exposes only what those tests touch: a fixture-free VM harness plus a
3512// handful of reservoir accessors. The harness builds the VM via struct literal to sidestep
3513// `VM::new`'s DB reads (which would pull `ethrex-storage`/`ethrex-blockchain` into levm and form
3514// a dependency cycle), keeping the two-pool arithmetic isolated.
3515#[doc(hidden)]
3516impl<'a> VM<'a> {
3517 /// Gas budget seeded into the harness top frame; large enough that spills never run it OOG.
3518 pub const STATE_GAS_HARNESS_FRAME_GAS: u64 = 1_000_000;
3519
3520 /// Builds a fixture-free VM on `fork` with a single top frame and the given starting
3521 /// `state_gas_reservoir`. `db`/`tx`/`crypto` are borrowed for the VM's lifetime but never
3522 /// read (the frame is built directly, so no account/storage/code loads occur).
3523 pub fn new_state_gas_harness(
3524 fork: Fork,
3525 db: &'a mut GeneralizedDatabase,
3526 tx: &'a Transaction,
3527 crypto: &'a dyn Crypto,
3528 state_gas_reservoir: u64,
3529 ) -> VM<'a> {
3530 let env = Environment {
3531 config: crate::environment::EVMConfig::new(
3532 fork,
3533 crate::environment::EVMConfig::canonical_values(fork),
3534 ),
3535 gas_limit: Self::STATE_GAS_HARNESS_FRAME_GAS,
3536 block_gas_limit: Self::STATE_GAS_HARNESS_FRAME_GAS,
3537 ..Default::default()
3538 };
3539 let current_call_frame = CallFrame::new(
3540 Address::default(),
3541 Address::default(),
3542 Address::default(),
3543 Code::default(),
3544 U256::zero(),
3545 CallData::default(),
3546 false,
3547 Self::STATE_GAS_HARNESS_FRAME_GAS,
3548 0,
3549 true,
3550 false,
3551 0,
3552 0,
3553 Stack::default(),
3554 Memory::default(),
3555 );
3556 VM {
3557 call_frames: Vec::new(),
3558 current_call_frame,
3559 env,
3560 substate: Substate::default(),
3561 db,
3562 tx,
3563 hooks: Vec::new(),
3564 storage_original_values: Default::default(),
3565 tracer: LevmCallTracer::disabled(),
3566 opcode_tracer: LevmOpcodeTracer::disabled(),
3567 debug_mode: DebugMode::disabled(),
3568 stack_pool: Vec::new(),
3569 initcode_pool: Vec::new(),
3570 vm_type: VMType::L1,
3571 preserve_top_level_backup: false,
3572 state_gas_used: 0,
3573 state_gas_reservoir,
3574 state_gas_reservoir_initial: state_gas_reservoir,
3575 state_gas_spill: 0,
3576 cost_per_state_byte: 0,
3577 state_gas_new_account: 0,
3578 state_gas_storage_set: 0,
3579 state_gas_auth_base: 0,
3580 intrinsic_state_gas: 0,
3581 value_new_account_charged: false,
3582 prep_baseline_state_gas: 0,
3583 prep_baseline_reservoir: 0,
3584 prep_baseline_state_gas_spill: 0,
3585 prep_region_backup_marker: PrepareRegionBackupMarker::default(),
3586 pending_prep_oog: false,
3587 opcode_table: VM::build_opcode_table(fork),
3588 crypto,
3589 stateless_validator: None,
3590 validation_observer: ValidationObserver::disabled(),
3591 frame_tx_context: None,
3592 }
3593 }
3594
3595 pub fn state_gas_reservoir(&self) -> u64 {
3596 self.state_gas_reservoir
3597 }
3598 pub fn state_gas_used(&self) -> i64 {
3599 self.state_gas_used
3600 }
3601 pub fn state_gas_spill(&self) -> u64 {
3602 self.state_gas_spill
3603 }
3604 pub fn state_gas_new_account(&self) -> u64 {
3605 self.state_gas_new_account
3606 }
3607 pub fn set_state_gas_new_account(&mut self, v: u64) {
3608 self.state_gas_new_account = v;
3609 }
3610 /// Seeds the post-intrinsic baseline (mirrors `add_intrinsic_gas`): both the VM-level
3611 /// `state_gas_used` and the top frame's entry snapshot.
3612 pub fn seed_state_gas_baseline(&mut self, used: i64) {
3613 self.state_gas_used = used;
3614 self.current_call_frame.state_gas_used_at_entry = used;
3615 }
3616 pub fn frame_state_gas_used_at_entry(&self) -> i64 {
3617 self.current_call_frame.state_gas_used_at_entry
3618 }
3619 pub fn frame_gas_remaining(&self) -> i64 {
3620 self.current_call_frame.gas_remaining
3621 }
3622 pub fn set_frame_gas_remaining(&mut self, v: i64) {
3623 self.current_call_frame.gas_remaining = v;
3624 }
3625 pub fn frame_state_gas_spilled(&self) -> u64 {
3626 self.current_call_frame.frame_state_gas_spilled
3627 }
3628}