forked from lambdaclass/ethrex
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathvm.rs
More file actions
3494 lines (3225 loc) · 160 KB
/
Copy pathvm.rs
File metadata and controls
3494 lines (3225 loc) · 160 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
use crate::{
TransientStorage,
account::LevmAccount,
call_frame::{CallFrame, Stack},
db::gen_db::GeneralizedDatabase,
debug::DebugMode,
environment::Environment,
errors::{
ContextResult, ExceptionalHalt, ExecutionReport, InternalError, OpcodeResult, TxResult,
VMError,
},
gas_cost::{
STATE_BYTES_PER_AUTH_BASE, STATE_BYTES_PER_NEW_ACCOUNT, STATE_BYTES_PER_STORAGE_SET,
cost_per_state_byte as compute_cost_per_state_byte,
},
hooks::{
backup_hook::BackupHook,
hook::{Hook, get_hooks},
},
memory::Memory,
opcode_tracer::LevmOpcodeTracer,
opcodes::OpCodeFn,
precompiles::{
self, SIZE_PRECOMPILES_CANCUN, SIZE_PRECOMPILES_PRAGUE, SIZE_PRECOMPILES_PRE_CANCUN,
},
tracing::LevmCallTracer,
validation_observer::ValidationObserver,
};
use bytes::Bytes;
use ethrex_common::{
Address, BigEndianHash, H160, H256, U256,
tracing::CallType,
types::{
AccessListEntry, Code, Fork, Frame, FrameMode, Log, Transaction, TxType,
block_access_list::BlockAccessListCheckpoint, fee_config::FeeConfig,
},
};
use ethrex_crypto::Crypto;
use rustc_hash::{FxHashMap, FxHashSet};
use std::{
cell::{OnceCell, RefCell},
collections::{BTreeMap, BTreeSet},
mem,
rc::Rc,
};
/// Storage mapping from slot key to value.
pub type Storage = FxHashMap<U256, H256>;
/// Specifies whether the VM operates in L1 or L2 mode.
#[derive(Debug, Clone, Copy, Default)]
pub enum VMType {
/// Standard Ethereum L1 execution.
#[default]
L1,
/// L2 rollup execution with additional fee handling.
L2(FeeConfig),
}
/// Execution substate that tracks changes during transaction execution.
///
/// The substate maintains all information that may need to be reverted if a
/// call fails, including:
/// - Self-destructed accounts
/// - Accessed addresses and storage slots (for EIP-2929 gas accounting)
/// - Created accounts
/// - Gas refunds
/// - Transient storage (EIP-1153)
/// - Event logs
///
/// # Backup Mechanism
///
/// The substate supports checkpointing via [`push_backup`] and restoration via
/// [`revert_backup`] or commitment via [`commit_backup`]. This is used to handle
/// nested calls where inner calls may fail and need to be reverted.
///
/// Most fields are private by design. The backup mechanism only works correctly
/// if data modifications are append-only.
#[derive(Debug, Default)]
pub struct Substate {
/// Parent checkpoint for reverting on failure.
parent: Option<Box<Self>>,
/// Fork of the enclosing transaction. Lets the warmth helpers treat precompile addresses as
/// always-warm without occupying a hashset slot (EIP-2929). Constant for a tx, so it is
/// carried forward across `push_backup` checkpoints.
fork: Fork,
/// Accounts marked for self-destruction (deleted at end of transaction).
selfdestruct_set: FxHashSet<Address>,
/// Addresses accessed during execution (for EIP-2929 warm/cold gas costs).
/// Precompiles are NOT stored here; they are warm by construction (see `is_warm_precompile`).
accessed_addresses: FxHashSet<Address>,
/// Storage slots accessed per address (for EIP-2929 warm/cold gas costs).
accessed_storage_slots: FxHashMap<Address, FxHashSet<H256>>,
/// Accounts created during this transaction.
created_accounts: FxHashSet<Address>,
/// Accumulated gas refund (e.g., from storage clears).
pub refunded_gas: u64,
/// Transient storage (EIP-1153), cleared at end of transaction.
transient_storage: TransientStorage,
/// Event logs emitted during execution.
logs: Vec<Log>,
}
impl Substate {
pub fn from_accesses(
fork: Fork,
accessed_addresses: FxHashSet<Address>,
accessed_storage_slots: FxHashMap<Address, FxHashSet<H256>>,
) -> Self {
Self {
parent: None,
fork,
selfdestruct_set: FxHashSet::default(),
accessed_addresses,
accessed_storage_slots,
created_accounts: FxHashSet::default(),
refunded_gas: 0,
transient_storage: TransientStorage::default(),
logs: Vec::new(),
}
}
/// Whether `address` is a precompile that the EVM treats as warm from the start of the tx
/// (EIP-2929), exactly matching the addresses `Substate::initialize` used to pre-seed.
///
/// Replicates the pre-seed *precisely* — the contiguous range `0x01..=max_for_fork` plus the
/// post-Osaka P256VERIFY address `0x100` — and is intentionally `vm_type`-independent, since
/// the old pre-seed was too. (Using `precompiles::is_precompile`, which gates `0x100` on L2
/// for any fork, would change L2 pre-Osaka warmth — a consensus difference, not an opt.)
#[inline]
fn is_warm_precompile(&self, address: &Address) -> bool {
// Fast reject: every pre-seeded precompile has 18 leading zero bytes (max is `0x01_00`),
// so real contract/EOA addresses bail out here, off the hot warmth path.
if address.0[..18] != [0u8; 18] {
return false;
}
let n = u16::from_be_bytes([address.0[18], address.0[19]]);
let max_contiguous: u64 = match self.fork {
f if f >= Fork::Prague => SIZE_PRECOMPILES_PRAGUE,
f if f >= Fork::Cancun => SIZE_PRECOMPILES_CANCUN,
_ => SIZE_PRECOMPILES_PRE_CANCUN,
};
(n >= 1 && u64::from(n) <= max_contiguous) || (n == 0x100 && self.fork >= Fork::Osaka)
}
/// Push a checkpoint that can be either reverted or committed. All data up to this point is
/// still accessible.
pub fn push_backup(&mut self) {
let parent = mem::take(self);
self.refunded_gas = parent.refunded_gas;
// Carry the fork forward so child checkpoints keep the same precompile-warmth view.
self.fork = parent.fork;
self.parent = Some(Box::new(parent));
}
/// Pop and merge with the last backup.
///
/// Does nothing if the substate has no backup.
pub fn commit_backup(&mut self) {
if let Some(parent) = self.parent.as_mut() {
let mut delta = mem::take(parent);
mem::swap(self, &mut delta);
self.selfdestruct_set.extend(delta.selfdestruct_set);
self.accessed_addresses.extend(delta.accessed_addresses);
for (address, slot_set) in delta.accessed_storage_slots {
self.accessed_storage_slots
.entry(address)
.or_default()
.extend(slot_set);
}
self.created_accounts.extend(delta.created_accounts);
self.refunded_gas = delta.refunded_gas;
self.transient_storage.extend(delta.transient_storage);
self.logs.extend(delta.logs);
}
}
/// Discard current changes and revert to last backup.
///
/// Does nothing if the substate has no backup.
pub fn revert_backup(&mut self) {
if let Some(parent) = self.parent.as_mut() {
*self = mem::take(parent);
}
}
/// Return an iterator over all selfdestruct addresses.
pub fn iter_selfdestruct(&self) -> impl Iterator<Item = &Address> {
struct Iter<'a> {
parent: Option<&'a Substate>,
iter: std::collections::hash_set::Iter<'a, Address>,
}
impl<'a> Iterator for Iter<'a> {
type Item = &'a Address;
fn next(&mut self) -> Option<Self::Item> {
let next_item = self.iter.next();
if next_item.is_none()
&& let Some(parent) = self.parent
{
self.parent = parent.parent.as_deref();
self.iter = parent.selfdestruct_set.iter();
return self.next();
}
next_item
}
}
Iter {
parent: self.parent.as_deref(),
iter: self.selfdestruct_set.iter(),
}
}
/// Mark an address as selfdestructed and return whether is was already marked.
pub fn add_selfdestruct(&mut self, address: Address) -> bool {
if self.selfdestruct_set.contains(&address) {
return true;
}
let is_present = self
.parent
.as_ref()
.map(|parent| parent.is_selfdestruct(&address))
.unwrap_or_default();
is_present || !self.selfdestruct_set.insert(address)
}
/// Return whether an address is already marked as selfdestructed.
pub fn is_selfdestruct(&self, address: &Address) -> bool {
self.selfdestruct_set.contains(address)
|| self
.parent
.as_ref()
.map(|parent| parent.is_selfdestruct(address))
.unwrap_or_default()
}
/// Build an access list from all accessed storage slots.
pub fn make_access_list(&self) -> Vec<AccessListEntry> {
let mut entries = BTreeMap::<Address, BTreeSet<H256>>::new();
let mut current = self;
loop {
for (address, slot_set) in ¤t.accessed_storage_slots {
entries
.entry(*address)
.or_default()
.extend(slot_set.iter().copied());
}
current = match current.parent.as_deref() {
Some(x) => x,
None => break,
};
}
entries
.into_iter()
.map(|(address, storage_keys)| AccessListEntry {
address,
storage_keys: storage_keys.into_iter().collect(),
})
.collect()
}
/// Mark an address as accessed and return whether the slot was cold.
pub fn add_accessed_slot(&mut self, address: Address, key: H256) -> bool {
if self
.accessed_storage_slots
.get(&address)
.is_some_and(|set| set.contains(&key))
{
return false;
}
let is_present = self
.parent
.as_ref()
.map(|parent| parent.is_slot_accessed(&address, &key))
.unwrap_or_default();
// Note: Do not simplify this expression, it uses `||` to avoid executing the right hand
// expression if not necessary.
#[expect(clippy::nonminimal_bool, reason = "order of evaluation matters")]
!(is_present
|| !self
.accessed_storage_slots
.entry(address)
.or_default()
.insert(key))
}
/// Return whether an address has already been accessed.
pub fn is_slot_accessed(&self, address: &Address, key: &H256) -> bool {
self.accessed_storage_slots
.get(address)
.map(|slot_set| slot_set.contains(key))
.unwrap_or_default()
|| self
.parent
.as_ref()
.map(|parent| parent.is_slot_accessed(address, key))
.unwrap_or_default()
}
/// Returns all accessed storage slots for a given address.
/// Used by SELFDESTRUCT to record storage reads in BAL per EIP-7928:
/// "SELFDESTRUCT: Include modified/read storage keys as storage_read"
pub fn get_accessed_storage_slots(&self, address: &Address) -> BTreeSet<H256> {
let mut slots = BTreeSet::new();
// Collect from current substate
if let Some(slot_set) = self.accessed_storage_slots.get(address) {
slots.extend(slot_set.iter().copied());
}
// Collect from parent substates recursively
if let Some(parent) = self.parent.as_ref() {
slots.extend(parent.get_accessed_storage_slots(address));
}
slots
}
/// Mark an address as accessed and return whether the address was cold.
pub fn add_accessed_address(&mut self, address: Address) -> bool {
// Precompiles are warm from tx start (EIP-2929) without occupying a hashset slot. Returns
// `false` (not cold) so cold-access gas is never charged — identical to the old pre-seed.
if self.is_warm_precompile(&address) {
return false;
}
if self.accessed_addresses.contains(&address) {
return false;
}
let is_present = self
.parent
.as_ref()
.map(|parent| parent.is_address_accessed(&address))
.unwrap_or_default();
// Note: Do not simplify this expression, it uses `||` to avoid executing the right hand
// expression if not necessary.
#[expect(clippy::nonminimal_bool, reason = "order of evaluation matters")]
!(is_present || !self.accessed_addresses.insert(address))
}
/// Return whether an address has already been accessed.
pub fn is_address_accessed(&self, address: &Address) -> bool {
// Precompiles are always warm; the chain shares one `fork`, so this is consistent across
// sub-frame substates.
self.is_warm_precompile(address)
|| self.accessed_addresses.contains(address)
|| self
.parent
.as_ref()
.map(|parent| parent.is_address_accessed(address))
.unwrap_or_default()
}
/// Mark an address as a new account and return whether is was already marked.
pub fn add_created_account(&mut self, address: Address) -> bool {
if self.created_accounts.contains(&address) {
return true;
}
let is_present = self
.parent
.as_ref()
.map(|parent| parent.is_account_created(&address))
.unwrap_or_default();
is_present || !self.created_accounts.insert(address)
}
/// Return whether an address has already been marked as a new account.
pub fn is_account_created(&self, address: &Address) -> bool {
self.created_accounts.contains(address)
|| self
.parent
.as_ref()
.map(|parent| parent.is_account_created(address))
.unwrap_or_default()
}
/// Return the data associated with a transient storage entry, or zero if not present.
pub fn get_transient(&self, to: &Address, key: &U256) -> U256 {
self.transient_storage
.get(&(*to, *key))
.copied()
.unwrap_or_else(|| {
self.parent
.as_ref()
.map(|parent| parent.get_transient(to, key))
.unwrap_or_default()
})
}
/// Return the data associated with a transient storage entry, or zero if not present.
pub fn set_transient(&mut self, to: &Address, key: &U256, value: U256) {
self.transient_storage.insert((*to, *key), value);
}
/// Clear all transient storage (used between frames in frame transactions).
pub fn clear_transient_storage(&mut self) {
self.transient_storage.clear();
}
/// Extract all logs in order.
pub fn extract_logs(&self) -> Vec<Log> {
fn inner(substrate: &Substate, target: &mut Vec<Log>) {
if let Some(parent) = substrate.parent.as_deref() {
inner(parent, target);
}
target.extend_from_slice(&substrate.logs);
}
let mut logs = Vec::new();
inner(self, &mut logs);
logs
}
/// Return a clone of the current sub-substate's logs only, excluding parent logs.
/// Used by EIP-8141 frame execution to capture per-frame log deltas for
/// `frame_receipts[i].logs`. Must be called after `push_backup()` and before
/// `commit_backup()` to return only the logs emitted during the current scope.
pub fn current_logs(&self) -> Vec<Log> {
self.logs.clone()
}
/// Number of logs in the current scope, without cloning them. Used to slice
/// out a single frame's logs after `run_execution` has already committed the
/// frame's backup up into this scope.
pub fn logs_len(&self) -> usize {
self.logs.len()
}
/// Push a log record.
pub fn add_log(&mut self, log: Log) {
self.logs.push(log);
}
}
/// The LEVM (Lambda EVM) execution engine.
///
/// The VM executes Ethereum transactions by processing EVM bytecode. It maintains
/// a call stack, memory, and tracks all state changes during execution.
///
/// # Execution Model
///
/// 1. Transaction is validated (nonce, balance, gas limit)
/// 2. Initial call frame is created with transaction data
/// 3. Opcodes are executed sequentially until completion or error
/// 4. State changes are committed or reverted based on success
///
/// # Call Stack
///
/// Nested calls (CALL, DELEGATECALL, etc.) push new frames onto `call_frames`.
/// Each frame has its own memory, stack, and execution context. The `current_call_frame`
/// is always the active frame being executed.
///
/// # Hooks
///
/// The VM supports hooks for extending functionality (e.g., tracing, debugging).
/// Hooks are called at various points during execution and implement pre/post-execution
/// logic. L2-specific behavior (such as fee handling) is implemented via hooks.
///
/// # Example
///
/// ```ignore
/// let mut vm = VM::new(env, db, &tx, tracer, vm_type, &NativeCrypto);
/// let report = vm.execute()?;
/// if report.is_success() {
/// println!("Gas used: {}, Output: {:?}", report.gas_used, report.output);
/// } else {
/// println!("Transaction reverted");
/// }
/// ```
/// EIP-8141 spec lines 346-347: the top-level `frame.value` transfer
/// reverts the frame if the sender's balance is strictly less than the
/// amount being sent. Factored out so the decision can be unit-tested
/// without bringing up a full VM state.
pub fn frame_value_exceeds_balance(sender_balance: U256, frame_value: U256) -> bool {
sender_balance < frame_value
}
/// Context for frame transaction (EIP-8141) execution.
/// This is set when executing a frame transaction and is used by
/// APPROVE, TXPARAM, FRAMEDATALOAD, and FRAMEDATACOPY opcodes.
#[derive(Debug, Clone)]
pub struct FrameTxContext {
/// Whether the sender has approved (APPROVE scope `APPROVE_EXECUTION` or
/// `APPROVE_EXECUTION_AND_PAYMENT`).
pub sender_approved: bool,
/// The address that approved payment, set by `APPROVE_PAYMENT` or
/// `APPROVE_EXECUTION_AND_PAYMENT`. Per the latest EIP-8141 spec this is the
/// single source of truth for whether payment has been approved: when this
/// is `Some(_)`, the transaction has a `payer`; when `None`, it does not.
pub payer_address: Option<Address>,
/// Per-frame execution results (status, gas_used, logs).
/// `status` is a `FRAME_RECEIPT_STATUS_*` code (0 = failure, 1 = success,
/// 3 = skipped due to failed atomic batch).
pub frame_results: Vec<(u8, u64, Vec<Log>)>,
/// Index of the currently executing frame
pub current_frame_index: usize,
/// The sig_hash of the frame transaction
pub sig_hash: H256,
/// The full frame transaction (for TXPARAM access)
pub tx: ethrex_common::types::FrameTransaction,
/// Whether APPROVE was called in the current frame
pub approve_called_in_current_frame: bool,
/// Cached `FrameTransaction::total_gas_limit()`. Computing it re-encodes
/// every frame and signature, so it must not run per-opcode (TXPARAM 0x06,
/// compute_tx_max_cost). Computed once at tx entry.
pub total_gas_limit: u64,
}
impl FrameTxContext {
/// Capture the approval state at atomic-batch entry. A batch revert rolls
/// back the payer's balance deduction and the sender nonce increment, so
/// approvals granted inside the batch must be rolled back with it —
/// otherwise a reverted APPROVE would leave the transaction authorized
/// by a frame whose effects no longer exist.
pub fn approval_snapshot(&self) -> (bool, Option<Address>) {
(self.sender_approved, self.payer_address)
}
/// Restore the approval state captured by `approval_snapshot` when the
/// enclosing atomic batch reverts. Approvals granted before the batch
/// are unaffected (the snapshot includes them).
pub fn restore_approvals(&mut self, snapshot: (bool, Option<Address>)) {
let (sender_approved, payer_address) = snapshot;
self.sender_approved = sender_approved;
self.payer_address = payer_address;
}
}
/// Snapshot of `call_frame_backup`'s key-space taken by [`VM::enter_prepare_region`]
/// at the start of the atomic prepare region (EIP-8037 auth + prepare-dispatch
/// charges). `original_accounts_info` / `original_account_storage_slots` are
/// first-write-wins maps keyed by address, so a plain entry-count marker can't
/// tell which keys are region-added; recording the pre-region key sets lets
/// [`VM::fail_prepare_region`] revert exactly the region's writes while leaving
/// the earlier sender nonce-bump / fee-deduction backup entries intact.
/// `inserted_code_hashes` is an append-only `Vec`, so its pre-region length is
/// enough to identify the region-added tail.
#[derive(Debug, Default)]
pub struct PrepareRegionBackupMarker {
/// Addresses already backed up in `original_accounts_info` before the region.
accounts: FxHashSet<Address>,
/// Addresses already backed up in `original_account_storage_slots` before the region.
storage: FxHashSet<Address>,
/// Length of `inserted_code_hashes` before the region.
code_hashes_len: usize,
/// Region-entry state of every account already backed up before the region
/// (notably the sender, post-fee/post-nonce). The marker excludes these from
/// the region rollback to preserve their pre-region writes, but first-write-wins
/// backup means an in-region write to one of them (e.g. a self-sponsored EIP-7702
/// authorization on the sender) leaves no new backup entry. `fail_prepare_region`
/// restores these to the captured state, reverting the in-region write while
/// keeping the pre-region nonce bump / fee deduction.
entry_state: FxHashMap<Address, LevmAccount>,
/// BAL recorder checkpoint at region entry, so an applied-then-reverted
/// delegation's code/nonce changes are discarded (demoted to an access-only
/// touch) rather than leaking into the block access list.
bal_checkpoint: Option<BlockAccessListCheckpoint>,
}
/// Result of [`VM::simulate_validation_prefix`] (EIP-8141 mempool simulation).
#[derive(Debug, Clone)]
pub struct PrefixSimResult {
/// Whether any prefix frame reverted (fatal for validation).
pub any_revert: bool,
/// The payer established by the prefix, if any.
pub payer_address: Option<Address>,
/// Whether the sender was approved by a verify/pay frame.
pub sender_approved: bool,
/// Total simulated gas used across the prefix frames.
pub total_gas_used: u64,
}
pub struct VM<'a> {
/// Stack of parent call frames (for nested calls).
pub call_frames: Vec<CallFrame>,
/// The currently executing call frame.
pub current_call_frame: CallFrame,
/// Block and transaction environment.
pub env: Environment,
/// Execution substate (accessed addresses, logs, refunds, etc.).
pub substate: Substate,
/// Database for reading/writing account state.
pub db: &'a mut GeneralizedDatabase,
/// The transaction being executed. Borrowed for the VM's lifetime (the caller owns it for at
/// least that long), avoiding a per-tx deep clone of the access/authorization lists.
pub tx: &'a Transaction,
/// Execution hooks for tracing and debugging.
pub hooks: Vec<Rc<RefCell<dyn Hook>>>,
/// Original storage values before transaction (for SSTORE gas calculation),
/// keyed first by account to avoid hashing the full tuple on each access.
pub storage_original_values: FxHashMap<Address, FxHashMap<H256, U256>>,
/// Call tracer for execution tracing.
pub tracer: LevmCallTracer,
/// Opcode (EIP-3155) tracer. Disabled by default; zero overhead when inactive.
pub opcode_tracer: LevmOpcodeTracer,
/// EIP-8141 mempool validation-trace observer. Disabled by default; active
/// only during `simulate_frame_validation_prefix`. Read only behind
/// `if self.validation_observer.active`, so an inactive observer adds one
/// branch to the dispatch loop and nothing more (mirrors `opcode_tracer`).
pub validation_observer: ValidationObserver,
/// Debug mode for development diagnostics.
pub debug_mode: DebugMode,
/// Pool of reusable stacks to reduce allocations.
pub stack_pool: Vec<Stack>,
/// VM type (L1 or L2 with fee config).
pub vm_type: VMType,
/// Frame transaction context (EIP-8141). Set when executing a frame tx.
pub frame_tx_context: Option<FrameTxContext>,
/// Whether the top-level call-frame backup must be PRESERVED (deep-cloned) on the
/// revert / invalid-tx paths because a `BackupHook` will read it in `finalize_execution`
/// to build the tx-level undo snapshot. Derived from the installed `hooks` (via
/// [`Hook::reads_top_level_backup`]) rather than from `vm_type`, so it stays correct if
/// hook wiring changes; `add_hook` keeps it in sync for the `BackupHook` that
/// `stateless_execute` installs after construction. False for normal L1 block execution
/// (no `BackupHook`), where the backup is dead once the cache is restored and can be moved
/// out instead of cloned.
pub(crate) preserve_top_level_backup: bool,
/// EIP-8037: Accumulated state gas for this transaction (Amsterdam+).
/// Signed: goes negative when inline refunds exceed gross charges in the local frame
/// (e.g. SSTORE 0→x→0 restoration matching an ancestor's charge).
pub state_gas_used: i64,
/// EIP-8037: State gas reservoir pre-funded from excess gas_limit (Amsterdam+).
pub state_gas_reservoir: u64,
/// EIP-8037: Initial reservoir at tx start (before any execution). Captured in
/// add_intrinsic_gas so block-dimensional regular gas can be computed
/// independently of mid-tx reservoir activity (auth refunds, SSTORE credits).
pub state_gas_reservoir_initial: u64,
/// EIP-8037: Cumulative state gas that spilled to regular gas during execution
/// (when reservoir was insufficient). Subtracted when computing dimensional
/// regular gas for block accounting — EELS charge_state_gas spills don't
/// increment regular_gas_used.
pub state_gas_spill: u64,
/// EIP-8037: Dynamic cost per state byte (computed from block_gas_limit, Amsterdam+).
pub cost_per_state_byte: u64,
/// EIP-8037: State gas for new account creation (STATE_BYTES_PER_NEW_ACCOUNT * cost_per_state_byte).
pub state_gas_new_account: u64,
/// EIP-8037: State gas for storage slot creation (STATE_BYTES_PER_STORAGE_SET * cost_per_state_byte).
pub state_gas_storage_set: u64,
/// EIP-8037: State gas for the 23-byte EIP-7702 delegation indicator
/// (STATE_BYTES_PER_AUTH_BASE * cost_per_state_byte). Charged in-region by
/// `eip7702_set_access_code` (EELS `set_delegation`) once per authority when a
/// net-new delegation indicator is written, and never credited back.
pub state_gas_auth_base: u64,
/// EIP-8037: intrinsic state gas (`tx_env.intrinsic_state_gas` in EELS). Captured at
/// `add_intrinsic_gas` time. ethrex lumps intrinsic + execution into `state_gas_used`,
/// so on top-level error this field is what we leave behind when refunding the
/// execution portion to the reservoir — block accounting then bills the intrinsic
/// (matches EELS `tx_state_gas = intrinsic_state_gas + tx_output.state_gas_used`).
pub intrinsic_state_gas: u64,
/// EIP-8037: set by `prepare_execution` when the in-region value-to-not-alive
/// `NEW_ACCOUNT` charge fires (a value-bearing call to a not-yet-alive
/// recipient). Consumed once, at the top of `run_execution`, to decide
/// whether an Amsterdam precompile-halt must roll the charge back (the
/// recipient never materializes on halt).
pub value_new_account_charged: bool,
/// EIP-8037: `current_call_frame.state_gas_used_at_entry` captured by
/// `enter_prepare_region` right before the atomic prepare region (EIP-7702
/// auth + prepare-dispatch charges) begins. `fail_prepare_region` rewinds the
/// frame's `state_gas_used` back to this pre-region baseline on an internal OOG,
/// mirroring EELS `restore_tx_state(prep_snapshot)`.
pub prep_baseline_state_gas: i64,
/// EIP-8037: `state_gas_reservoir` captured by `enter_prepare_region`. The
/// `set_delegation` auth lock-in zeroes the frame spill, so `fail_prepare_region`
/// cannot reconstruct the pre-region reservoir from `refill_frame_state_gas`
/// arithmetic; it restores this captured value directly (EELS
/// `message.state_gas_reservoir = prep_reservoir`).
pub prep_baseline_reservoir: u64,
/// EIP-8037: cumulative `state_gas_spill` captured by `enter_prepare_region`, so
/// `fail_prepare_region` restores block-accounting spill to its pre-region value
/// (the region's spilled state gas is fully rolled back / burned as regular).
pub prep_baseline_state_gas_spill: u64,
/// EIP-8037: pre-region key-space snapshot of `call_frame_backup`, recorded by
/// `enter_prepare_region`. Lets `fail_prepare_region` revert only region-added
/// writes, leaving the sender nonce-bump / fee-deduction entries intact.
pub prep_region_backup_marker: PrepareRegionBackupMarker,
/// EIP-8037: set by `fail_prepare_region` when an internal OOG rolls back the
/// atomic prepare region. Consumed by `run_execution`, which turns it into a
/// full-gas revert `ContextResult` (mirrors EELS depth-0
/// `except ExceptionalHalt: evm.regular_gas_used += evm.gas_left; evm.gas_left = 0`)
/// instead of a tx-level rejection `Err` that would wrongly invalidate the block.
pub pending_prep_oog: bool,
/// The opcode table mapping opcodes to opcode handlers for fast lookup.
/// A shared `&'static` reference to a per-fork table that is `const`-built once for the
/// whole process (immutable), so each VM holds only a pointer instead of a 2 KB inline copy.
pub(crate) opcode_table: &'static [OpCodeFn; 256],
/// Crypto provider for cryptographic operations.
pub crypto: &'a dyn Crypto,
}
/// Validate every EIP-8141 outer signature (spec commit fe0940cae2) against
/// the canonical `sig_hash`. Returns false if any signature is malformed or
/// invalid. Verification gas is intrinsic (already in `total_gas_limit`), so a
/// scratch budget is used for the crypto precompiles and their deduction is
/// ignored.
#[expect(
clippy::indexing_slicing,
reason = "signature length is checked before each fixed-offset slice"
)]
pub fn validate_frame_signatures(
signatures: &[ethrex_common::types::FrameSignature],
sig_hash: ethrex_common::H256,
sender: ethrex_common::Address,
fork: Fork,
crypto: &dyn Crypto,
) -> bool {
use ethrex_common::types::{
FRAME_SIG_SCHEME_ARBITRARY, FRAME_SIG_SCHEME_P256, FRAME_SIG_SCHEME_SECP256K1,
};
for sig in signatures {
// Resolve the signed message.
let msg: [u8; 32] = match sig.msg.len() {
0 => sig_hash.0,
32 => {
let mut m = [0u8; 32];
m.copy_from_slice(&sig.msg);
if m == [0u8; 32] {
return false;
}
m
}
_ => return false,
};
let mut scratch_gas = u64::MAX;
match sig.scheme {
FRAME_SIG_SCHEME_SECP256K1 => {
if sig.signature.len() != 65 {
return false;
}
let v = sig.signature[0];
let r = &sig.signature[1..33];
let s = &sig.signature[33..65];
// EIP-8141 defines verification as `signer == ecrecover(msg, v, r, s)`
// and does NOT mandate EIP-2 low-s, so a high-s frame signature is
// spec-valid and MUST be accepted here (this is the consensus
// block-execution path). Anti-malleability (low-s) is enforced as
// local mempool policy in `frame_signatures_are_low_s`, never at
// consensus — rejecting high-s here would diverge from a conformant
// client that accepts it.
let mut calldata = vec![0u8; 128];
calldata[..32].copy_from_slice(&msg);
calldata[63] = v;
calldata[64..96].copy_from_slice(r);
calldata[96..128].copy_from_slice(s);
let Ok(result) = crate::precompiles::ecrecover(
&Bytes::from(calldata),
&mut scratch_gas,
fork,
crypto,
) else {
return false;
};
if result.len() != 32 {
return false;
}
let recovered = ethrex_common::Address::from_slice(&result[12..]);
if recovered == ethrex_common::Address::zero() {
return false;
}
// Per EIP-8141, an absent signer resolves to tx.sender (used for
// introspection too); the recovered key must equal the resolved signer.
if recovered != sig.signer.unwrap_or(sender) {
return false;
}
}
FRAME_SIG_SCHEME_P256 => {
if sig.signature.len() != 128 {
return false;
}
let r = &sig.signature[0..32];
let s = &sig.signature[32..64];
let qx = &sig.signature[64..96];
let qy = &sig.signature[96..128];
// signer = keccak256(qx || qy)[12:] (NO domain separator)
let mut pk = Vec::with_capacity(64);
pk.extend_from_slice(qx);
pk.extend_from_slice(qy);
let h = ethrex_crypto::keccak::keccak_hash(&pk);
// Per EIP-8141, an absent signer resolves to tx.sender (used for
// introspection too); the derived key must equal the resolved signer.
if ethrex_common::Address::from_slice(&h[12..]) != sig.signer.unwrap_or(sender) {
return false;
}
let mut calldata = vec![0u8; 160];
calldata[..32].copy_from_slice(&msg);
calldata[32..64].copy_from_slice(r);
calldata[64..96].copy_from_slice(s);
calldata[96..128].copy_from_slice(qx);
calldata[128..160].copy_from_slice(qy);
let Ok(result) = crate::precompiles::p_256_verify(
&Bytes::from(calldata),
&mut scratch_gas,
fork,
crypto,
) else {
return false;
};
if result.len() != 32 || result[31] != 1 {
return false;
}
}
FRAME_SIG_SCHEME_ARBITRARY => {
// ARBITRARY: no protocol crypto; the signer must be empty. A
// custom verifier reads the raw bytes via SIGPARAM 0x04.
if sig.signer.is_some() {
return false;
}
}
_ => return false,
}
}
true
}
/// Local mempool anti-malleability policy (NOT consensus): returns `false` if any
/// frame signature is high-s (`s > n/2`).
///
/// EIP-8141 verification is `signer == ecrecover(msg, v, r, s)` and does not
/// mandate EIP-2 low-s, so a high-s signature is spec-valid and is accepted on the
/// consensus block-execution path. But the raw signature bytes are committed to the
/// transaction identity hash while being elided from the sig hash, so the malleated
/// form `(v, r, s) -> (v^1, r, n-s)` (and the P256 `s -> n-s`) yields a second valid
/// tx hash for the same logical transaction — a mempool dedup bypass. Admission
/// rejects high-s so a malleated duplicate never occupies a pool slot; this gates
/// only what this node admits/relays, never what it accepts in a block.
///
/// Signatures are assumed already structurally validated by
/// [`validate_frame_signatures`]; malformed inputs conservatively return `false`.
#[allow(
clippy::indexing_slicing,
reason = "signature length is checked before each fixed-offset slice"
)]
pub fn frame_signatures_are_low_s(signatures: &[ethrex_common::types::FrameSignature]) -> bool {
use ethrex_common::types::{
FRAME_SIG_SCHEME_ARBITRARY, FRAME_SIG_SCHEME_P256, FRAME_SIG_SCHEME_SECP256K1,
};
// secp256k1n/2 = 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0
const SECP256K1_N_HALF: [u8; 32] = [
0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0x5d, 0x57, 0x6e, 0x73, 0x57, 0xa4, 0x50, 0x1d, 0xdf, 0xe9, 0x2f, 0x46, 0x68, 0x1b,
0x20, 0xa0,
];
// P-256 (secp256r1) n/2 = 0x7fffffff800000007fffffffffffffffde737d56d38bcf4279dce5617e3192a8
const P256_N_HALF: [u8; 32] = [
0x7f, 0xff, 0xff, 0xff, 0x80, 0x00, 0x00, 0x00, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
0xff, 0xde, 0x73, 0x7d, 0x56, 0xd3, 0x8b, 0xcf, 0x42, 0x79, 0xdc, 0xe5, 0x61, 0x7e, 0x31,
0x92, 0xa8,
];
for sig in signatures {
match sig.scheme {
FRAME_SIG_SCHEME_SECP256K1 => {
if sig.signature.len() != 65 {
return false;
}
if sig.signature[33..65] > SECP256K1_N_HALF[..] {
return false;
}
}
FRAME_SIG_SCHEME_P256 => {
if sig.signature.len() != 128 {
return false;
}
if sig.signature[32..64] > P256_N_HALF[..] {
return false;
}
}
// ARBITRARY carries no ECDSA `s`, so low-s malleability does not apply.
FRAME_SIG_SCHEME_ARBITRARY => {}
_ => return false,
}
}
true
}
/// Find the end of the atomic batch containing `failed_idx`, per EIP-8141:
/// a batch is a maximal contiguous run of frames whose ATOMIC_BATCH_FLAG is
/// set, terminated by the first frame without the flag — any mode (spec
/// commit 8b61fdc4). Returns the index of the batch's terminating frame.
///
/// Exposed (hidden) for the `ethrex-test` crate's frame-batch unit tests; not
/// part of the stable public API.
#[doc(hidden)]
pub fn find_batch_end(frames: &[Frame], failed_idx: usize) -> usize {
frames
.get(failed_idx..)
.and_then(|rest| rest.iter().position(|f| !f.is_atomic_batch()))
.map(|offset| failed_idx.saturating_add(offset))
.unwrap_or(failed_idx)
}
impl<'a> VM<'a> {
/// Constructs a VM, allocating a fresh 32 KB root call-frame stack.
///
/// Hot block execution should prefer [`VM::new_pooled`], which draws the root stack from a
/// reusable pool instead of allocating + zeroing one per transaction.
pub fn new(
env: Environment,
db: &'a mut GeneralizedDatabase,
tx: &'a Transaction,
tracer: LevmCallTracer,
vm_type: VMType,
crypto: &'a dyn Crypto,
) -> Result<Self, VMError> {
Self::new_with_root_stack(
env,
db,
tx,
tracer,
vm_type,
crypto,
Stack::default(),
Memory::default(),
)
}
/// Like [`VM::new`], but draws the root call-frame stack from `stack_pool` (falling back to a
/// fresh `Stack::default()` only when the pool is empty) and adopts the remaining pooled
/// stacks for sub-call frames. This avoids the per-tx 32 KB stack alloc+zero on a warm pool —
/// the dominant allocation for transfer-heavy blocks, where the root frame is the only frame.
///
/// Pair with [`VM::reclaim_into`] after execution to return every stack (root + sub-frame)
/// to `stack_pool` and the root memory buffer to `memory_pool` so the next tx reuses them.
#[allow(clippy::too_many_arguments)]
pub fn new_pooled(
env: Environment,
db: &'a mut GeneralizedDatabase,
tx: &'a Transaction,
tracer: LevmCallTracer,
vm_type: VMType,
crypto: &'a dyn Crypto,
stack_pool: &mut Vec<Stack>,
memory_pool: &mut Vec<Memory>,
) -> Result<Self, VMError> {
// Reuse a pooled stack for the root frame. `clear()` only resets the offset (no zeroing),
// which is sound because the EVM never reads stack slots it didn't write — the same
// invariant that already makes sub-frame pooling safe.
let mut root_stack = stack_pool.pop().unwrap_or_default();
root_stack.clear();
// Reuse a pooled root memory buffer (capacity retained from a prior tx, contents dropped).
// `reclaim_into` truncates it to length 0, so `resize`'s zero-fill invariant holds. Only
// the root buffer is pooled: sub-frame memories are `Rc` clones of it (`next_memory`).
let mut root_memory = memory_pool.pop().unwrap_or_default();
root_memory.reset_for_reuse();
let mut vm = Self::new_with_root_stack(
env,
db,
tx,
tracer,
vm_type,
crypto,
root_stack,
root_memory,
)?;
// Adopt the caller's pooled stacks for sub-frames; returned via `reclaim_into`.
mem::swap(&mut vm.stack_pool, stack_pool);
Ok(vm)
}
/// Returns this VM's reusable buffers to the caller's pools so the next transaction reuses
/// them instead of allocating: every stack (root call-frame stack plus any sub-frame stacks
/// still pooled internally) to `stack_pool`, and the root memory buffer to `memory_pool`.
/// Must run on both the success and error paths of [`VM::execute`].
pub fn reclaim_into(mut self, stack_pool: &mut Vec<Stack>, memory_pool: &mut Vec<Memory>) {
// Hand the internal sub-frame pool back to the caller first.
mem::swap(&mut self.stack_pool, stack_pool);
// Then reclaim the root frame's stack. Moving it out by value (VM/CallFrame have no Drop)
// avoids leaving a fresh 32 KB `Stack::default()` placeholder behind — which a
// `mem::take`/`mem::replace` against an empty pool would force, defeating the win on
// exactly the transfer-only blocks (no sub-frames ever seed the pool) we target.
let mut root_stack = self.current_call_frame.stack;
root_stack.clear();
stack_pool.push(root_stack);
// Reclaim the root memory buffer with its grown capacity. `reset_for_reuse` truncates it
// to length 0 (capacity kept) so the next tx's `resize` zero-fills correctly.
//
// Every call frame shares the same `Rc<RefCell<Vec<u8>>>` buffer, so on the error path the
// ancestor frames left in `call_frames` (error propagation unwinds out of `execute` without
// popping them) still hold clones. Drop them first so the buffer is `Rc`-unique on BOTH
// paths before we clear it — otherwise the clear would propagate to a frame still holding a
// reference. `CallFrame` has no `Drop` and these frames are never read again, so dropping
// them early is free.
self.call_frames.clear();