Skip to main content

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 &current.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}