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 ¤t.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}