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};
27use bytes::Bytes;
28use ethrex_common::{
29 Address, BigEndianHash, H160, H256, U256,
30 tracing::CallType,
31 types::{AccessListEntry, Code, Fork, Log, Transaction, fee_config::FeeConfig},
32};
33use ethrex_crypto::Crypto;
34use rustc_hash::{FxHashMap, FxHashSet};
35use std::{
36 cell::{OnceCell, RefCell},
37 collections::{BTreeMap, BTreeSet},
38 mem,
39 rc::Rc,
40};
41
42/// Storage mapping from slot key to value.
43pub type Storage = FxHashMap<U256, H256>;
44
45/// Specifies whether the VM operates in L1 or L2 mode.
46#[derive(Debug, Clone, Copy, Default)]
47pub enum VMType {
48 /// Standard Ethereum L1 execution.
49 #[default]
50 L1,
51 /// L2 rollup execution with additional fee handling.
52 L2(FeeConfig),
53}
54
55/// Execution substate that tracks changes during transaction execution.
56///
57/// The substate maintains all information that may need to be reverted if a
58/// call fails, including:
59/// - Self-destructed accounts
60/// - Accessed addresses and storage slots (for EIP-2929 gas accounting)
61/// - Created accounts
62/// - Gas refunds
63/// - Transient storage (EIP-1153)
64/// - Event logs
65///
66/// # Backup Mechanism
67///
68/// The substate supports checkpointing via [`push_backup`] and restoration via
69/// [`revert_backup`] or commitment via [`commit_backup`]. This is used to handle
70/// nested calls where inner calls may fail and need to be reverted.
71///
72/// Most fields are private by design. The backup mechanism only works correctly
73/// if data modifications are append-only.
74#[derive(Debug, Default)]
75pub struct Substate {
76 /// Parent checkpoint for reverting on failure.
77 parent: Option<Box<Self>>,
78 /// Fork of the enclosing transaction. Lets the warmth helpers treat precompile addresses as
79 /// always-warm without occupying a hashset slot (EIP-2929). Constant for a tx, so it is
80 /// carried forward across `push_backup` checkpoints.
81 fork: Fork,
82 /// Accounts marked for self-destruction (deleted at end of transaction).
83 selfdestruct_set: FxHashSet<Address>,
84 /// Addresses accessed during execution (for EIP-2929 warm/cold gas costs).
85 /// Precompiles are NOT stored here; they are warm by construction (see `is_warm_precompile`).
86 accessed_addresses: FxHashSet<Address>,
87 /// Storage slots accessed per address (for EIP-2929 warm/cold gas costs).
88 accessed_storage_slots: FxHashMap<Address, FxHashSet<H256>>,
89 /// Accounts created during this transaction.
90 created_accounts: FxHashSet<Address>,
91 /// Accumulated gas refund (e.g., from storage clears).
92 pub refunded_gas: u64,
93 /// Transient storage (EIP-1153), cleared at end of transaction.
94 transient_storage: TransientStorage,
95 /// Event logs emitted during execution.
96 logs: Vec<Log>,
97}
98
99impl Substate {
100 pub fn from_accesses(
101 fork: Fork,
102 accessed_addresses: FxHashSet<Address>,
103 accessed_storage_slots: FxHashMap<Address, FxHashSet<H256>>,
104 ) -> Self {
105 Self {
106 parent: None,
107 fork,
108 selfdestruct_set: FxHashSet::default(),
109 accessed_addresses,
110 accessed_storage_slots,
111 created_accounts: FxHashSet::default(),
112 refunded_gas: 0,
113 transient_storage: TransientStorage::default(),
114 logs: Vec::new(),
115 }
116 }
117
118 /// Whether `address` is a precompile that the EVM treats as warm from the start of the tx
119 /// (EIP-2929), exactly matching the addresses `Substate::initialize` used to pre-seed.
120 ///
121 /// Replicates the pre-seed *precisely* — the contiguous range `0x01..=max_for_fork` plus the
122 /// post-Osaka P256VERIFY address `0x100` — and is intentionally `vm_type`-independent, since
123 /// the old pre-seed was too. (Using `precompiles::is_precompile`, which gates `0x100` on L2
124 /// for any fork, would change L2 pre-Osaka warmth — a consensus difference, not an opt.)
125 #[inline]
126 fn is_warm_precompile(&self, address: &Address) -> bool {
127 // Fast reject: every pre-seeded precompile has 18 leading zero bytes (max is `0x01_00`),
128 // so real contract/EOA addresses bail out here, off the hot warmth path.
129 if address.0[..18] != [0u8; 18] {
130 return false;
131 }
132 let n = u16::from_be_bytes([address.0[18], address.0[19]]);
133 let max_contiguous: u64 = match self.fork {
134 f if f >= Fork::Prague => SIZE_PRECOMPILES_PRAGUE,
135 f if f >= Fork::Cancun => SIZE_PRECOMPILES_CANCUN,
136 _ => SIZE_PRECOMPILES_PRE_CANCUN,
137 };
138 (n >= 1 && u64::from(n) <= max_contiguous) || (n == 0x100 && self.fork >= Fork::Osaka)
139 }
140
141 /// Push a checkpoint that can be either reverted or committed. All data up to this point is
142 /// still accessible.
143 pub fn push_backup(&mut self) {
144 let parent = mem::take(self);
145 self.refunded_gas = parent.refunded_gas;
146 // Carry the fork forward so child checkpoints keep the same precompile-warmth view.
147 self.fork = parent.fork;
148 self.parent = Some(Box::new(parent));
149 }
150
151 /// Pop and merge with the last backup.
152 ///
153 /// Does nothing if the substate has no backup.
154 pub fn commit_backup(&mut self) {
155 if let Some(parent) = self.parent.as_mut() {
156 let mut delta = mem::take(parent);
157 mem::swap(self, &mut delta);
158
159 self.selfdestruct_set.extend(delta.selfdestruct_set);
160 self.accessed_addresses.extend(delta.accessed_addresses);
161 for (address, slot_set) in delta.accessed_storage_slots {
162 self.accessed_storage_slots
163 .entry(address)
164 .or_default()
165 .extend(slot_set);
166 }
167 self.created_accounts.extend(delta.created_accounts);
168 self.refunded_gas = delta.refunded_gas;
169 self.transient_storage.extend(delta.transient_storage);
170 self.logs.extend(delta.logs);
171 }
172 }
173
174 /// Discard current changes and revert to last backup.
175 ///
176 /// Does nothing if the substate has no backup.
177 pub fn revert_backup(&mut self) {
178 if let Some(parent) = self.parent.as_mut() {
179 *self = mem::take(parent);
180 }
181 }
182
183 /// Return an iterator over all selfdestruct addresses.
184 pub fn iter_selfdestruct(&self) -> impl Iterator<Item = &Address> {
185 struct Iter<'a> {
186 parent: Option<&'a Substate>,
187 iter: std::collections::hash_set::Iter<'a, Address>,
188 }
189
190 impl<'a> Iterator for Iter<'a> {
191 type Item = &'a Address;
192
193 fn next(&mut self) -> Option<Self::Item> {
194 let next_item = self.iter.next();
195 if next_item.is_none()
196 && let Some(parent) = self.parent
197 {
198 self.parent = parent.parent.as_deref();
199 self.iter = parent.selfdestruct_set.iter();
200
201 return self.next();
202 }
203
204 next_item
205 }
206 }
207
208 Iter {
209 parent: self.parent.as_deref(),
210 iter: self.selfdestruct_set.iter(),
211 }
212 }
213
214 /// Mark an address as selfdestructed and return whether is was already marked.
215 pub fn add_selfdestruct(&mut self, address: Address) -> bool {
216 if self.selfdestruct_set.contains(&address) {
217 return true;
218 }
219
220 let is_present = self
221 .parent
222 .as_ref()
223 .map(|parent| parent.is_selfdestruct(&address))
224 .unwrap_or_default();
225
226 is_present || !self.selfdestruct_set.insert(address)
227 }
228
229 /// Return whether an address is already marked as selfdestructed.
230 pub fn is_selfdestruct(&self, address: &Address) -> bool {
231 self.selfdestruct_set.contains(address)
232 || self
233 .parent
234 .as_ref()
235 .map(|parent| parent.is_selfdestruct(address))
236 .unwrap_or_default()
237 }
238
239 /// Build an access list from all accessed storage slots.
240 pub fn make_access_list(&self) -> Vec<AccessListEntry> {
241 let mut entries = BTreeMap::<Address, BTreeSet<H256>>::new();
242
243 let mut current = self;
244 loop {
245 for (address, slot_set) in ¤t.accessed_storage_slots {
246 entries
247 .entry(*address)
248 .or_default()
249 .extend(slot_set.iter().copied());
250 }
251
252 current = match current.parent.as_deref() {
253 Some(x) => x,
254 None => break,
255 };
256 }
257
258 entries
259 .into_iter()
260 .map(|(address, storage_keys)| AccessListEntry {
261 address,
262 storage_keys: storage_keys.into_iter().collect(),
263 })
264 .collect()
265 }
266
267 /// Mark an address as accessed and return whether the slot was cold.
268 pub fn add_accessed_slot(&mut self, address: Address, key: H256) -> bool {
269 if self
270 .accessed_storage_slots
271 .get(&address)
272 .is_some_and(|set| set.contains(&key))
273 {
274 return false;
275 }
276
277 let is_present = self
278 .parent
279 .as_ref()
280 .map(|parent| parent.is_slot_accessed(&address, &key))
281 .unwrap_or_default();
282
283 // Note: Do not simplify this expression, it uses `||` to avoid executing the right hand
284 // expression if not necessary.
285 #[expect(clippy::nonminimal_bool, reason = "order of evaluation matters")]
286 !(is_present
287 || !self
288 .accessed_storage_slots
289 .entry(address)
290 .or_default()
291 .insert(key))
292 }
293
294 /// Return whether an address has already been accessed.
295 pub fn is_slot_accessed(&self, address: &Address, key: &H256) -> bool {
296 self.accessed_storage_slots
297 .get(address)
298 .map(|slot_set| slot_set.contains(key))
299 .unwrap_or_default()
300 || self
301 .parent
302 .as_ref()
303 .map(|parent| parent.is_slot_accessed(address, key))
304 .unwrap_or_default()
305 }
306
307 /// Returns all accessed storage slots for a given address.
308 /// Used by SELFDESTRUCT to record storage reads in BAL per EIP-7928:
309 /// "SELFDESTRUCT: Include modified/read storage keys as storage_read"
310 pub fn get_accessed_storage_slots(&self, address: &Address) -> BTreeSet<H256> {
311 let mut slots = BTreeSet::new();
312
313 // Collect from current substate
314 if let Some(slot_set) = self.accessed_storage_slots.get(address) {
315 slots.extend(slot_set.iter().copied());
316 }
317
318 // Collect from parent substates recursively
319 if let Some(parent) = self.parent.as_ref() {
320 slots.extend(parent.get_accessed_storage_slots(address));
321 }
322
323 slots
324 }
325
326 /// Mark an address as accessed and return whether the address was cold.
327 pub fn add_accessed_address(&mut self, address: Address) -> bool {
328 // Precompiles are warm from tx start (EIP-2929) without occupying a hashset slot. Returns
329 // `false` (not cold) so cold-access gas is never charged — identical to the old pre-seed.
330 if self.is_warm_precompile(&address) {
331 return false;
332 }
333
334 if self.accessed_addresses.contains(&address) {
335 return false;
336 }
337
338 let is_present = self
339 .parent
340 .as_ref()
341 .map(|parent| parent.is_address_accessed(&address))
342 .unwrap_or_default();
343
344 // Note: Do not simplify this expression, it uses `||` to avoid executing the right hand
345 // expression if not necessary.
346 #[expect(clippy::nonminimal_bool, reason = "order of evaluation matters")]
347 !(is_present || !self.accessed_addresses.insert(address))
348 }
349
350 /// Return whether an address has already been accessed.
351 pub fn is_address_accessed(&self, address: &Address) -> bool {
352 // Precompiles are always warm; the chain shares one `fork`, so this is consistent across
353 // sub-frame substates.
354 self.is_warm_precompile(address)
355 || self.accessed_addresses.contains(address)
356 || self
357 .parent
358 .as_ref()
359 .map(|parent| parent.is_address_accessed(address))
360 .unwrap_or_default()
361 }
362
363 /// Mark an address as a new account and return whether is was already marked.
364 pub fn add_created_account(&mut self, address: Address) -> bool {
365 if self.created_accounts.contains(&address) {
366 return true;
367 }
368
369 let is_present = self
370 .parent
371 .as_ref()
372 .map(|parent| parent.is_account_created(&address))
373 .unwrap_or_default();
374
375 is_present || !self.created_accounts.insert(address)
376 }
377
378 /// Return whether an address has already been marked as a new account.
379 pub fn is_account_created(&self, address: &Address) -> bool {
380 self.created_accounts.contains(address)
381 || self
382 .parent
383 .as_ref()
384 .map(|parent| parent.is_account_created(address))
385 .unwrap_or_default()
386 }
387
388 /// Return the data associated with a transient storage entry, or zero if not present.
389 pub fn get_transient(&self, to: &Address, key: &U256) -> U256 {
390 self.transient_storage
391 .get(&(*to, *key))
392 .copied()
393 .unwrap_or_else(|| {
394 self.parent
395 .as_ref()
396 .map(|parent| parent.get_transient(to, key))
397 .unwrap_or_default()
398 })
399 }
400
401 /// Return the data associated with a transient storage entry, or zero if not present.
402 pub fn set_transient(&mut self, to: &Address, key: &U256, value: U256) {
403 self.transient_storage.insert((*to, *key), value);
404 }
405
406 /// Extract all logs in order.
407 pub fn extract_logs(&self) -> Vec<Log> {
408 fn inner(substrate: &Substate, target: &mut Vec<Log>) {
409 if let Some(parent) = substrate.parent.as_deref() {
410 inner(parent, target);
411 }
412
413 target.extend_from_slice(&substrate.logs);
414 }
415
416 let mut logs = Vec::new();
417 inner(self, &mut logs);
418
419 logs
420 }
421
422 /// Push a log record.
423 pub fn add_log(&mut self, log: Log) {
424 self.logs.push(log);
425 }
426}
427
428/// The LEVM (Lambda EVM) execution engine.
429///
430/// The VM executes Ethereum transactions by processing EVM bytecode. It maintains
431/// a call stack, memory, and tracks all state changes during execution.
432///
433/// # Execution Model
434///
435/// 1. Transaction is validated (nonce, balance, gas limit)
436/// 2. Initial call frame is created with transaction data
437/// 3. Opcodes are executed sequentially until completion or error
438/// 4. State changes are committed or reverted based on success
439///
440/// # Call Stack
441///
442/// Nested calls (CALL, DELEGATECALL, etc.) push new frames onto `call_frames`.
443/// Each frame has its own memory, stack, and execution context. The `current_call_frame`
444/// is always the active frame being executed.
445///
446/// # Hooks
447///
448/// The VM supports hooks for extending functionality (e.g., tracing, debugging).
449/// Hooks are called at various points during execution and implement pre/post-execution
450/// logic. L2-specific behavior (such as fee handling) is implemented via hooks.
451///
452/// # Example
453///
454/// ```ignore
455/// let mut vm = VM::new(env, db, &tx, tracer, vm_type, &NativeCrypto);
456/// let report = vm.execute()?;
457/// if report.is_success() {
458/// println!("Gas used: {}, Output: {:?}", report.gas_used, report.output);
459/// } else {
460/// println!("Transaction reverted");
461/// }
462/// ```
463pub struct VM<'a> {
464 /// Stack of parent call frames (for nested calls).
465 pub call_frames: Vec<CallFrame>,
466 /// The currently executing call frame.
467 pub current_call_frame: CallFrame,
468 /// Block and transaction environment.
469 pub env: Environment,
470 /// Execution substate (accessed addresses, logs, refunds, etc.).
471 pub substate: Substate,
472 /// Database for reading/writing account state.
473 pub db: &'a mut GeneralizedDatabase,
474 /// The transaction being executed. Borrowed for the VM's lifetime (the caller owns it for at
475 /// least that long), avoiding a per-tx deep clone of the access/authorization lists.
476 pub tx: &'a Transaction,
477 /// Execution hooks for tracing and debugging.
478 pub hooks: Vec<Rc<RefCell<dyn Hook>>>,
479 /// Original storage values before transaction (for SSTORE gas calculation),
480 /// keyed first by account to avoid hashing the full tuple on each access.
481 pub storage_original_values: FxHashMap<Address, FxHashMap<H256, U256>>,
482 /// Call tracer for execution tracing.
483 pub tracer: LevmCallTracer,
484 /// Opcode (EIP-3155) tracer. Disabled by default; zero overhead when inactive.
485 pub opcode_tracer: LevmOpcodeTracer,
486 /// Debug mode for development diagnostics.
487 pub debug_mode: DebugMode,
488 /// Pool of reusable stacks to reduce allocations.
489 pub stack_pool: Vec<Stack>,
490 /// VM type (L1 or L2 with fee config).
491 pub vm_type: VMType,
492 /// Whether the top-level call-frame backup must be PRESERVED (deep-cloned) on the
493 /// revert / invalid-tx paths because a `BackupHook` will read it in `finalize_execution`
494 /// to build the tx-level undo snapshot. Derived from the installed `hooks` (via
495 /// [`Hook::reads_top_level_backup`]) rather than from `vm_type`, so it stays correct if
496 /// hook wiring changes; `add_hook` keeps it in sync for the `BackupHook` that
497 /// `stateless_execute` installs after construction. False for normal L1 block execution
498 /// (no `BackupHook`), where the backup is dead once the cache is restored and can be moved
499 /// out instead of cloned.
500 pub(crate) preserve_top_level_backup: bool,
501 /// EIP-8037: Accumulated state gas for this transaction (Amsterdam+).
502 /// Signed: goes negative when inline refunds exceed gross charges in the local frame
503 /// (e.g. SSTORE 0→x→0 restoration matching an ancestor's charge).
504 pub state_gas_used: i64,
505 /// EIP-8037: State gas reservoir pre-funded from excess gas_limit (Amsterdam+).
506 pub state_gas_reservoir: u64,
507 /// EIP-8037: Initial reservoir at tx start (before any execution). Captured in
508 /// add_intrinsic_gas so block-dimensional regular gas can be computed
509 /// independently of mid-tx reservoir activity (auth refunds, SSTORE credits).
510 pub state_gas_reservoir_initial: u64,
511 /// EIP-8037: Cumulative state gas that spilled to regular gas during execution
512 /// (when reservoir was insufficient). Subtracted when computing dimensional
513 /// regular gas for block accounting — EELS charge_state_gas spills don't
514 /// increment regular_gas_used.
515 pub state_gas_spill: u64,
516 /// EIP-8037: Dynamic cost per state byte (computed from block_gas_limit, Amsterdam+).
517 pub cost_per_state_byte: u64,
518 /// EIP-8037: State gas for new account creation (STATE_BYTES_PER_NEW_ACCOUNT * cost_per_state_byte).
519 pub state_gas_new_account: u64,
520 /// EIP-8037: State gas for storage slot creation (STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte).
521 pub state_gas_storage_set: u64,
522 /// EIP-8037: State gas for EIP-7702 auth total (STATE_BYTES_PER_AUTH_TOTAL * cost_per_state_byte).
523 pub state_gas_auth_total: u64,
524 /// EIP-8037: State gas for the 23-byte EIP-7702 delegation indicator
525 /// (STATE_BYTES_PER_AUTH_BASE * cost_per_state_byte). Refunded by
526 /// `set_delegation` when no new delegation indicator bytes are written —
527 /// either the authority's code slot already holds an indicator or the
528 /// auth clears against an empty authority.
529 pub state_gas_auth_base: u64,
530 /// EIP-8037: state-gas refund channel.
531 /// Mirrors EELS `MessageCallOutput.state_refund` — a separate, monotonic accumulator
532 /// for refunds that bypass per-frame `state_gas_used` accounting. Populated by
533 /// `set_delegation` for existing-authority refunds, subtracted from block-level
534 /// state-gas at the end of `refund_sender`. Survives revert/halt/OOG since it lives
535 /// on the VM, not in any call-frame backup.
536 pub state_refund: u64,
537 /// EIP-8037: intrinsic state gas (`tx_env.intrinsic_state_gas` in EELS). Captured at
538 /// `add_intrinsic_gas` time. ethrex lumps intrinsic + execution into `state_gas_used`,
539 /// so on top-level error this field is what we leave behind when refunding the
540 /// execution portion to the reservoir — block accounting then bills the intrinsic
541 /// (matches EELS `tx_state_gas = intrinsic_state_gas + tx_output.state_gas_used`).
542 pub intrinsic_state_gas: u64,
543 /// The opcode table mapping opcodes to opcode handlers for fast lookup.
544 /// A shared `&'static` reference to a per-fork table that is `const`-built once for the
545 /// whole process (immutable), so each VM holds only a pointer instead of a 2 KB inline copy.
546 pub(crate) opcode_table: &'static [OpCodeFn; 256],
547 /// Crypto provider for cryptographic operations.
548 pub crypto: &'a dyn Crypto,
549}
550
551impl<'a> VM<'a> {
552 /// Constructs a VM, allocating a fresh 32 KB root call-frame stack.
553 ///
554 /// Hot block execution should prefer [`VM::new_pooled`], which draws the root stack from a
555 /// reusable pool instead of allocating + zeroing one per transaction.
556 pub fn new(
557 env: Environment,
558 db: &'a mut GeneralizedDatabase,
559 tx: &'a Transaction,
560 tracer: LevmCallTracer,
561 vm_type: VMType,
562 crypto: &'a dyn Crypto,
563 ) -> Result<Self, VMError> {
564 Self::new_with_root_stack(
565 env,
566 db,
567 tx,
568 tracer,
569 vm_type,
570 crypto,
571 Stack::default(),
572 Memory::default(),
573 )
574 }
575
576 /// Like [`VM::new`], but draws the root call-frame stack from `stack_pool` (falling back to a
577 /// fresh `Stack::default()` only when the pool is empty) and adopts the remaining pooled
578 /// stacks for sub-call frames. This avoids the per-tx 32 KB stack alloc+zero on a warm pool —
579 /// the dominant allocation for transfer-heavy blocks, where the root frame is the only frame.
580 ///
581 /// Pair with [`VM::reclaim_into`] after execution to return every stack (root + sub-frame)
582 /// to `stack_pool` and the root memory buffer to `memory_pool` so the next tx reuses them.
583 #[allow(clippy::too_many_arguments)]
584 pub fn new_pooled(
585 env: Environment,
586 db: &'a mut GeneralizedDatabase,
587 tx: &'a Transaction,
588 tracer: LevmCallTracer,
589 vm_type: VMType,
590 crypto: &'a dyn Crypto,
591 stack_pool: &mut Vec<Stack>,
592 memory_pool: &mut Vec<Memory>,
593 ) -> Result<Self, VMError> {
594 // Reuse a pooled stack for the root frame. `clear()` only resets the offset (no zeroing),
595 // which is sound because the EVM never reads stack slots it didn't write — the same
596 // invariant that already makes sub-frame pooling safe.
597 let mut root_stack = stack_pool.pop().unwrap_or_default();
598 root_stack.clear();
599 // Reuse a pooled root memory buffer (capacity retained from a prior tx, contents dropped).
600 // `reclaim_into` truncates it to length 0, so `resize`'s zero-fill invariant holds. Only
601 // the root buffer is pooled: sub-frame memories are `Rc` clones of it (`next_memory`).
602 let mut root_memory = memory_pool.pop().unwrap_or_default();
603 root_memory.reset_for_reuse();
604 let mut vm = Self::new_with_root_stack(
605 env,
606 db,
607 tx,
608 tracer,
609 vm_type,
610 crypto,
611 root_stack,
612 root_memory,
613 )?;
614 // Adopt the caller's pooled stacks for sub-frames; returned via `reclaim_into`.
615 mem::swap(&mut vm.stack_pool, stack_pool);
616 Ok(vm)
617 }
618
619 /// Returns this VM's reusable buffers to the caller's pools so the next transaction reuses
620 /// them instead of allocating: every stack (root call-frame stack plus any sub-frame stacks
621 /// still pooled internally) to `stack_pool`, and the root memory buffer to `memory_pool`.
622 /// Must run on both the success and error paths of [`VM::execute`].
623 pub fn reclaim_into(mut self, stack_pool: &mut Vec<Stack>, memory_pool: &mut Vec<Memory>) {
624 // Hand the internal sub-frame pool back to the caller first.
625 mem::swap(&mut self.stack_pool, stack_pool);
626 // Then reclaim the root frame's stack. Moving it out by value (VM/CallFrame have no Drop)
627 // avoids leaving a fresh 32 KB `Stack::default()` placeholder behind — which a
628 // `mem::take`/`mem::replace` against an empty pool would force, defeating the win on
629 // exactly the transfer-only blocks (no sub-frames ever seed the pool) we target.
630 let mut root_stack = self.current_call_frame.stack;
631 root_stack.clear();
632 stack_pool.push(root_stack);
633 // Reclaim the root memory buffer with its grown capacity. `reset_for_reuse` truncates it
634 // to length 0 (capacity kept) so the next tx's `resize` zero-fills correctly.
635 //
636 // Every call frame shares the same `Rc<RefCell<Vec<u8>>>` buffer, so on the error path the
637 // ancestor frames left in `call_frames` (error propagation unwinds out of `execute` without
638 // popping them) still hold clones. Drop them first so the buffer is `Rc`-unique on BOTH
639 // paths before we clear it — otherwise the clear would propagate to a frame still holding a
640 // reference. `CallFrame` has no `Drop` and these frames are never read again, so dropping
641 // them early is free.
642 self.call_frames.clear();
643 let mut root_memory = self.current_call_frame.memory;
644 debug_assert_eq!(
645 Rc::strong_count(&root_memory.buffer),
646 1,
647 "root memory buffer must be Rc-unique at reclaim; a frame is still holding it and \
648 would observe the reset_for_reuse clear",
649 );
650 root_memory.reset_for_reuse();
651 memory_pool.push(root_memory);
652 }
653
654 #[allow(clippy::too_many_arguments)]
655 fn new_with_root_stack(
656 env: Environment,
657 db: &'a mut GeneralizedDatabase,
658 tx: &'a Transaction,
659 tracer: LevmCallTracer,
660 vm_type: VMType,
661 crypto: &'a dyn Crypto,
662 root_stack: Stack,
663 root_memory: Memory,
664 ) -> Result<Self, VMError> {
665 db.tx_backup = None; // If BackupHook is enabled, it will contain backup at the end of tx execution.
666
667 let mut substate = Substate::initialize(&env, tx)?;
668
669 let (callee, is_create) = Self::get_tx_callee(tx, db, &env, &mut substate)?;
670
671 let fork = env.config.fork;
672
673 #[expect(
674 clippy::arithmetic_side_effects,
675 reason = "byte-count constants are small (<200) and cpsb is bounded by block_gas_limit/year formula"
676 )]
677 let (
678 cpsb,
679 state_gas_new_account,
680 state_gas_storage_set,
681 state_gas_auth_total,
682 state_gas_auth_base,
683 ) = if fork >= Fork::Amsterdam {
684 let cpsb = compute_cost_per_state_byte(env.block_gas_limit);
685 (
686 cpsb,
687 STATE_BYTES_PER_NEW_ACCOUNT * cpsb,
688 STATE_BYTES_PER_STORAGE_SET * cpsb,
689 STATE_BYTES_PER_AUTH_TOTAL * cpsb,
690 STATE_BYTES_PER_AUTH_BASE * cpsb,
691 )
692 } else {
693 (0, 0, 0, 0, 0)
694 };
695
696 // Derive whether the top-level backup must be preserved from the installed hooks rather
697 // than from `vm_type`. The flag's real meaning is "a hook reads the top-level backup in
698 // `finalize_execution`," which today is the `BackupHook` on L2 / stateless. Deriving it
699 // keeps the flag correct if hook wiring ever changes (e.g. a future `vm_type` that adds
700 // `BackupHook`, or L2 dropping it), and `add_hook` keeps it in sync for the `BackupHook`
701 // that `stateless_execute` installs after construction. L1 block execution installs no
702 // `BackupHook` (see `l1_hooks`), so the backup is dead once the cache is restored.
703 let hooks = get_hooks(&vm_type);
704 let preserve_top_level_backup = hooks
705 .iter()
706 .any(|hook| hook.borrow().reads_top_level_backup());
707
708 let mut vm = Self {
709 call_frames: Vec::new(),
710 substate,
711 db,
712 tx,
713 hooks,
714 storage_original_values: FxHashMap::default(),
715 tracer,
716 opcode_tracer: LevmOpcodeTracer::disabled(),
717 debug_mode: DebugMode::disabled(),
718 stack_pool: Vec::new(),
719 vm_type,
720 preserve_top_level_backup,
721 state_gas_used: 0,
722 state_gas_reservoir: 0,
723 state_gas_reservoir_initial: 0,
724 state_gas_spill: 0,
725 cost_per_state_byte: cpsb,
726 state_gas_new_account,
727 state_gas_storage_set,
728 state_gas_auth_total,
729 state_gas_auth_base,
730 state_refund: 0,
731 intrinsic_state_gas: 0,
732 current_call_frame: CallFrame::new(
733 env.origin,
734 callee,
735 Address::default(), // Will be assigned at the end of prepare_execution
736 Code::default(), // Will be assigned at the end of prepare_execution
737 tx.value(),
738 tx.data().clone(),
739 false,
740 env.gas_limit,
741 0,
742 true,
743 is_create,
744 0,
745 0,
746 root_stack,
747 root_memory,
748 ),
749 env,
750 opcode_table: VM::build_opcode_table(fork),
751 crypto,
752 };
753
754 let call_type = if is_create {
755 CallType::CREATE
756 } else {
757 CallType::CALL
758 };
759 vm.tracer.enter(
760 call_type,
761 vm.env.origin,
762 callee,
763 vm.tx.value(),
764 vm.env.gas_limit,
765 vm.tx.data(),
766 );
767
768 #[cfg(feature = "debug")]
769 {
770 // Enable debug mode for printing in Solidity contracts.
771 vm.debug_mode.enabled = true;
772 }
773
774 Ok(vm)
775 }
776
777 fn add_hook(&mut self, hook: impl Hook + 'static) {
778 // Keep `preserve_top_level_backup` in sync: a hook added after construction (e.g. the
779 // `BackupHook` in `stateless_execute`) may read the top-level backup in `finalize_execution`.
780 self.preserve_top_level_backup |= hook.reads_top_level_backup();
781 self.hooks.push(Rc::new(RefCell::new(hook)));
782 }
783
784 /// EIP-8037: Charge state gas, drawing from reservoir first, spilling to gas_remaining if exhausted.
785 ///
786 /// Must only be called for Amsterdam+ forks. All call sites must guard with
787 /// `fork >= Fork::Amsterdam` before invoking this method.
788 #[expect(
789 clippy::arithmetic_side_effects,
790 reason = "arithmetic proven safe by min()"
791 )]
792 pub fn increase_state_gas(&mut self, gas: u64) -> Result<(), VMError> {
793 debug_assert!(
794 self.env.config.fork >= Fork::Amsterdam,
795 "increase_state_gas called pre-Amsterdam"
796 );
797 // Draw from reservoir first; only spill to gas_remaining if reservoir exhausted
798 let from_reservoir = self.state_gas_reservoir.min(gas);
799 // Safe: from_reservoir <= gas
800 let spill = gas - from_reservoir;
801 if spill > 0 {
802 // Charge spill from gas_remaining first — if OOG, return early
803 // without mutating reservoir or state_gas_used (matches EELS behavior)
804 self.current_call_frame.increase_consumed_gas(spill)?;
805 }
806 // Safe: from_reservoir = min(reservoir, gas) so reservoir >= from_reservoir
807 self.state_gas_reservoir -= from_reservoir;
808 // Only increment state_gas_used AFTER the charge succeeds.
809 // state_gas_used is i64; tx gas_limit caps charges well below i64::MAX.
810 self.state_gas_used = self
811 .state_gas_used
812 .checked_add(i64::try_from(gas).map_err(|_| InternalError::Overflow)?)
813 .ok_or(InternalError::Overflow)?;
814 // Track the spill for block-accounting: EELS charge_state_gas spills
815 // don't count toward regular_gas_used for the regular dimension.
816 self.state_gas_spill = self
817 .state_gas_spill
818 .checked_add(spill)
819 .ok_or(InternalError::Overflow)?;
820 Ok(())
821 }
822
823 /// EIP-8037: credit `amount` directly to the local frame's reservoir; `state_gas_used`
824 /// may go negative when the matching charge lives in an ancestor frame.
825 ///
826 /// Must only be called for Amsterdam+ forks.
827 pub fn credit_state_gas_refund(&mut self, amount: u64) -> Result<(), VMError> {
828 debug_assert!(
829 self.env.config.fork >= Fork::Amsterdam,
830 "credit_state_gas_refund called pre-Amsterdam"
831 );
832 self.state_gas_reservoir = self
833 .state_gas_reservoir
834 .checked_add(amount)
835 .ok_or(InternalError::Overflow)?;
836 self.state_gas_used = self
837 .state_gas_used
838 .checked_sub(i64::try_from(amount).map_err(|_| InternalError::Overflow)?)
839 .ok_or(InternalError::Overflow)?;
840 Ok(())
841 }
842
843 /// EIP-8037 `incorporate_child_on_error`: on child revert, restore the parent's
844 /// `state_gas_used` to its pre-child value and refund the child's net
845 /// `(state_gas_used + state_gas_left)` back into the parent's reservoir.
846 ///
847 /// In ethrex's shared-VM model the child holds the entire reservoir during its
848 /// execution, so `child.state_gas_left == self.state_gas_reservoir` (absolute,
849 /// not a delta against entry). `child.state_gas_used` can be negative when
850 /// inline refunds inside the child exceeded its gross charges.
851 pub fn incorporate_child_state_gas_on_revert(
852 &mut self,
853 state_gas_used_at_entry: i64,
854 ) -> Result<(), VMError> {
855 let child_state_gas_used = self
856 .state_gas_used
857 .checked_sub(state_gas_used_at_entry)
858 .ok_or(InternalError::Overflow)?;
859 let child_state_gas_left =
860 i64::try_from(self.state_gas_reservoir).map_err(|_| InternalError::Overflow)?;
861 self.state_gas_used = state_gas_used_at_entry;
862 let net_return = child_state_gas_used
863 .checked_add(child_state_gas_left)
864 .ok_or(InternalError::Overflow)?;
865 // net_return is always >= 0 by the spec invariant (reservoir conservation
866 // means a child cannot refund more than its ancestors charged); clamp
867 // defensively and cast — `as u64` is sound because of the `.max(0)`.
868 #[expect(clippy::as_conversions, reason = ".max(0) proves non-negativity")]
869 {
870 self.state_gas_reservoir = net_return.max(0) as u64;
871 }
872 Ok(())
873 }
874
875 /// Executes a whole external transaction. Performing validations at the beginning.
876 pub fn execute(&mut self) -> Result<ExecutionReport, VMError> {
877 if let Err(e) = self.prepare_execution() {
878 // Restore cache to state previous to this Tx execution because this Tx is invalid.
879 // Consume the backup unless a `BackupHook` will read it (L2 / stateless); on L1 it
880 // is dead once the cache is restored.
881 if self.preserve_top_level_backup {
882 self.restore_cache_state()?;
883 } else {
884 self.restore_cache_state_consuming()?;
885 }
886 return Err(e);
887 }
888
889 // Clear callframe backup so that changes made in prepare_execution are written in stone.
890 // We want to apply these changes even if the Tx reverts. E.g. Incrementing sender nonce
891 self.current_call_frame.call_frame_backup.clear();
892
893 // Empty bytecode would only execute STOP; skip the dispatch loop.
894 // The BAL checkpoint below is intentionally skipped: a codeless transfer cannot
895 // fail past this point and has no inner calls, so there's nothing to roll back.
896 if self.is_simple_transfer_fast_path() {
897 #[expect(clippy::as_conversions, reason = "gas_remaining is non-negative here")]
898 let gas_used = self
899 .current_call_frame
900 .gas_limit
901 .checked_sub(self.current_call_frame.gas_remaining as u64)
902 .ok_or(InternalError::Underflow)?;
903 let context_result = ContextResult {
904 result: TxResult::Success,
905 gas_used,
906 gas_spent: gas_used,
907 output: Bytes::new(),
908 };
909 return self.finalize_execution(context_result);
910 }
911
912 // EIP-7928: Take a BAL checkpoint AFTER clearing the backup. This captures the state
913 // after prepare_execution (nonce increment, etc.) but before actual execution.
914 // When the top-level call fails, we restore to this checkpoint so that inner call
915 // state changes (like value transfers) are reverted from the BAL.
916 self.current_call_frame.call_frame_backup.bal_checkpoint =
917 self.db.bal_recorder.as_ref().map(|r| r.checkpoint());
918
919 if self.is_create()? {
920 // Create contract, reverting the Tx if address is already occupied.
921 if let Some(context_result) = self.handle_create_transaction()? {
922 let report = self.finalize_execution(context_result)?;
923 return Ok(report);
924 }
925 }
926
927 self.substate.push_backup();
928 let context_result = self.run_execution()?;
929
930 let report = self.finalize_execution(context_result)?;
931
932 Ok(report)
933 }
934
935 /// Must run after `prepare_execution` so EIP-7702 delegation is already resolved into
936 /// `bytecode`.
937 #[inline(always)]
938 fn is_simple_transfer_fast_path(&self) -> bool {
939 !self.current_call_frame.is_create
940 && self.current_call_frame.bytecode.is_empty()
941 // Privileged L2 txs can leave gas negative; let the slow path surface that as OOG.
942 && self.current_call_frame.gas_remaining >= 0
943 && self.tx.authorization_list().is_none()
944 // Precompiles dispatch via run_execution even with empty bytecode.
945 && !precompiles::is_precompile(
946 &self.current_call_frame.to,
947 self.env.config.fork,
948 self.vm_type,
949 )
950 }
951
952 /// Main execution loop.
953 pub fn run_execution(&mut self) -> Result<ContextResult, VMError> {
954 // If gas is already exhausted (negative), fail immediately.
955 // This can happen when intrinsic gas exceeds the gas limit in privileged L2 transactions.
956 // Without this check, casting negative gas_remaining to u64 would wrap to a huge value.
957 if self.current_call_frame.gas_remaining < 0 {
958 return Ok(ContextResult {
959 result: TxResult::Revert(ExceptionalHalt::OutOfGas.into()),
960 gas_used: self.current_call_frame.gas_limit,
961 gas_spent: self.current_call_frame.gas_limit,
962 output: Bytes::new(),
963 });
964 }
965
966 #[expect(clippy::as_conversions, reason = "remaining gas conversion")]
967 if precompiles::is_precompile(
968 &self.current_call_frame.to,
969 self.env.config.fork,
970 self.vm_type,
971 ) {
972 let call_frame = &mut self.current_call_frame;
973
974 let mut gas_remaining = call_frame.gas_remaining as u64;
975 let result = Self::execute_precompile(
976 call_frame.code_address,
977 &call_frame.calldata,
978 call_frame.gas_limit,
979 &mut gas_remaining,
980 self.env.config.fork,
981 self.db.store.precompile_cache(),
982 self.crypto,
983 );
984
985 // EIP-8037 Amsterdam 2D accounting recomputes `block_gas_used` from
986 // `raw_consumed = gas_limit - gas_remaining` inside `refund_sender`. On a
987 // top-level precompile exceptional halt, `handle_precompile_result` already
988 // sets `ContextResult.gas_used = gas_limit`, but `gas_remaining` retains the
989 // untouched forwarded amount — under Amsterdam that would make the block
990 // report only the intrinsic portion. Zero it here so the block matches the
991 // `gas_used = gas_limit` contract from `handle_precompile_result`. Pre-Amsterdam
992 // reads `ctx_result.gas_used` directly and is unaffected by this path either way.
993 if self.env.config.fork >= Fork::Amsterdam
994 && let Ok(ctx) = &result
995 && !ctx.is_success()
996 {
997 gas_remaining = 0;
998 }
999
1000 call_frame.gas_remaining = gas_remaining as i64;
1001
1002 return result;
1003 }
1004
1005 // Specialize the dispatch loop on whether a struct-log tracer is active.
1006 // The `!TRACED` variant compiles out every tracer branch and capture call,
1007 // leaving a minimal hot loop (the common, non-traced case).
1008 if self.opcode_tracer.active {
1009 self.run_dispatch::<true>()
1010 } else {
1011 self.run_dispatch::<false>()
1012 }
1013 }
1014
1015 /// Opcode dispatch loop, monomorphized over whether a struct-log tracer is
1016 /// active. With `TRACED = false` the compiler eliminates the tracer branches
1017 /// and the cold `trace_*_step` calls entirely, so the hot loop body stays
1018 /// minimal; the traced variant keeps the cold helpers out of line.
1019 fn run_dispatch<const TRACED: bool>(&mut self) -> Result<ContextResult, VMError> {
1020 let mut error = OnceCell::<VMError>::new();
1021
1022 #[cfg(feature = "perf_opcode_timings")]
1023 let mut timings = crate::timings::OPCODE_TIMINGS.lock().expect("poison");
1024
1025 // Copy the `&'static` table pointer once; it doesn't borrow `self`, so dispatch can still
1026 // pass `self` mutably to the handler without reloading the pointer each iteration.
1027 let opcode_table = self.opcode_table;
1028
1029 loop {
1030 // Capture pc BEFORE advance_pc() — this is the address of the current opcode.
1031 let pc_of_current_op = self.current_call_frame.pc;
1032 let opcode = self.current_call_frame.next_opcode();
1033 self.advance_pc();
1034
1035 // Struct-log pre-step capture (compiled out entirely when !TRACED).
1036 let gas_before_op = if TRACED {
1037 self.trace_pre_step(opcode, pc_of_current_op)
1038 } else {
1039 0
1040 };
1041
1042 #[cfg(feature = "perf_opcode_timings")]
1043 let opcode_time_start = std::time::Instant::now();
1044
1045 #[allow(clippy::indexing_slicing, clippy::as_conversions)]
1046 let op_result = opcode_table[opcode as usize].call(self, &mut error);
1047
1048 #[cfg(feature = "perf_opcode_timings")]
1049 {
1050 let time = opcode_time_start.elapsed();
1051 timings.update(opcode, time);
1052 }
1053
1054 // Struct-log post-step (compiled out entirely when !TRACED).
1055 if TRACED {
1056 self.trace_post_step(gas_before_op, &error);
1057 }
1058
1059 let result = match op_result {
1060 OpcodeResult::Continue => continue,
1061 OpcodeResult::Halt => match error.take() {
1062 None => self.handle_opcode_result()?,
1063 Some(error) => self.handle_opcode_error(error)?,
1064 },
1065 };
1066
1067 // Return the ExecutionReport if the executed callframe was the first one.
1068 if self.is_initial_call_frame() {
1069 // Consume the backup (move it out) unless a `BackupHook` will read it afterward
1070 // to build the tx-level undo snapshot (L2 / stateless). On L1 nothing reads it
1071 // once the cache is restored, so cloning it would be dead work.
1072 self.handle_state_backup(&result, !self.preserve_top_level_backup)?;
1073 return Ok(result);
1074 }
1075
1076 // Handle interaction between child and parent callframe.
1077 self.handle_return(&result)?;
1078 }
1079 }
1080
1081 /// Struct-log pre-step capture, split out of the interpreter loop and kept
1082 /// cold + non-inlined so the hot dispatch loop stays small (this code is
1083 /// only reached when a struct-log tracer is active). Returns `gas_before`.
1084 #[cold]
1085 #[inline(never)]
1086 fn trace_pre_step(&mut self, opcode: u8, pc_of_current_op: usize) -> u64 {
1087 #[expect(
1088 clippy::as_conversions,
1089 reason = "gas_remaining is i64; clamp to 0 before converting to u64"
1090 )]
1091 let gas_before = self.current_call_frame.gas_remaining.max(0) as u64;
1092 #[expect(
1093 clippy::as_conversions,
1094 reason = "call depth bounded by STACK_LIMIT=1024, fits in u32"
1095 )]
1096 let depth = (self.call_frames.len() as u32).saturating_add(1);
1097 let refund = self.substate.refunded_gas;
1098 let stack_view = self.collect_stack_for_trace();
1099 let mem_view = self.collect_memory_for_trace();
1100 // mem_size always reflects actual memory size, regardless of enable_memory.
1101 #[expect(
1102 clippy::as_conversions,
1103 reason = "memory size is bounded by gas; fits in u64"
1104 )]
1105 let mem_size_for_trace = self.current_call_frame.memory.len() as u64;
1106 let storage_kv = self.read_storage_for_trace(opcode);
1107 let return_data = if self.opcode_tracer.cfg.enable_return_data {
1108 self.current_call_frame.sub_return_data.clone()
1109 } else {
1110 Bytes::new()
1111 };
1112 #[expect(
1113 clippy::as_conversions,
1114 reason = "pc is usize, fits in u64 on supported targets"
1115 )]
1116 let pc_u64 = pc_of_current_op as u64;
1117 self.opcode_tracer.pre_step_capture(
1118 pc_u64,
1119 opcode,
1120 gas_before,
1121 depth,
1122 refund,
1123 &stack_view,
1124 &mem_view,
1125 mem_size_for_trace,
1126 &return_data,
1127 storage_kv,
1128 );
1129 gas_before
1130 }
1131
1132 /// Struct-log post-step: patch gas_cost, refund-after-op, and error into the
1133 /// buffered entry. Cold + non-inlined for the same reason as `trace_pre_step`.
1134 #[cold]
1135 #[inline(never)]
1136 fn trace_post_step(&mut self, gas_before_op: u64, error: &OnceCell<VMError>) {
1137 #[expect(
1138 clippy::as_conversions,
1139 reason = "gas_remaining is i64; clamp to 0 before converting to u64"
1140 )]
1141 let gas_after = self.current_call_frame.gas_remaining.max(0) as u64;
1142 // Prefer the explicit opcode-overhead cost written by CALL/CREATE handlers;
1143 // fall back to the gas diff for all other opcodes.
1144 let gas_cost = self
1145 .opcode_tracer
1146 .last_opcode_gas_cost
1147 .take()
1148 .unwrap_or_else(|| gas_before_op.saturating_sub(gas_after));
1149 // refund-after-op matches geth's structLogger timing: for SSTORE and
1150 // (pre-London) SELFDESTRUCT, the refund counter shown is the value
1151 // *after* the opcode's accounting applied.
1152 let refund_after = self.substate.refunded_gas;
1153 let err_str = error.get().map(|e| e.to_string());
1154 self.opcode_tracer
1155 .finalize_step(gas_cost, refund_after, err_str.as_deref());
1156 }
1157
1158 /// Executes precompile and handles the output that it returns, generating a report.
1159 pub fn execute_precompile(
1160 code_address: H160,
1161 calldata: &Bytes,
1162 gas_limit: u64,
1163 gas_remaining: &mut u64,
1164 fork: Fork,
1165 cache: Option<&precompiles::PrecompileCache>,
1166 crypto: &dyn Crypto,
1167 ) -> Result<ContextResult, VMError> {
1168 Self::handle_precompile_result(
1169 precompiles::execute_precompile(
1170 code_address,
1171 calldata,
1172 gas_remaining,
1173 fork,
1174 cache,
1175 crypto,
1176 ),
1177 gas_limit,
1178 *gas_remaining,
1179 )
1180 }
1181
1182 /// True if external transaction is a contract creation
1183 pub fn is_create(&self) -> Result<bool, InternalError> {
1184 Ok(self.current_call_frame.is_create)
1185 }
1186
1187 /// Executes without making changes to the cache.
1188 pub fn stateless_execute(&mut self) -> Result<ExecutionReport, VMError> {
1189 // Add backup hook to restore state after execution. `add_hook` flips
1190 // `preserve_top_level_backup` on via `Hook::reads_top_level_backup`, so the backup is
1191 // cloned (not moved out) on the revert paths even though this VM was built with L1 `vm_type`.
1192 self.add_hook(BackupHook::default());
1193 let report = self.execute()?;
1194 // Restore cache to the state before execution.
1195 self.db.undo_last_transaction()?;
1196 Ok(report)
1197 }
1198
1199 fn prepare_execution(&mut self) -> Result<(), VMError> {
1200 // Clone each hook's `Rc` (cheap refcount bump) so the borrow on `self.hooks` is released
1201 // and `self` can be passed mutably — without `self.hooks.clone()`'s per-tx `Vec` realloc.
1202 // `self.hooks` is not mutated during the loop, so `get(i)` is always `Some` in range.
1203 for i in 0..self.hooks.len() {
1204 if let Some(hook) = self.hooks.get(i).map(Rc::clone) {
1205 hook.borrow_mut().prepare_execution(self)?;
1206 }
1207 }
1208
1209 Ok(())
1210 }
1211
1212 fn finalize_execution(
1213 &mut self,
1214 mut ctx_result: ContextResult,
1215 ) -> Result<ExecutionReport, VMError> {
1216 // EIP-8037: On top-level tx failure (REVERT, ExceptionalHalt, or OOG),
1217 // refund only the EXECUTION portion of state gas to the reservoir; the intrinsic
1218 // stays in `state_gas_used` so block accounting bills it. EELS keeps these in
1219 // separate fields (`tx_output.state_gas_used` vs `tx_env.intrinsic_state_gas`);
1220 // ethrex lumps them so we split on the way out:
1221 // tx_output.state_gas_left += tx_output.state_gas_used
1222 // tx_output.state_gas_used = 0
1223 // becomes in lumped form (with intrinsic preserved):
1224 // reservoir += signed(state_gas_used − intrinsic) [clamped at 0]
1225 // state_gas_used = intrinsic
1226 // Collision is handled separately in the hook.
1227 if self.env.config.fork >= Fork::Amsterdam && !ctx_result.is_success() {
1228 if !ctx_result.is_collision() {
1229 let intrinsic_signed =
1230 i64::try_from(self.intrinsic_state_gas).map_err(|_| InternalError::Overflow)?;
1231 let execution_state_gas_used = self.state_gas_used.saturating_sub(intrinsic_signed);
1232 let reservoir_signed = i64::try_from(self.state_gas_reservoir)
1233 .map_err(|_| InternalError::Overflow)?
1234 .saturating_add(execution_state_gas_used);
1235 self.state_gas_reservoir =
1236 u64::try_from(reservoir_signed.max(0)).map_err(|_| InternalError::Overflow)?;
1237 self.state_gas_used = intrinsic_signed;
1238 }
1239
1240 // EIP-8037: on ANY top-level CREATE-tx
1241 // failure (revert / halt / OOG / collision), refund the intrinsic
1242 // `STATE_BYTES_PER_NEW_ACCOUNT * cost_per_state_byte` charge to the reservoir.
1243 // Also add to `state_refund` so block-level accounting subtracts it.
1244 // EELS reference: fork.py::process_transaction:
1245 // if isinstance(tx.to, Bytes0):
1246 // new_account_refund = STATE_BYTES_PER_NEW_ACCOUNT * COST_PER_STATE_BYTE
1247 // tx_output.state_gas_left += new_account_refund
1248 // tx_output.state_refund += new_account_refund
1249 if self.is_create()? {
1250 let new_account_refund = self.state_gas_new_account;
1251 self.state_gas_reservoir = self
1252 .state_gas_reservoir
1253 .checked_add(new_account_refund)
1254 .ok_or(InternalError::Overflow)?;
1255 self.state_refund = self
1256 .state_refund
1257 .checked_add(new_account_refund)
1258 .ok_or(InternalError::Overflow)?;
1259 }
1260 }
1261
1262 // See `prepare_execution`: per-hook `Rc::clone` avoids the `self.hooks.clone()` realloc.
1263 for i in 0..self.hooks.len() {
1264 if let Some(hook) = self.hooks.get(i).map(Rc::clone) {
1265 hook.borrow_mut()
1266 .finalize_execution(self, &mut ctx_result)?;
1267 }
1268 }
1269
1270 self.tracer.exit_context(&ctx_result, true)?;
1271
1272 // Struct-log end-of-tx capture: record final output, gas used, and revert error.
1273 // gas matches geth's `executionResult.Gas` which is post-refund (`receipt.GasUsed`).
1274 if self.opcode_tracer.active {
1275 self.opcode_tracer.output = ctx_result.output.clone();
1276 self.opcode_tracer.gas_used = ctx_result.gas_spent;
1277 self.opcode_tracer.error = match ctx_result.result {
1278 TxResult::Revert(ref err) => Some(err.to_string()),
1279 _ => None,
1280 };
1281 }
1282
1283 // Only include logs if transaction succeeded. When a transaction reverts,
1284 // no logs should be emitted (including EIP-7708 Transfer logs).
1285 let logs = if ctx_result.is_success() {
1286 self.substate.extract_logs()
1287 } else {
1288 Vec::new()
1289 };
1290
1291 // EIP-8037: `state_gas_used` is already net (signed; credits
1292 // decrement it inline). Subtract `state_refund` (EIP-7702 tx-level channel) and
1293 // clamp at zero for block accounting — `state_gas_used` may be negative when inline
1294 // refunds exceed gross charges.
1295 let state_refund_signed =
1296 i64::try_from(self.state_refund).map_err(|_| InternalError::Overflow)?;
1297 let net_state_gas_used: u64 = u64::try_from(
1298 self.state_gas_used
1299 .saturating_sub(state_refund_signed)
1300 .max(0),
1301 )
1302 .map_err(|_| InternalError::Overflow)?;
1303
1304 let report = ExecutionReport {
1305 result: ctx_result.result.clone(),
1306 gas_used: ctx_result.gas_used,
1307 gas_spent: ctx_result.gas_spent,
1308 gas_refunded: self.substate.refunded_gas,
1309 state_gas_used: net_state_gas_used,
1310 output: std::mem::take(&mut ctx_result.output),
1311 logs,
1312 };
1313
1314 Ok(report)
1315 }
1316
1317 // ── Struct-log helper methods ─────────────────────────────────────────────
1318
1319 /// Collects the current stack in bottom-first order for struct-log emission.
1320 ///
1321 /// LEVM stack is top-first in memory (`values[offset]` = top), so we reverse
1322 /// the active slice to produce the bottom-first wire format geth uses.
1323 /// Returns an empty `Vec` when `cfg.disable_stack` is true.
1324 pub fn collect_stack_for_trace(&self) -> Vec<U256> {
1325 use crate::constants::STACK_LIMIT;
1326 if self.opcode_tracer.cfg.disable_stack {
1327 return Vec::new();
1328 }
1329 let s = &self.current_call_frame.stack;
1330 // offset <= STACK_LIMIT by stack invariant.
1331 s.values
1332 .get(s.offset..STACK_LIMIT)
1333 .map(|slice| slice.iter().rev().copied().collect())
1334 .unwrap_or_default()
1335 }
1336
1337 /// Collects the live memory bytes for the current frame.
1338 ///
1339 /// Returns an empty `Vec` when `cfg.enable_memory` is false or memory is empty.
1340 pub fn collect_memory_for_trace(&self) -> Vec<u8> {
1341 if !self.opcode_tracer.cfg.enable_memory {
1342 return Vec::new();
1343 }
1344 self.current_call_frame.memory.live_bytes()
1345 }
1346
1347 /// Pre-reads the storage key/value for the current SLOAD or SSTORE opcode.
1348 ///
1349 /// Returns `None` when:
1350 /// - `cfg.disable_storage` is set, or
1351 /// - `opcode` is not SLOAD (0x54) or SSTORE (0x55), or
1352 /// - the stack is empty (guard against underflow before the handler runs), or
1353 /// - the storage read fails for any reason (including `AccountNotFound` —
1354 /// the trace omits the entry rather than emitting an ambiguous zero).
1355 ///
1356 /// For SLOAD: key = `stack.top`; value = the *current* stored value read from the DB.
1357 /// For SSTORE: key = `stack.top`, value = `stack[top-1]` (the new value being written).
1358 pub fn read_storage_for_trace(&mut self, opcode: u8) -> Option<(H256, H256)> {
1359 const SLOAD: u8 = 0x54;
1360 const SSTORE: u8 = 0x55;
1361
1362 if self.opcode_tracer.cfg.disable_storage {
1363 return None;
1364 }
1365 if opcode != SLOAD && opcode != SSTORE {
1366 return None;
1367 }
1368
1369 // Need at least one element on stack for SLOAD, two for SSTORE.
1370 use crate::constants::STACK_LIMIT;
1371 let offset = self.current_call_frame.stack.offset;
1372 if offset >= STACK_LIMIT {
1373 return None; // stack empty
1374 }
1375
1376 // SLOAD/SSTORE operate on the call's storage context (`to`), not the code's
1377 // address. Under DELEGATECALL/CALLCODE these differ.
1378 let addr = self.current_call_frame.to;
1379
1380 let stack_values = &self.current_call_frame.stack.values;
1381 let key_u256 = *stack_values.get(offset)?;
1382 let key = BigEndianHash::from_uint(&key_u256);
1383
1384 if opcode == SLOAD {
1385 // Omit the entry on any read failure (incl. account not yet cached);
1386 // a zero value would be indistinguishable from a legitimate never-written slot.
1387 let v = self.get_storage_value(addr, key).ok()?;
1388 let value = BigEndianHash::from_uint(&v);
1389 Some((key, value))
1390 } else {
1391 // SSTORE: need two stack elements.
1392 let next_offset = offset.checked_add(1)?;
1393 if next_offset >= STACK_LIMIT {
1394 return None;
1395 }
1396 // values[offset+1] is the new value being written (second from top = stack[top-1]).
1397 let value_u256 = *self.current_call_frame.stack.values.get(next_offset)?;
1398 let value = BigEndianHash::from_uint(&value_u256);
1399 Some((key, value))
1400 }
1401 }
1402}
1403
1404impl Substate {
1405 /// Initializes the VM substate, mainly adding addresses to the "accessed_addresses" field and the same with storage slots
1406 pub fn initialize(env: &Environment, tx: &Transaction) -> Result<Substate, VMError> {
1407 let fork = env.config.fork;
1408
1409 // Add sender and recipient to accessed accounts [https://www.evm.codes/about#access_list]
1410 // Precompiles are NO LONGER inserted here — they are warm by construction (see
1411 // `is_warm_precompile`), removing the ~20-entry floor that used to dominate this set. The
1412 // remaining working set is small (sender + coinbase + recipient + access-list/touched
1413 // addresses; real p99 ~7), so a capacity of 8 covers most txs with little waste.
1414 let mut initial_accessed_addresses =
1415 FxHashSet::with_capacity_and_hasher(8, Default::default());
1416 // Storage slots are ~98% empty (p95 0, p99 4), so `default()` (alloc-free until first
1417 // insert) beats pre-sizing, which would tax the common empty case.
1418 let mut initial_accessed_storage_slots: FxHashMap<Address, FxHashSet<H256>> =
1419 FxHashMap::default();
1420
1421 // Add Tx sender to accessed accounts
1422 initial_accessed_addresses.insert(env.origin);
1423
1424 // [EIP-3651] - Add coinbase to accessed accounts after Shanghai
1425 if fork >= Fork::Shanghai {
1426 initial_accessed_addresses.insert(env.coinbase);
1427 }
1428
1429 // Add access lists contents to accessed accounts and accessed storage slots.
1430 // Iterate by reference (`Address`/`H256` are `Copy`); the old `.clone()` deep-copied
1431 // the whole `Vec<(Address, Vec<H256>)>` per tx just to read it.
1432 for (address, keys) in tx.access_list() {
1433 initial_accessed_addresses.insert(*address);
1434 // 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
1435 let warm_slots = initial_accessed_storage_slots.entry(*address).or_default();
1436 for slot in keys {
1437 warm_slots.insert(*slot);
1438 }
1439 }
1440
1441 let substate = Substate::from_accesses(
1442 fork,
1443 initial_accessed_addresses,
1444 initial_accessed_storage_slots,
1445 );
1446
1447 Ok(substate)
1448 }
1449}