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-2780 top-frame new-account state gas pending for the top-level value
521 /// transfer to an empty recipient. Captured in `prepare_execution` (before the
522 /// value transfer, while the recipient is still empty) and charged at the start
523 /// of `run_execution` so an OOG reverts the tx (EELS charges it inside
524 /// `process_message`) instead of invalidating the block.
525 pub pending_top_frame_state_gas: u64,
526 /// EIP-2780 top-frame regular gas pending for a 7702-delegated recipient (the
527 /// extra COLD_ACCOUNT_ACCESS to resolve the delegation). Deferred to
528 /// `run_execution` for the same revert-not-invalidate reason as the state charge.
529 pub pending_top_frame_regular_gas: u64,
530 /// EIP-8037: State gas for storage slot creation (STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte).
531 pub state_gas_storage_set: u64,
532 /// EIP-8037: State gas for EIP-7702 auth total (STATE_BYTES_PER_AUTH_TOTAL * cost_per_state_byte).
533 pub state_gas_auth_total: u64,
534 /// EIP-8037: State gas for the 23-byte EIP-7702 delegation indicator
535 /// (STATE_BYTES_PER_AUTH_BASE * cost_per_state_byte). Refunded by
536 /// `set_delegation` when no new delegation indicator bytes are written —
537 /// either the authority's code slot already holds an indicator or the
538 /// auth clears against an empty authority.
539 pub state_gas_auth_base: u64,
540 /// EIP-8037: state-gas refund channel.
541 /// Mirrors EELS `MessageCallOutput.state_refund` — a separate, monotonic accumulator
542 /// for refunds that bypass per-frame `state_gas_used` accounting. Populated by
543 /// `set_delegation` for existing-authority refunds, subtracted from block-level
544 /// state-gas at the end of `refund_sender`. Survives revert/halt/OOG since it lives
545 /// on the VM, not in any call-frame backup.
546 pub state_refund: u64,
547 /// EIP-8037: intrinsic state gas (`tx_env.intrinsic_state_gas` in EELS). Captured at
548 /// `add_intrinsic_gas` time. ethrex lumps intrinsic + execution into `state_gas_used`,
549 /// so on top-level error this field is what we leave behind when refunding the
550 /// execution portion to the reservoir — block accounting then bills the intrinsic
551 /// (matches EELS `tx_state_gas = intrinsic_state_gas + tx_output.state_gas_used`).
552 pub intrinsic_state_gas: u64,
553 /// EIP-8037 (#3002): whether a top-level CREATE transaction targeted an
554 /// already-alive account (existed and non-empty) at tx start, captured in
555 /// `handle_create_transaction` before any state mutation. Mirrors EELS
556 /// `MessageCallOutput.created_target_alive`. Extends the create-tx
557 /// new-account refund in `finalize_execution` to also fire on success when
558 /// the target was alive (no new account leaf created). Default false.
559 pub created_target_alive: bool,
560 /// The opcode table mapping opcodes to opcode handlers for fast lookup.
561 /// A shared `&'static` reference to a per-fork table that is `const`-built once for the
562 /// whole process (immutable), so each VM holds only a pointer instead of a 2 KB inline copy.
563 pub(crate) opcode_table: &'static [OpCodeFn; 256],
564 /// Crypto provider for cryptographic operations.
565 pub crypto: &'a dyn Crypto,
566}
567
568impl<'a> VM<'a> {
569 /// Constructs a VM, allocating a fresh 32 KB root call-frame stack.
570 ///
571 /// Hot block execution should prefer [`VM::new_pooled`], which draws the root stack from a
572 /// reusable pool instead of allocating + zeroing one per transaction.
573 pub fn new(
574 env: Environment,
575 db: &'a mut GeneralizedDatabase,
576 tx: &'a Transaction,
577 tracer: LevmCallTracer,
578 vm_type: VMType,
579 crypto: &'a dyn Crypto,
580 ) -> Result<Self, VMError> {
581 Self::new_with_root_stack(
582 env,
583 db,
584 tx,
585 tracer,
586 vm_type,
587 crypto,
588 Stack::default(),
589 Memory::default(),
590 )
591 }
592
593 /// Like [`VM::new`], but draws the root call-frame stack from `stack_pool` (falling back to a
594 /// fresh `Stack::default()` only when the pool is empty) and adopts the remaining pooled
595 /// stacks for sub-call frames. This avoids the per-tx 32 KB stack alloc+zero on a warm pool —
596 /// the dominant allocation for transfer-heavy blocks, where the root frame is the only frame.
597 ///
598 /// Pair with [`VM::reclaim_into`] after execution to return every stack (root + sub-frame)
599 /// to `stack_pool` and the root memory buffer to `memory_pool` so the next tx reuses them.
600 #[allow(clippy::too_many_arguments)]
601 pub fn new_pooled(
602 env: Environment,
603 db: &'a mut GeneralizedDatabase,
604 tx: &'a Transaction,
605 tracer: LevmCallTracer,
606 vm_type: VMType,
607 crypto: &'a dyn Crypto,
608 stack_pool: &mut Vec<Stack>,
609 memory_pool: &mut Vec<Memory>,
610 ) -> Result<Self, VMError> {
611 // Reuse a pooled stack for the root frame. `clear()` only resets the offset (no zeroing),
612 // which is sound because the EVM never reads stack slots it didn't write — the same
613 // invariant that already makes sub-frame pooling safe.
614 let mut root_stack = stack_pool.pop().unwrap_or_default();
615 root_stack.clear();
616 // Reuse a pooled root memory buffer (capacity retained from a prior tx, contents dropped).
617 // `reclaim_into` truncates it to length 0, so `resize`'s zero-fill invariant holds. Only
618 // the root buffer is pooled: sub-frame memories are `Rc` clones of it (`next_memory`).
619 let mut root_memory = memory_pool.pop().unwrap_or_default();
620 root_memory.reset_for_reuse();
621 let mut vm = Self::new_with_root_stack(
622 env,
623 db,
624 tx,
625 tracer,
626 vm_type,
627 crypto,
628 root_stack,
629 root_memory,
630 )?;
631 // Adopt the caller's pooled stacks for sub-frames; returned via `reclaim_into`.
632 mem::swap(&mut vm.stack_pool, stack_pool);
633 Ok(vm)
634 }
635
636 /// Returns this VM's reusable buffers to the caller's pools so the next transaction reuses
637 /// them instead of allocating: every stack (root call-frame stack plus any sub-frame stacks
638 /// still pooled internally) to `stack_pool`, and the root memory buffer to `memory_pool`.
639 /// Must run on both the success and error paths of [`VM::execute`].
640 pub fn reclaim_into(mut self, stack_pool: &mut Vec<Stack>, memory_pool: &mut Vec<Memory>) {
641 // Hand the internal sub-frame pool back to the caller first.
642 mem::swap(&mut self.stack_pool, stack_pool);
643 // Then reclaim the root frame's stack. Moving it out by value (VM/CallFrame have no Drop)
644 // avoids leaving a fresh 32 KB `Stack::default()` placeholder behind — which a
645 // `mem::take`/`mem::replace` against an empty pool would force, defeating the win on
646 // exactly the transfer-only blocks (no sub-frames ever seed the pool) we target.
647 let mut root_stack = self.current_call_frame.stack;
648 root_stack.clear();
649 stack_pool.push(root_stack);
650 // Reclaim the root memory buffer with its grown capacity. `reset_for_reuse` truncates it
651 // to length 0 (capacity kept) so the next tx's `resize` zero-fills correctly.
652 //
653 // Every call frame shares the same `Rc<RefCell<Vec<u8>>>` buffer, so on the error path the
654 // ancestor frames left in `call_frames` (error propagation unwinds out of `execute` without
655 // popping them) still hold clones. Drop them first so the buffer is `Rc`-unique on BOTH
656 // paths before we clear it — otherwise the clear would propagate to a frame still holding a
657 // reference. `CallFrame` has no `Drop` and these frames are never read again, so dropping
658 // them early is free.
659 self.call_frames.clear();
660 let mut root_memory = self.current_call_frame.memory;
661 debug_assert_eq!(
662 Rc::strong_count(&root_memory.buffer),
663 1,
664 "root memory buffer must be Rc-unique at reclaim; a frame is still holding it and \
665 would observe the reset_for_reuse clear",
666 );
667 root_memory.reset_for_reuse();
668 memory_pool.push(root_memory);
669 }
670
671 #[allow(clippy::too_many_arguments)]
672 fn new_with_root_stack(
673 env: Environment,
674 db: &'a mut GeneralizedDatabase,
675 tx: &'a Transaction,
676 tracer: LevmCallTracer,
677 vm_type: VMType,
678 crypto: &'a dyn Crypto,
679 root_stack: Stack,
680 root_memory: Memory,
681 ) -> Result<Self, VMError> {
682 db.tx_backup = None; // If BackupHook is enabled, it will contain backup at the end of tx execution.
683
684 let mut substate = Substate::initialize(&env, tx)?;
685
686 let (callee, is_create) = Self::get_tx_callee(tx, db, &env, &mut substate)?;
687
688 let fork = env.config.fork;
689
690 #[expect(
691 clippy::arithmetic_side_effects,
692 reason = "byte-count constants are small (<200) and cpsb is bounded by block_gas_limit/year formula"
693 )]
694 let (
695 cpsb,
696 state_gas_new_account,
697 state_gas_storage_set,
698 state_gas_auth_total,
699 state_gas_auth_base,
700 ) = if fork >= Fork::Amsterdam {
701 let cpsb = compute_cost_per_state_byte(env.block_gas_limit);
702 (
703 cpsb,
704 STATE_BYTES_PER_NEW_ACCOUNT * cpsb,
705 STATE_BYTES_PER_STORAGE_SET * cpsb,
706 STATE_BYTES_PER_AUTH_TOTAL * cpsb,
707 STATE_BYTES_PER_AUTH_BASE * cpsb,
708 )
709 } else {
710 (0, 0, 0, 0, 0)
711 };
712
713 // Derive whether the top-level backup must be preserved from the installed hooks rather
714 // than from `vm_type`. The flag's real meaning is "a hook reads the top-level backup in
715 // `finalize_execution`," which today is the `BackupHook` on L2 / stateless. Deriving it
716 // keeps the flag correct if hook wiring ever changes (e.g. a future `vm_type` that adds
717 // `BackupHook`, or L2 dropping it), and `add_hook` keeps it in sync for the `BackupHook`
718 // that `stateless_execute` installs after construction. L1 block execution installs no
719 // `BackupHook` (see `l1_hooks`), so the backup is dead once the cache is restored.
720 let hooks = get_hooks(&vm_type);
721 let preserve_top_level_backup = hooks
722 .iter()
723 .any(|hook| hook.borrow().reads_top_level_backup());
724
725 let mut vm = Self {
726 call_frames: Vec::new(),
727 substate,
728 db,
729 tx,
730 hooks,
731 storage_original_values: FxHashMap::default(),
732 tracer,
733 opcode_tracer: LevmOpcodeTracer::disabled(),
734 debug_mode: DebugMode::disabled(),
735 stack_pool: Vec::new(),
736 vm_type,
737 preserve_top_level_backup,
738 state_gas_used: 0,
739 state_gas_reservoir: 0,
740 state_gas_reservoir_initial: 0,
741 state_gas_spill: 0,
742 cost_per_state_byte: cpsb,
743 state_gas_new_account,
744 pending_top_frame_state_gas: 0,
745 pending_top_frame_regular_gas: 0,
746 state_gas_storage_set,
747 state_gas_auth_total,
748 state_gas_auth_base,
749 state_refund: 0,
750 intrinsic_state_gas: 0,
751 created_target_alive: false,
752 current_call_frame: CallFrame::new(
753 env.origin,
754 callee,
755 Address::default(), // Will be assigned at the end of prepare_execution
756 Code::default(), // Will be assigned at the end of prepare_execution
757 tx.value(),
758 tx.data().clone(),
759 false,
760 env.gas_limit,
761 0,
762 true,
763 is_create,
764 0,
765 0,
766 root_stack,
767 root_memory,
768 ),
769 env,
770 opcode_table: VM::build_opcode_table(fork),
771 crypto,
772 };
773
774 let call_type = if is_create {
775 CallType::CREATE
776 } else {
777 CallType::CALL
778 };
779 vm.tracer.enter(
780 call_type,
781 vm.env.origin,
782 callee,
783 vm.tx.value(),
784 vm.env.gas_limit,
785 vm.tx.data(),
786 );
787
788 #[cfg(feature = "debug")]
789 {
790 // Enable debug mode for printing in Solidity contracts.
791 vm.debug_mode.enabled = true;
792 }
793
794 Ok(vm)
795 }
796
797 fn add_hook(&mut self, hook: impl Hook + 'static) {
798 // Keep `preserve_top_level_backup` in sync: a hook added after construction (e.g. the
799 // `BackupHook` in `stateless_execute`) may read the top-level backup in `finalize_execution`.
800 self.preserve_top_level_backup |= hook.reads_top_level_backup();
801 self.hooks.push(Rc::new(RefCell::new(hook)));
802 }
803
804 /// EIP-8037: Charge state gas, drawing from reservoir first, spilling to gas_remaining if exhausted.
805 ///
806 /// Must only be called for Amsterdam+ forks. All call sites must guard with
807 /// `fork >= Fork::Amsterdam` before invoking this method.
808 #[expect(
809 clippy::arithmetic_side_effects,
810 reason = "arithmetic proven safe by min()"
811 )]
812 pub fn increase_state_gas(&mut self, gas: u64) -> Result<(), VMError> {
813 debug_assert!(
814 self.env.config.fork >= Fork::Amsterdam,
815 "increase_state_gas called pre-Amsterdam"
816 );
817 // Draw from reservoir first; only spill to gas_remaining if reservoir exhausted
818 let from_reservoir = self.state_gas_reservoir.min(gas);
819 // Safe: from_reservoir <= gas
820 let spill = gas - from_reservoir;
821 if spill > 0 {
822 // Charge spill from gas_remaining first — if OOG, return early
823 // without mutating reservoir or state_gas_used (matches EELS behavior)
824 self.current_call_frame.increase_consumed_gas(spill)?;
825 }
826 // Safe: from_reservoir = min(reservoir, gas) so reservoir >= from_reservoir
827 self.state_gas_reservoir -= from_reservoir;
828 // Only increment state_gas_used AFTER the charge succeeds.
829 // state_gas_used is i64; tx gas_limit caps charges well below i64::MAX.
830 self.state_gas_used = self
831 .state_gas_used
832 .checked_add(i64::try_from(gas).map_err(|_| InternalError::Overflow)?)
833 .ok_or(InternalError::Overflow)?;
834 // Track the spill for block-accounting: EELS charge_state_gas spills
835 // don't count toward regular_gas_used for the regular dimension.
836 self.state_gas_spill = self
837 .state_gas_spill
838 .checked_add(spill)
839 .ok_or(InternalError::Overflow)?;
840 // Per-frame spill: EELS charge_state_gas does `frame_state_gas_spilled += remainder`.
841 // LIFO refund source; propagated to parent on child success.
842 self.current_call_frame.frame_state_gas_spilled = self
843 .current_call_frame
844 .frame_state_gas_spilled
845 .checked_add(spill)
846 .ok_or(InternalError::Overflow)?;
847 Ok(())
848 }
849
850 /// EIP-8037 `credit_state_gas_refund`: refund `amount` LIFO, mirroring EELS. The portion
851 /// spilled past the reservoir into this frame's `gas_remaining` (`frame_state_gas_spilled`)
852 /// is returned to `gas_remaining` first; only the remainder flows to the shared reservoir.
853 /// `state_gas_used` drops by the full `amount` (may go negative when the matching charge lives
854 /// in an ancestor frame). Block accounting: both spill counters drop by the
855 /// `gas_remaining`-credited portion only, never the full `amount`. Amsterdam+ only.
856 #[expect(
857 clippy::arithmetic_side_effects,
858 reason = "subtractions proven safe by min()"
859 )]
860 pub fn credit_state_gas_refund(&mut self, amount: u64) -> Result<(), VMError> {
861 debug_assert!(
862 self.env.config.fork >= Fork::Amsterdam,
863 "credit_state_gas_refund called pre-Amsterdam"
864 );
865 // LIFO: drain the frame's spill (gas borrowed from gas_remaining) first.
866 let from_gas_left = self.current_call_frame.frame_state_gas_spilled.min(amount);
867 // Return the spilled portion to gas_remaining (i64).
868 self.current_call_frame.gas_remaining = self
869 .current_call_frame
870 .gas_remaining
871 .checked_add(i64::try_from(from_gas_left).map_err(|_| InternalError::Overflow)?)
872 .ok_or(InternalError::Overflow)?;
873 // Safe: from_gas_left = min(spill, amount) <= frame_state_gas_spilled.
874 self.current_call_frame.frame_state_gas_spilled -= from_gas_left;
875 // Block accounting: the refilled spill is no longer regular gas.
876 self.state_gas_spill = self
877 .state_gas_spill
878 .checked_sub(from_gas_left)
879 .ok_or(InternalError::Underflow)?;
880 // The remainder of the refund flows into the shared reservoir.
881 // Safe: from_gas_left = min(spill, amount) <= amount.
882 let to_reservoir = amount - from_gas_left;
883 self.state_gas_reservoir = self
884 .state_gas_reservoir
885 .checked_add(to_reservoir)
886 .ok_or(InternalError::Overflow)?;
887 // state_gas_used always drops by the full amount (may go negative).
888 self.state_gas_used = self
889 .state_gas_used
890 .checked_sub(i64::try_from(amount).map_err(|_| InternalError::Overflow)?)
891 .ok_or(InternalError::Overflow)?;
892 Ok(())
893 }
894
895 /// Refund the EIP-8037 new-account state gas when `charged` is true. Used by the
896 /// CALL paths where a value-bearing call to an empty account charged the new-account
897 /// state gas but no account ends up created (insufficient balance, max depth, child
898 /// revert / failed precompile).
899 #[inline]
900 pub fn refund_new_account_state_gas(&mut self, charged: bool) -> Result<(), VMError> {
901 if charged {
902 self.credit_state_gas_refund(self.state_gas_new_account)?;
903 }
904 Ok(())
905 }
906
907 /// EIP-8037 `refill_frame_state_gas`: roll back this frame's state gas in LIFO
908 /// order on revert or exceptional halt, mirroring EELS `refill_frame_state_gas`.
909 ///
910 /// `entry` is the value of `state_gas_used` when this frame began executing
911 /// (`current_call_frame.state_gas_used_at_entry`). The frame's net charge is
912 /// `frame_used = state_gas_used - entry`. Of that, `frame_state_gas_spilled` was
913 /// drawn from `gas_remaining` (spilled past the reservoir) and the remainder came
914 /// from the reservoir. LIFO refill returns the spilled portion to `gas_remaining`
915 /// first and the rest to the reservoir, restoring the exact pools the charges drew
916 /// from. `state_gas_used` is rolled back to `entry` and the per-frame spill counter
917 /// is cleared.
918 ///
919 /// Revert-vs-halt equivalence (load-bearing): on revert, the spilled gas returns to
920 /// `gas_remaining` (raising the sender refund / lowering raw_consumed) while
921 /// `state_gas_spill` drops by the same amount, so the regular dimension in
922 /// `refund_sender` (default_hook) drops by exactly the refilled spill. On exceptional
923 /// halt the caller subsequently sets `gas_remaining = 0` and burns it to the regular
924 /// dimension — but `state_gas_spill` was already decremented here, so the spilled gas
925 /// stays counted as regular. Both paths are correct.
926 ///
927 /// Must only be called for Amsterdam+ forks.
928 pub fn refill_frame_state_gas(&mut self, entry: i64) -> Result<(), VMError> {
929 debug_assert!(
930 self.env.config.fork >= Fork::Amsterdam,
931 "refill_frame_state_gas called pre-Amsterdam"
932 );
933 // The frame's net state-gas charge since it began executing. May be
934 // negative when the frame's inline refunds (e.g. an SSTORE clearing a
935 // slot an ancestor set) exceeded its own gross charges.
936 let frame_used = self
937 .state_gas_used
938 .checked_sub(entry)
939 .ok_or(InternalError::Underflow)?;
940 let spilled = self.current_call_frame.frame_state_gas_spilled;
941 // LIFO invariant: any remaining spill is undrained own-charge, so it
942 // implies frame_used >= 0. A net-negative frame_used only arises after
943 // credit_state_gas_refund has already drained all spill (spilled == 0).
944 debug_assert!(
945 frame_used >= 0 || spilled == 0,
946 "negative frame_used with positive spill violates LIFO invariant \
947 (frame_used={frame_used}, spilled={spilled})"
948 );
949 // LIFO: return the spilled portion (borrowed from gas_remaining) first.
950 self.current_call_frame.gas_remaining = self
951 .current_call_frame
952 .gas_remaining
953 .checked_add(i64::try_from(spilled).map_err(|_| InternalError::Overflow)?)
954 .ok_or(InternalError::Overflow)?;
955 // The remainder (drawn from the reservoir) flows back to the reservoir.
956 let to_reservoir = frame_used
957 .checked_sub(i64::try_from(spilled).map_err(|_| InternalError::Overflow)?)
958 .ok_or(InternalError::Overflow)?;
959 // `to_reservoir` is negative in the cross-ancestor refund case
960 // (frame_used < 0); clamp so the reservoir never goes negative.
961 let reservoir_signed =
962 i64::try_from(self.state_gas_reservoir).map_err(|_| InternalError::Overflow)?;
963 self.state_gas_reservoir = u64::try_from(
964 reservoir_signed
965 .checked_add(to_reservoir)
966 .ok_or(InternalError::Overflow)?
967 .max(0),
968 )
969 .map_err(|_| InternalError::Overflow)?;
970 // Roll back state_gas_used to the frame's entry baseline.
971 self.state_gas_used = entry;
972 // Block accounting: the refilled spill is no longer regular gas.
973 self.state_gas_spill = self
974 .state_gas_spill
975 .checked_sub(spilled)
976 .ok_or(InternalError::Underflow)?;
977 self.current_call_frame.frame_state_gas_spilled = 0;
978 Ok(())
979 }
980
981 /// Executes a whole external transaction. Performing validations at the beginning.
982 pub fn execute(&mut self) -> Result<ExecutionReport, VMError> {
983 if let Err(e) = self.prepare_execution() {
984 // Restore cache to state previous to this Tx execution because this Tx is invalid.
985 // Consume the backup unless a `BackupHook` will read it (L2 / stateless); on L1 it
986 // is dead once the cache is restored.
987 if self.preserve_top_level_backup {
988 self.restore_cache_state()?;
989 } else {
990 self.restore_cache_state_consuming()?;
991 }
992 return Err(e);
993 }
994
995 // Clear callframe backup so that changes made in prepare_execution are written in stone.
996 // We want to apply these changes even if the Tx reverts. E.g. Incrementing sender nonce
997 self.current_call_frame.call_frame_backup.clear();
998
999 // Empty bytecode would only execute STOP; skip the dispatch loop.
1000 // The BAL checkpoint below is intentionally skipped: a codeless transfer cannot
1001 // fail past this point and has no inner calls, so there's nothing to roll back.
1002 if self.is_simple_transfer_fast_path() {
1003 // EIP-8037: no `refill_frame_state_gas` needed here — a codeless transfer always
1004 // succeeds, runs no opcodes, and charges no execution state gas, so the frame's
1005 // `frame_state_gas_spilled` is 0 and `state_gas_used` equals its entry baseline.
1006 #[expect(clippy::as_conversions, reason = "gas_remaining is non-negative here")]
1007 let gas_used = self
1008 .current_call_frame
1009 .gas_limit
1010 .checked_sub(self.current_call_frame.gas_remaining as u64)
1011 .ok_or(InternalError::Underflow)?;
1012 let context_result = ContextResult {
1013 result: TxResult::Success,
1014 gas_used,
1015 gas_spent: gas_used,
1016 output: Bytes::new(),
1017 };
1018 return self.finalize_execution(context_result);
1019 }
1020
1021 // EIP-7928: Take a BAL checkpoint AFTER clearing the backup. This captures the state
1022 // after prepare_execution (nonce increment, etc.) but before actual execution.
1023 // When the top-level call fails, we restore to this checkpoint so that inner call
1024 // state changes (like value transfers) are reverted from the BAL.
1025 self.current_call_frame.call_frame_backup.bal_checkpoint =
1026 self.db.bal_recorder.as_ref().map(|r| r.checkpoint());
1027
1028 if self.is_create()? {
1029 // Create contract, reverting the Tx if address is already occupied.
1030 if let Some(context_result) = self.handle_create_transaction()? {
1031 let report = self.finalize_execution(context_result)?;
1032 return Ok(report);
1033 }
1034 }
1035
1036 self.substate.push_backup();
1037 let context_result = self.run_execution()?;
1038
1039 let report = self.finalize_execution(context_result)?;
1040
1041 Ok(report)
1042 }
1043
1044 /// Must run after `prepare_execution` so EIP-7702 delegation is already resolved into
1045 /// `bytecode`.
1046 #[inline(always)]
1047 fn is_simple_transfer_fast_path(&self) -> bool {
1048 !self.current_call_frame.is_create
1049 && self.current_call_frame.bytecode.is_empty()
1050 // A pending EIP-2780 top-frame charge must be applied via run_execution.
1051 && self.pending_top_frame_state_gas == 0
1052 && self.pending_top_frame_regular_gas == 0
1053 // Privileged L2 txs can leave gas negative; let the slow path surface that as OOG.
1054 && self.current_call_frame.gas_remaining >= 0
1055 && self.tx.authorization_list().is_none()
1056 // Precompiles dispatch via run_execution even with empty bytecode.
1057 && !precompiles::is_precompile(
1058 &self.current_call_frame.to,
1059 self.env.config.fork,
1060 self.vm_type,
1061 )
1062 }
1063
1064 /// Main execution loop.
1065 pub fn run_execution(&mut self) -> Result<ContextResult, VMError> {
1066 // If gas is already exhausted (negative), fail immediately.
1067 // This can happen when intrinsic gas exceeds the gas limit in privileged L2 transactions.
1068 // Without this check, casting negative gas_remaining to u64 would wrap to a huge value.
1069 if self.current_call_frame.gas_remaining < 0 {
1070 return Ok(ContextResult {
1071 result: TxResult::Revert(ExceptionalHalt::OutOfGas.into()),
1072 gas_used: self.current_call_frame.gas_limit,
1073 gas_spent: self.current_call_frame.gas_limit,
1074 output: Bytes::new(),
1075 });
1076 }
1077
1078 // A pending top-frame NEW_ACCOUNT charge means the recipient was an EIP-161-empty
1079 // account receiving value. If the recipient is a precompile that then exceptionally
1080 // halts/reverts, the account is never materialized, so the charge is rolled back in
1081 // the precompile branch below (mirrors EELS `refill_frame_state_gas`).
1082 let top_frame_new_account_charged = self.pending_top_frame_state_gas > 0;
1083
1084 // EIP-2780 top-frame new-account state charge (deferred from prepare_execution):
1085 // charged from the state-gas reservoir at the top of the frame, mirroring EELS
1086 // `process_message`. If it cannot be covered the tx reverts (consuming all gas),
1087 // rather than being rejected as an invalid transaction.
1088 if self.pending_top_frame_state_gas > 0 || self.pending_top_frame_regular_gas > 0 {
1089 let pending_state = std::mem::take(&mut self.pending_top_frame_state_gas);
1090 let pending_regular = std::mem::take(&mut self.pending_top_frame_regular_gas);
1091 // State charge first, then the 7702-delegation regular cold-access (EELS order).
1092 let charged = (pending_state == 0 || self.increase_state_gas(pending_state).is_ok())
1093 && (pending_regular == 0
1094 || self
1095 .current_call_frame
1096 .increase_consumed_gas(pending_regular)
1097 .is_ok());
1098 if !charged {
1099 return Ok(ContextResult {
1100 result: TxResult::Revert(ExceptionalHalt::OutOfGas.into()),
1101 gas_used: self.current_call_frame.gas_limit,
1102 gas_spent: self.current_call_frame.gas_limit,
1103 output: Bytes::new(),
1104 });
1105 }
1106 }
1107
1108 #[expect(clippy::as_conversions, reason = "remaining gas conversion")]
1109 if precompiles::is_precompile(
1110 &self.current_call_frame.to,
1111 self.env.config.fork,
1112 self.vm_type,
1113 ) {
1114 // `execute_precompile` itself never touches state gas (it only mutates
1115 // `gas_remaining`; it has no access to `state_gas_used` / `state_gas_reservoir` /
1116 // `state_gas_spill`) — the assert below guards that. The EIP-2780 top-frame
1117 // NEW_ACCOUNT charge applied above, however, IS frame state gas, and on an
1118 // exceptional halt/revert it must be rolled back (see below). `self` is borrowed
1119 // by field rather than via `&mut self.current_call_frame` so the refund call,
1120 // which needs `&mut self`, can run after `execute_precompile`.
1121 let state_gas_used_before_precompile = self.state_gas_used;
1122 let code_address = self.current_call_frame.code_address;
1123 let precompile_gas_limit = self.current_call_frame.gas_limit;
1124 let mut gas_remaining = self.current_call_frame.gas_remaining as u64;
1125 let result = Self::execute_precompile(
1126 code_address,
1127 &self.current_call_frame.calldata,
1128 precompile_gas_limit,
1129 &mut gas_remaining,
1130 self.env.config.fork,
1131 self.db.store.precompile_cache(),
1132 self.crypto,
1133 );
1134
1135 debug_assert_eq!(
1136 self.state_gas_used, state_gas_used_before_precompile,
1137 "precompile execution must not mutate state_gas_used"
1138 );
1139
1140 // EIP-8037 Amsterdam 2D accounting recomputes `block_gas_used` from
1141 // `raw_consumed = gas_limit - gas_remaining` inside `refund_sender`. On a
1142 // top-level precompile exceptional halt, `handle_precompile_result` already
1143 // sets `ContextResult.gas_used = gas_limit`, but `gas_remaining` retains the
1144 // untouched forwarded amount — under Amsterdam that would make the block
1145 // report only the intrinsic portion. Zero it so the block matches the
1146 // `gas_used = gas_limit` contract from `handle_precompile_result`, and roll
1147 // back the top-frame NEW_ACCOUNT charge (the recipient is never materialized
1148 // on halt) so the burned gas counts entirely as regular gas, matching EELS
1149 // `refill_frame_state_gas`. Pre-Amsterdam reads `ctx_result.gas_used` directly
1150 // and is unaffected by this path either way.
1151 if self.env.config.fork >= Fork::Amsterdam
1152 && let Ok(ctx) = &result
1153 && !ctx.is_success()
1154 {
1155 gas_remaining = 0;
1156 self.refund_new_account_state_gas(top_frame_new_account_charged)?;
1157 }
1158
1159 self.current_call_frame.gas_remaining = gas_remaining as i64;
1160
1161 return result;
1162 }
1163
1164 // Specialize the dispatch loop on whether a struct-log tracer is active.
1165 // The `!TRACED` variant compiles out every tracer branch and capture call,
1166 // leaving a minimal hot loop (the common, non-traced case).
1167 if self.opcode_tracer.active {
1168 self.run_dispatch::<true>()
1169 } else {
1170 self.run_dispatch::<false>()
1171 }
1172 }
1173
1174 /// Opcode dispatch loop, monomorphized over whether a struct-log tracer is
1175 /// active. With `TRACED = false` the compiler eliminates the tracer branches
1176 /// and the cold `trace_*_step` calls entirely, so the hot loop body stays
1177 /// minimal; the traced variant keeps the cold helpers out of line.
1178 fn run_dispatch<const TRACED: bool>(&mut self) -> Result<ContextResult, VMError> {
1179 let mut error = OnceCell::<VMError>::new();
1180
1181 #[cfg(feature = "perf_opcode_timings")]
1182 let mut timings = crate::timings::OPCODE_TIMINGS.lock().expect("poison");
1183
1184 // Copy the `&'static` table pointer once; it doesn't borrow `self`, so dispatch can still
1185 // pass `self` mutably to the handler without reloading the pointer each iteration.
1186 let opcode_table = self.opcode_table;
1187
1188 loop {
1189 // Capture pc BEFORE advance_pc() — this is the address of the current opcode.
1190 let pc_of_current_op = self.current_call_frame.pc;
1191 let opcode = self.current_call_frame.next_opcode();
1192 self.advance_pc();
1193
1194 // Struct-log pre-step capture (compiled out entirely when !TRACED).
1195 let gas_before_op = if TRACED {
1196 self.trace_pre_step(opcode, pc_of_current_op)
1197 } else {
1198 0
1199 };
1200
1201 #[cfg(feature = "perf_opcode_timings")]
1202 let opcode_time_start = std::time::Instant::now();
1203
1204 #[allow(clippy::indexing_slicing, clippy::as_conversions)]
1205 let op_result = opcode_table[opcode as usize].call(self, &mut error);
1206
1207 #[cfg(feature = "perf_opcode_timings")]
1208 {
1209 let time = opcode_time_start.elapsed();
1210 timings.update(opcode, time);
1211 }
1212
1213 // Struct-log post-step (compiled out entirely when !TRACED).
1214 if TRACED {
1215 self.trace_post_step(gas_before_op, &error);
1216 }
1217
1218 let result = match op_result {
1219 OpcodeResult::Continue => continue,
1220 OpcodeResult::Halt => match error.take() {
1221 None => self.handle_opcode_result()?,
1222 Some(error) => self.handle_opcode_error(error)?,
1223 },
1224 };
1225
1226 // Return the ExecutionReport if the executed callframe was the first one.
1227 if self.is_initial_call_frame() {
1228 // Consume the backup (move it out) unless a `BackupHook` will read it afterward
1229 // to build the tx-level undo snapshot (L2 / stateless). On L1 nothing reads it
1230 // once the cache is restored, so cloning it would be dead work.
1231 self.handle_state_backup(&result, !self.preserve_top_level_backup)?;
1232 return Ok(result);
1233 }
1234
1235 // Handle interaction between child and parent callframe.
1236 self.handle_return(&result)?;
1237 }
1238 }
1239
1240 /// Struct-log pre-step capture, split out of the interpreter loop and kept
1241 /// cold + non-inlined so the hot dispatch loop stays small (this code is
1242 /// only reached when a struct-log tracer is active). Returns `gas_before`.
1243 #[cold]
1244 #[inline(never)]
1245 fn trace_pre_step(&mut self, opcode: u8, pc_of_current_op: usize) -> u64 {
1246 #[expect(
1247 clippy::as_conversions,
1248 reason = "gas_remaining is i64; clamp to 0 before converting to u64"
1249 )]
1250 let gas_before = self.current_call_frame.gas_remaining.max(0) as u64;
1251 #[expect(
1252 clippy::as_conversions,
1253 reason = "call depth bounded by STACK_LIMIT=1024, fits in u32"
1254 )]
1255 let depth = (self.call_frames.len() as u32).saturating_add(1);
1256 let refund = self.substate.refunded_gas;
1257 let stack_view = self.collect_stack_for_trace();
1258 let mem_view = self.collect_memory_for_trace();
1259 // mem_size always reflects actual memory size, regardless of enable_memory.
1260 #[expect(
1261 clippy::as_conversions,
1262 reason = "memory size is bounded by gas; fits in u64"
1263 )]
1264 let mem_size_for_trace = self.current_call_frame.memory.len() as u64;
1265 let storage_kv = self.read_storage_for_trace(opcode);
1266 let return_data = if self.opcode_tracer.cfg.enable_return_data {
1267 self.current_call_frame.sub_return_data.clone()
1268 } else {
1269 Bytes::new()
1270 };
1271 #[expect(
1272 clippy::as_conversions,
1273 reason = "pc is usize, fits in u64 on supported targets"
1274 )]
1275 let pc_u64 = pc_of_current_op as u64;
1276 self.opcode_tracer.pre_step_capture(
1277 pc_u64,
1278 opcode,
1279 gas_before,
1280 depth,
1281 refund,
1282 &stack_view,
1283 &mem_view,
1284 mem_size_for_trace,
1285 &return_data,
1286 storage_kv,
1287 );
1288 gas_before
1289 }
1290
1291 /// Struct-log post-step: patch gas_cost, refund-after-op, and error into the
1292 /// buffered entry. Cold + non-inlined for the same reason as `trace_pre_step`.
1293 #[cold]
1294 #[inline(never)]
1295 fn trace_post_step(&mut self, gas_before_op: u64, error: &OnceCell<VMError>) {
1296 #[expect(
1297 clippy::as_conversions,
1298 reason = "gas_remaining is i64; clamp to 0 before converting to u64"
1299 )]
1300 let gas_after = self.current_call_frame.gas_remaining.max(0) as u64;
1301 // Prefer the explicit opcode-overhead cost written by CALL/CREATE handlers;
1302 // fall back to the gas diff for all other opcodes.
1303 let gas_cost = self
1304 .opcode_tracer
1305 .last_opcode_gas_cost
1306 .take()
1307 .unwrap_or_else(|| gas_before_op.saturating_sub(gas_after));
1308 // refund-after-op matches geth's structLogger timing: for SSTORE and
1309 // (pre-London) SELFDESTRUCT, the refund counter shown is the value
1310 // *after* the opcode's accounting applied.
1311 let refund_after = self.substate.refunded_gas;
1312 let err_str = error.get().map(|e| e.to_string());
1313 self.opcode_tracer
1314 .finalize_step(gas_cost, refund_after, err_str.as_deref());
1315 }
1316
1317 /// Executes precompile and handles the output that it returns, generating a report.
1318 pub fn execute_precompile(
1319 code_address: H160,
1320 calldata: &Bytes,
1321 gas_limit: u64,
1322 gas_remaining: &mut u64,
1323 fork: Fork,
1324 cache: Option<&precompiles::PrecompileCache>,
1325 crypto: &dyn Crypto,
1326 ) -> Result<ContextResult, VMError> {
1327 Self::handle_precompile_result(
1328 precompiles::execute_precompile(
1329 code_address,
1330 calldata,
1331 gas_remaining,
1332 fork,
1333 cache,
1334 crypto,
1335 ),
1336 gas_limit,
1337 *gas_remaining,
1338 )
1339 }
1340
1341 /// True if external transaction is a contract creation
1342 pub fn is_create(&self) -> Result<bool, InternalError> {
1343 Ok(self.current_call_frame.is_create)
1344 }
1345
1346 /// Executes without making changes to the cache.
1347 pub fn stateless_execute(&mut self) -> Result<ExecutionReport, VMError> {
1348 // Add backup hook to restore state after execution. `add_hook` flips
1349 // `preserve_top_level_backup` on via `Hook::reads_top_level_backup`, so the backup is
1350 // cloned (not moved out) on the revert paths even though this VM was built with L1 `vm_type`.
1351 self.add_hook(BackupHook::default());
1352 let report = self.execute()?;
1353 // Restore cache to the state before execution.
1354 self.db.undo_last_transaction()?;
1355 Ok(report)
1356 }
1357
1358 fn prepare_execution(&mut self) -> Result<(), VMError> {
1359 // Clone each hook's `Rc` (cheap refcount bump) so the borrow on `self.hooks` is released
1360 // and `self` can be passed mutably — without `self.hooks.clone()`'s per-tx `Vec` realloc.
1361 // `self.hooks` is not mutated during the loop, so `get(i)` is always `Some` in range.
1362 for i in 0..self.hooks.len() {
1363 if let Some(hook) = self.hooks.get(i).map(Rc::clone) {
1364 hook.borrow_mut().prepare_execution(self)?;
1365 }
1366 }
1367
1368 Ok(())
1369 }
1370
1371 fn finalize_execution(
1372 &mut self,
1373 mut ctx_result: ContextResult,
1374 ) -> Result<ExecutionReport, VMError> {
1375 // EIP-8037: On top-level tx failure (REVERT, ExceptionalHalt, or OOG), the
1376 // execution portion of state gas has already been refilled into the reservoir by
1377 // the top-frame `refill_frame_state_gas` (seeded at the post-intrinsic baseline in
1378 // `add_intrinsic_gas` and fired on revert/halt in `handle_opcode_error` /
1379 // `handle_opcode_result`). The intrinsic portion stays in `state_gas_used` so block
1380 // accounting bills it. No reservoir-move is performed here. Collision returns before
1381 // any execution state gas is charged, so it has nothing to refill (see the create
1382 // collision branch in `handle_create_transaction`).
1383 //
1384 // EIP-8037 (#3002): the create-tx NEW_ACCOUNT refund fires for every top-level
1385 // CREATE-tx failure (revert / halt / OOG / collision), AND on success when the
1386 // target was already alive (`created_target_alive`) — no new account leaf created.
1387 // EELS reference: fork.py::process_transaction:
1388 // if isinstance(tx.to, Bytes0) and (
1389 // tx_output.error is not None or tx_output.created_target_alive
1390 // ):
1391 // new_account_refund = STATE_BYTES_PER_NEW_ACCOUNT * COST_PER_STATE_BYTE
1392 // tx_output.state_gas_left += new_account_refund
1393 // tx_output.state_refund += new_account_refund
1394 // The `created_target_alive` term only ever holds on the success path: on
1395 // collision `handle_create_transaction` returns before setting it, so the
1396 // collision refund still fires exactly once via `!is_success`.
1397 if self.env.config.fork >= Fork::Amsterdam
1398 && self.is_create()?
1399 && (!ctx_result.is_success() || self.created_target_alive)
1400 {
1401 let new_account_refund = self.state_gas_new_account;
1402 self.state_gas_reservoir = self
1403 .state_gas_reservoir
1404 .checked_add(new_account_refund)
1405 .ok_or(InternalError::Overflow)?;
1406 self.state_refund = self
1407 .state_refund
1408 .checked_add(new_account_refund)
1409 .ok_or(InternalError::Overflow)?;
1410 }
1411
1412 // See `prepare_execution`: per-hook `Rc::clone` avoids the `self.hooks.clone()` realloc.
1413 for i in 0..self.hooks.len() {
1414 if let Some(hook) = self.hooks.get(i).map(Rc::clone) {
1415 hook.borrow_mut()
1416 .finalize_execution(self, &mut ctx_result)?;
1417 }
1418 }
1419
1420 self.tracer.exit_context(&ctx_result, true)?;
1421
1422 // Struct-log end-of-tx capture: record final output, gas used, and revert error.
1423 // gas matches geth's `executionResult.Gas` which is post-refund (`receipt.GasUsed`).
1424 if self.opcode_tracer.active {
1425 self.opcode_tracer.output = ctx_result.output.clone();
1426 self.opcode_tracer.gas_used = ctx_result.gas_spent;
1427 self.opcode_tracer.error = match ctx_result.result {
1428 TxResult::Revert(ref err) => Some(err.to_string()),
1429 _ => None,
1430 };
1431 }
1432
1433 // Only include logs if transaction succeeded. When a transaction reverts,
1434 // no logs should be emitted (including EIP-7708 Transfer logs).
1435 let logs = if ctx_result.is_success() {
1436 self.substate.extract_logs()
1437 } else {
1438 Vec::new()
1439 };
1440
1441 // EIP-8037: `state_gas_used` is already net (signed; credits
1442 // decrement it inline). Subtract `state_refund` (EIP-7702 tx-level channel) and
1443 // clamp at zero for block accounting — `state_gas_used` may be negative when inline
1444 // refunds exceed gross charges.
1445 let state_refund_signed =
1446 i64::try_from(self.state_refund).map_err(|_| InternalError::Overflow)?;
1447 let net_state_gas_used: u64 = u64::try_from(
1448 self.state_gas_used
1449 .saturating_sub(state_refund_signed)
1450 .max(0),
1451 )
1452 .map_err(|_| InternalError::Overflow)?;
1453
1454 let report = ExecutionReport {
1455 result: ctx_result.result.clone(),
1456 gas_used: ctx_result.gas_used,
1457 gas_spent: ctx_result.gas_spent,
1458 gas_refunded: self.substate.refunded_gas,
1459 state_gas_used: net_state_gas_used,
1460 output: std::mem::take(&mut ctx_result.output),
1461 logs,
1462 };
1463
1464 Ok(report)
1465 }
1466
1467 // ── Struct-log helper methods ─────────────────────────────────────────────
1468
1469 /// Collects the current stack in bottom-first order for struct-log emission.
1470 ///
1471 /// LEVM stack is top-first in memory (`values[offset]` = top), so we reverse
1472 /// the active slice to produce the bottom-first wire format geth uses.
1473 /// Returns an empty `Vec` when `cfg.disable_stack` is true.
1474 pub fn collect_stack_for_trace(&self) -> Vec<U256> {
1475 use crate::constants::STACK_LIMIT;
1476 if self.opcode_tracer.cfg.disable_stack {
1477 return Vec::new();
1478 }
1479 let s = &self.current_call_frame.stack;
1480 // offset <= STACK_LIMIT by stack invariant.
1481 s.values
1482 .get(s.offset..STACK_LIMIT)
1483 .map(|slice| slice.iter().rev().copied().collect())
1484 .unwrap_or_default()
1485 }
1486
1487 /// Collects the live memory bytes for the current frame.
1488 ///
1489 /// Returns an empty `Vec` when `cfg.enable_memory` is false or memory is empty.
1490 pub fn collect_memory_for_trace(&self) -> Vec<u8> {
1491 if !self.opcode_tracer.cfg.enable_memory {
1492 return Vec::new();
1493 }
1494 self.current_call_frame.memory.live_bytes()
1495 }
1496
1497 /// Pre-reads the storage key/value for the current SLOAD or SSTORE opcode.
1498 ///
1499 /// Returns `None` when:
1500 /// - `cfg.disable_storage` is set, or
1501 /// - `opcode` is not SLOAD (0x54) or SSTORE (0x55), or
1502 /// - the stack is empty (guard against underflow before the handler runs), or
1503 /// - the storage read fails for any reason (including `AccountNotFound` —
1504 /// the trace omits the entry rather than emitting an ambiguous zero).
1505 ///
1506 /// For SLOAD: key = `stack.top`; value = the *current* stored value read from the DB.
1507 /// For SSTORE: key = `stack.top`, value = `stack[top-1]` (the new value being written).
1508 pub fn read_storage_for_trace(&mut self, opcode: u8) -> Option<(H256, H256)> {
1509 const SLOAD: u8 = 0x54;
1510 const SSTORE: u8 = 0x55;
1511
1512 if self.opcode_tracer.cfg.disable_storage {
1513 return None;
1514 }
1515 if opcode != SLOAD && opcode != SSTORE {
1516 return None;
1517 }
1518
1519 // Need at least one element on stack for SLOAD, two for SSTORE.
1520 use crate::constants::STACK_LIMIT;
1521 let offset = self.current_call_frame.stack.offset;
1522 if offset >= STACK_LIMIT {
1523 return None; // stack empty
1524 }
1525
1526 // SLOAD/SSTORE operate on the call's storage context (`to`), not the code's
1527 // address. Under DELEGATECALL/CALLCODE these differ.
1528 let addr = self.current_call_frame.to;
1529
1530 let stack_values = &self.current_call_frame.stack.values;
1531 let key_u256 = *stack_values.get(offset)?;
1532 let key = BigEndianHash::from_uint(&key_u256);
1533
1534 if opcode == SLOAD {
1535 // Omit the entry on any read failure (incl. account not yet cached);
1536 // a zero value would be indistinguishable from a legitimate never-written slot.
1537 let v = self.get_storage_value(addr, key).ok()?;
1538 let value = BigEndianHash::from_uint(&v);
1539 Some((key, value))
1540 } else {
1541 // SSTORE: need two stack elements.
1542 let next_offset = offset.checked_add(1)?;
1543 if next_offset >= STACK_LIMIT {
1544 return None;
1545 }
1546 // values[offset+1] is the new value being written (second from top = stack[top-1]).
1547 let value_u256 = *self.current_call_frame.stack.values.get(next_offset)?;
1548 let value = BigEndianHash::from_uint(&value_u256);
1549 Some((key, value))
1550 }
1551 }
1552}
1553
1554impl Substate {
1555 /// Initializes the VM substate, mainly adding addresses to the "accessed_addresses" field and the same with storage slots
1556 pub fn initialize(env: &Environment, tx: &Transaction) -> Result<Substate, VMError> {
1557 let fork = env.config.fork;
1558
1559 // Add sender and recipient to accessed accounts [https://www.evm.codes/about#access_list]
1560 // Precompiles are NO LONGER inserted here — they are warm by construction (see
1561 // `is_warm_precompile`), removing the ~20-entry floor that used to dominate this set. The
1562 // remaining working set is small (sender + coinbase + recipient + access-list/touched
1563 // addresses; real p99 ~7), so a capacity of 8 covers most txs with little waste.
1564 let mut initial_accessed_addresses =
1565 FxHashSet::with_capacity_and_hasher(8, Default::default());
1566 // Storage slots are ~98% empty (p95 0, p99 4), so `default()` (alloc-free until first
1567 // insert) beats pre-sizing, which would tax the common empty case.
1568 let mut initial_accessed_storage_slots: FxHashMap<Address, FxHashSet<H256>> =
1569 FxHashMap::default();
1570
1571 // Add Tx sender to accessed accounts
1572 initial_accessed_addresses.insert(env.origin);
1573
1574 // [EIP-3651] - Add coinbase to accessed accounts after Shanghai
1575 if fork >= Fork::Shanghai {
1576 initial_accessed_addresses.insert(env.coinbase);
1577 }
1578
1579 // Add access lists contents to accessed accounts and accessed storage slots.
1580 // Iterate by reference (`Address`/`H256` are `Copy`); the old `.clone()` deep-copied
1581 // the whole `Vec<(Address, Vec<H256>)>` per tx just to read it.
1582 for (address, keys) in tx.access_list() {
1583 initial_accessed_addresses.insert(*address);
1584 // 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
1585 let warm_slots = initial_accessed_storage_slots.entry(*address).or_default();
1586 for slot in keys {
1587 warm_slots.insert(*slot);
1588 }
1589 }
1590
1591 let substate = Substate::from_accesses(
1592 fork,
1593 initial_accessed_addresses,
1594 initial_accessed_storage_slots,
1595 );
1596
1597 Ok(substate)
1598 }
1599}
1600
1601// Test-support surface for the EIP-8037 state-gas reservoir/clamp-spill unit tests, which live
1602// in the `ethrex-test` crate (`test/tests/levm/eip8037_reservoir_tests.rs`) per the repo's
1603// test-location convention but must drive crate-private VM internals. Everything here is
1604// `#[doc(hidden)]` and exposes only what those tests touch: a fixture-free VM harness plus a
1605// handful of reservoir accessors. The harness builds the VM via struct literal to sidestep
1606// `VM::new`'s DB reads (which would pull `ethrex-storage`/`ethrex-blockchain` into levm and form
1607// a dependency cycle), keeping the two-pool arithmetic isolated.
1608#[doc(hidden)]
1609impl<'a> VM<'a> {
1610 /// Gas budget seeded into the harness top frame; large enough that spills never run it OOG.
1611 pub const STATE_GAS_HARNESS_FRAME_GAS: u64 = 1_000_000;
1612
1613 /// Builds a fixture-free VM on `fork` with a single top frame and the given starting
1614 /// `state_gas_reservoir`. `db`/`tx`/`crypto` are borrowed for the VM's lifetime but never
1615 /// read (the frame is built directly, so no account/storage/code loads occur).
1616 pub fn new_state_gas_harness(
1617 fork: Fork,
1618 db: &'a mut GeneralizedDatabase,
1619 tx: &'a Transaction,
1620 crypto: &'a dyn Crypto,
1621 state_gas_reservoir: u64,
1622 ) -> VM<'a> {
1623 let env = Environment {
1624 config: crate::environment::EVMConfig::new(
1625 fork,
1626 crate::environment::EVMConfig::canonical_values(fork),
1627 ),
1628 gas_limit: Self::STATE_GAS_HARNESS_FRAME_GAS,
1629 block_gas_limit: Self::STATE_GAS_HARNESS_FRAME_GAS,
1630 ..Default::default()
1631 };
1632 let current_call_frame = CallFrame::new(
1633 Address::default(),
1634 Address::default(),
1635 Address::default(),
1636 Code::default(),
1637 U256::zero(),
1638 Bytes::new(),
1639 false,
1640 Self::STATE_GAS_HARNESS_FRAME_GAS,
1641 0,
1642 true,
1643 false,
1644 0,
1645 0,
1646 Stack::default(),
1647 Memory::default(),
1648 );
1649 VM {
1650 call_frames: Vec::new(),
1651 current_call_frame,
1652 env,
1653 substate: Substate::default(),
1654 db,
1655 tx,
1656 hooks: Vec::new(),
1657 storage_original_values: FxHashMap::default(),
1658 tracer: LevmCallTracer::disabled(),
1659 opcode_tracer: LevmOpcodeTracer::disabled(),
1660 debug_mode: DebugMode::disabled(),
1661 stack_pool: Vec::new(),
1662 vm_type: VMType::L1,
1663 preserve_top_level_backup: false,
1664 state_gas_used: 0,
1665 state_gas_reservoir,
1666 state_gas_reservoir_initial: state_gas_reservoir,
1667 state_gas_spill: 0,
1668 cost_per_state_byte: 0,
1669 state_gas_new_account: 0,
1670 pending_top_frame_state_gas: 0,
1671 pending_top_frame_regular_gas: 0,
1672 state_gas_storage_set: 0,
1673 state_gas_auth_total: 0,
1674 state_gas_auth_base: 0,
1675 state_refund: 0,
1676 intrinsic_state_gas: 0,
1677 created_target_alive: false,
1678 opcode_table: VM::build_opcode_table(fork),
1679 crypto,
1680 }
1681 }
1682
1683 pub fn state_gas_reservoir(&self) -> u64 {
1684 self.state_gas_reservoir
1685 }
1686 pub fn state_gas_used(&self) -> i64 {
1687 self.state_gas_used
1688 }
1689 pub fn state_gas_spill(&self) -> u64 {
1690 self.state_gas_spill
1691 }
1692 pub fn state_gas_new_account(&self) -> u64 {
1693 self.state_gas_new_account
1694 }
1695 pub fn set_state_gas_new_account(&mut self, v: u64) {
1696 self.state_gas_new_account = v;
1697 }
1698 /// Seeds the post-intrinsic baseline (mirrors `add_intrinsic_gas`): both the VM-level
1699 /// `state_gas_used` and the top frame's entry snapshot.
1700 pub fn seed_state_gas_baseline(&mut self, used: i64) {
1701 self.state_gas_used = used;
1702 self.current_call_frame.state_gas_used_at_entry = used;
1703 }
1704 pub fn frame_state_gas_used_at_entry(&self) -> i64 {
1705 self.current_call_frame.state_gas_used_at_entry
1706 }
1707 pub fn frame_gas_remaining(&self) -> i64 {
1708 self.current_call_frame.gas_remaining
1709 }
1710 pub fn set_frame_gas_remaining(&mut self, v: i64) {
1711 self.current_call_frame.gas_remaining = v;
1712 }
1713 pub fn frame_state_gas_spilled(&self) -> u64 {
1714 self.current_call_frame.frame_state_gas_spilled
1715 }
1716}