Skip to main content

ethrex_levm/
vm.rs

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