ethrex_levm/validation_observer.rs
1//! EIP-8141 mempool validation-trace observer.
2//!
3//! A lightweight, opt-in observer attached to [`VM`](crate::vm::VM) that enforces
4//! the ERC-7562-style validation-trace rules during *mempool* simulation of a
5//! frame transaction's validation prefix (the verify/pay/deploy frames that must
6//! run before the transaction's payer is established). It is a **local peer
7//! policy**, never a consensus rule: it is active ONLY behind
8//! [`LEVM::simulate_frame_validation_prefix`](crate::vm::VM) (the mempool entry
9//! point) and is constructed with [`ValidationObserver::disabled`] everywhere
10//! else, so normal block execution and block building pay a single `if active`
11//! branch in the dispatch loop and nothing more (mirrors `LevmOpcodeTracer`).
12//!
13//! ## OQ4 — SETDELEGATE is vacuous
14//! The draft EIP's validation-trace rules reference a `SETDELEGATE` operation
15//! used by deploy frames to install an EIP-7702 delegation for the sender. There
16//! is no `SETDELEGATE` opcode in `opcodes.rs` (the ethrex LEVM opcode set), so
17//! the deploy-frame delegation allowance reduces to what the actual opcode set
18//! can express: `CREATE`/`CREATE2` plus `SSTORE` writing to the sender's own
19//! storage. The observer therefore permits exactly those state effects inside
20//! the deploy frame and bans all other state writes; there is no separate
21//! `SETDELEGATE` allowance to model.
22//!
23//! ## Canonical-pay-frame exemption
24//! ERC-7562 exempts the canonical paymaster's pay frame from the storage/call
25//! access restrictions (a canonical paymaster is trusted to touch shared
26//! reservation state). ethrex cannot resolve the canonical paymaster bytecode
27//! (OQ1: not pinned in the draft EIP or any reference implementation), so
28//! [`canonical_paymaster_pay_frame`](ValidationObserver::canonical_paymaster_pay_frame)
29//! is always `None` here and the exemption never fires. The field and the
30//! `current_frame_index == canonical_paymaster_pay_frame` skip are wired up so
31//! the exemption flips on for free once the canonical code hash is pinned.
32
33use ethrex_common::{Address, H256};
34
35/// A validation-trace rule violation detected during prefix simulation.
36///
37/// The first violation observed is recorded; the simulation harness treats any
38/// recorded violation as a failed validation (the transaction is rejected from
39/// the mempool).
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum FrameSimViolation {
42 /// A banned opcode was executed (carries the raw opcode byte). The banned
43 /// set is enforced in the dispatch loop; see
44 /// [`VM::run_execution`](crate::vm::VM::run_execution).
45 BannedOpcode(u8),
46 /// A state write (`SSTORE`/`CREATE`/`CREATE2`) occurred outside the deploy
47 /// frame, or an `SSTORE` inside the deploy frame targeted storage other than
48 /// the sender's own account.
49 StateWriteOutsideDeploy,
50 /// An `SLOAD` read storage of an account other than the sender.
51 StorageReadNonSender,
52 /// A `CALL`/`CALLCODE`/`DELEGATECALL`/`STATICCALL`/`EXTCODE*` referenced a
53 /// target that does not exist (no code, no balance, no nonce, not a
54 /// precompile) or that is EIP-7702-delegated; carries the offending target.
55 CallToNonexistentOrDelegated(Address),
56 /// A deploy frame finished without leaving non-empty code installed at the
57 /// sender's address.
58 DeployInstalledNoCode,
59}
60
61/// EIP-8141 validation-trace observer. Inert (`active == false`) in every VM
62/// construction except mempool prefix simulation.
63///
64/// The dispatch-loop and handler hooks read this only behind
65/// `if self.validation_observer.active`, so an inactive observer has zero
66/// overhead on the perf-sensitive execution path (one branch), exactly like
67/// [`LevmOpcodeTracer`](crate::opcode_tracer::LevmOpcodeTracer).
68#[derive(Debug, Clone)]
69pub struct ValidationObserver {
70 /// Whether this observer is active. `false` disables every hook.
71 pub active: bool,
72 /// The frame transaction's sender. SLOAD/SSTORE are restricted to this
73 /// address; CALL/EXTCODE targets equal to this address are exempt.
74 pub sender: Address,
75 /// Index (into `FrameTransaction.frames`) of the deploy frame, if the
76 /// prefix has one. `SSTORE`/`CREATE`/`CREATE2` are allowed only while this
77 /// frame is executing.
78 pub deploy_frame_index: Option<usize>,
79 /// Index of the currently executing prefix frame. Set by the harness before
80 /// each frame runs.
81 pub current_frame_index: usize,
82 /// Execution mode of the currently executing prefix frame (raw `mode` byte:
83 /// 0 = DEFAULT, 1 = VERIFY, 2 = SENDER). Set by the harness before each
84 /// frame runs.
85 pub current_frame_mode: u8,
86 /// Address of the canonical EXPIRY_VERIFIER predeploy (0x…8141). `TIMESTAMP`
87 /// is permitted only when `current_call_frame.code_address` equals this value,
88 /// which allows TIMESTAMP inside a nested call *into* the predeploy while
89 /// correctly banning it in any callee of an expiry frame that routes execution
90 /// elsewhere (an under-reject the per-top-frame boolean could not prevent).
91 pub expiry_verifier: Address,
92 /// Index of the canonical paymaster's pay frame, if any. Always `None`
93 /// (OQ1, see module docs); the access-restriction skip is wired for the
94 /// future canonical-paymaster case.
95 pub canonical_paymaster_pay_frame: Option<usize>,
96 /// The opcode byte executed on the previous dispatch-loop iteration. Used to
97 /// enforce the `GAS` sequential rule (`GAS` is allowed only immediately
98 /// before a `*CALL`). Reset each iteration.
99 pub last_opcode: u8,
100 /// Sender storage slots touched (read or written) during the prefix. Recorded
101 /// for the admission-time revalidation affected-set (Phase 3).
102 pub touched_sender_slots: Vec<H256>,
103 /// First violation observed, if any.
104 pub violation: Option<FrameSimViolation>,
105}
106
107impl ValidationObserver {
108 /// Returns an inactive observer. No allocations; zero overhead on the hot
109 /// path (every hook is gated by `if active`).
110 pub fn disabled() -> Self {
111 Self {
112 active: false,
113 sender: Address::zero(),
114 deploy_frame_index: None,
115 current_frame_index: 0,
116 current_frame_mode: 0,
117 expiry_verifier: Address::zero(),
118 canonical_paymaster_pay_frame: None,
119 last_opcode: 0,
120 touched_sender_slots: Vec::new(),
121 violation: None,
122 }
123 }
124
125 /// Returns an active observer for simulating the validation prefix of a
126 /// frame transaction sent by `sender`, whose deploy frame (if any) is at
127 /// `deploy_frame_index`.
128 pub fn new(
129 sender: Address,
130 deploy_frame_index: Option<usize>,
131 expiry_verifier: Address,
132 ) -> Self {
133 Self {
134 active: true,
135 sender,
136 deploy_frame_index,
137 current_frame_index: 0,
138 current_frame_mode: 0,
139 expiry_verifier,
140 canonical_paymaster_pay_frame: None,
141 last_opcode: 0,
142 touched_sender_slots: Vec::new(),
143 violation: None,
144 }
145 }
146
147 /// Records the first violation observed; later violations are ignored (the
148 /// transaction is already rejected).
149 pub fn record_violation(&mut self, violation: FrameSimViolation) {
150 if self.violation.is_none() {
151 self.violation = Some(violation);
152 }
153 }
154
155 /// Whether the currently executing frame is the canonical paymaster pay
156 /// frame (always `false` while `canonical_paymaster_pay_frame` is `None`).
157 pub fn in_canonical_pay_frame(&self) -> bool {
158 self.canonical_paymaster_pay_frame == Some(self.current_frame_index)
159 }
160
161 /// Whether the currently executing frame is the deploy frame.
162 pub fn in_deploy_frame(&self) -> bool {
163 self.deploy_frame_index == Some(self.current_frame_index)
164 }
165}