Skip to main content

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}