Skip to main content

ethrex_levm/
opcode_tracer.rs

1use bytes::Bytes;
2use ethrex_common::{
3    H256, U256,
4    tracing::{MemoryChunk, OpcodeStep, OpcodeTraceResult},
5};
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9/// Configuration for the per-opcode (EIP-3155) tracer.
10#[derive(Debug, Clone, Default, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase", default)]
12pub struct OpcodeTracerConfig {
13    /// When true, stack values are not included in each step.
14    pub disable_stack: bool,
15    /// When true, memory contents are included in each step.
16    pub enable_memory: bool,
17    /// When true, storage diffs at SLOAD/SSTORE steps are not captured.
18    pub disable_storage: bool,
19    /// When true, return data from the previous sub-call is included.
20    pub enable_return_data: bool,
21    /// Maximum number of log entries to collect.  0 = unlimited.
22    pub limit: usize,
23}
24
25/// Per-opcode (EIP-3155) tracer, emitted under the de-facto cross-client
26/// `structLogger` wrapper shape.
27///
28/// Use `LevmOpcodeTracer::disabled()` when tracing is not wanted;
29/// the dispatch-loop guard is a single `if self.opcode_tracer.active` branch
30/// with no other overhead on the fast path.
31#[derive(Debug)]
32pub struct LevmOpcodeTracer {
33    /// Whether this tracer is active.
34    pub active: bool,
35    /// Configuration.
36    pub cfg: OpcodeTracerConfig,
37    /// Collected per-step entries.
38    pub logs: Vec<OpcodeStep>,
39    /// Final output bytes (from RETURN / REVERT).
40    pub output: Bytes,
41    /// Top-level error string, if the transaction reverted.
42    pub error: Option<String>,
43    /// Gas used by the transaction.
44    pub gas_used: u64,
45    /// Explicit gas cost written by CALL/CALLCODE/DELEGATECALL/STATICCALL/CREATE/CREATE2
46    /// handlers before invoking the child frame, and by `jump()` when JUMP/JUMPI is
47    /// fused with JUMPDEST under active tracing.  The dispatch loop prefers this value
48    /// over the (incorrect) gas-diff that would include forwarded gas.
49    pub last_opcode_gas_cost: Option<u64>,
50    /// Index in `logs` of the entry that the next `finalize_step` should patch.
51    /// `Some(i)` is set by `pre_step_capture` after a push; `None` after the
52    /// `limit` cap is reached (so `finalize_step` is a no-op).  Synthesized
53    /// steps (e.g. fused JUMPDEST) push directly without touching this index,
54    /// preserving the parent opcode's pending finalize target.
55    pub last_step_index: Option<usize>,
56    /// Cumulative map of every storage slot touched by an SLOAD/SSTORE so far in
57    /// this transaction, with the most recent value observed. Each
58    /// SLOAD/SSTORE-bearing step embeds a snapshot of this map under its
59    /// `storage` field, matching geth's structLogger behavior of accumulating
60    /// touched slots across the trace rather than emitting only the slot just
61    /// accessed. Empty until the first SLOAD/SSTORE; not reset between call
62    /// frames (consistent with how slot keys are indexed — by slot only, not by
63    /// `(address, slot)` — so cross-frame frame isolation is a separate concern).
64    pub cumulative_storage: BTreeMap<H256, H256>,
65}
66
67impl LevmOpcodeTracer {
68    /// Returns an inactive tracer.  No allocations; zero overhead on the hot path.
69    pub fn disabled() -> Self {
70        Self {
71            active: false,
72            cfg: OpcodeTracerConfig::default(),
73            logs: Vec::new(),
74            output: Bytes::new(),
75            error: None,
76            gas_used: 0,
77            last_opcode_gas_cost: None,
78            last_step_index: None,
79            cumulative_storage: BTreeMap::new(),
80        }
81    }
82
83    /// Returns an active tracer with the given config.
84    pub fn new(cfg: OpcodeTracerConfig) -> Self {
85        Self {
86            active: true,
87            cfg,
88            logs: Vec::new(),
89            output: Bytes::new(),
90            error: None,
91            gas_used: 0,
92            last_opcode_gas_cost: None,
93            last_step_index: None,
94            cumulative_storage: BTreeMap::new(),
95        }
96    }
97
98    /// Captures pre-step state, building and buffering an `OpcodeStep` entry.
99    ///
100    /// Called BEFORE the opcode executes.  `pc` must be the address of the
101    /// current opcode (before `advance_pc()`).
102    ///
103    /// `stack_view` must already be bottom-first (caller reverses LEVM's top-first
104    /// layout) and empty when `cfg.disable_stack` is true.
105    ///
106    /// `memory_view` is the live byte slice for the current frame (caller provides
107    /// this only when `cfg.enable_memory` is true; otherwise pass `&[]`).
108    ///
109    /// `storage_kv` is pre-fetched by the caller via `read_storage_for_trace`; it is
110    /// `None` for all opcodes except SLOAD/SSTORE (or when storage capture is disabled).
111    #[expect(
112        clippy::too_many_arguments,
113        reason = "all fields are required per-step state from the dispatch-loop hook"
114    )]
115    pub fn pre_step_capture(
116        &mut self,
117        pc: u64,
118        opcode: u8,
119        gas: u64,
120        depth: u32,
121        refund: u64,
122        stack_view: &[U256],
123        memory_view: &[u8],
124        mem_size: u64,
125        return_data: &Bytes,
126        storage_kv: Option<(H256, H256)>,
127    ) {
128        // Update the cumulative storage map BEFORE the limit check so that the
129        // observed slot value is preserved even when a later step is dropped by
130        // the limit cap.
131        if let Some((key, value)) = storage_kv {
132            self.cumulative_storage.insert(key, value);
133        }
134
135        // Enforce limit: stop appending once the cap is reached. Clearing the
136        // patch index ensures `finalize_step` does not clobber the last retained
137        // step on subsequent opcodes.
138        if self.cfg.limit > 0 && self.logs.len() >= self.cfg.limit {
139            self.last_step_index = None;
140            return;
141        }
142
143        let mut log = build_step(
144            &self.cfg,
145            pc,
146            opcode,
147            gas,
148            /* gas_cost */ 0, // patched in finalize_step
149            depth,
150            refund,
151            stack_view,
152            memory_view,
153            mem_size,
154            return_data,
155            storage_kv,
156        );
157
158        // For SLOAD/SSTORE steps, replace the single-entry storage map produced
159        // by `build_step` with a snapshot of the cumulative map, matching geth's
160        // structLogger behavior. `build_step` is also called by synthetic-step
161        // builders (e.g. fused JUMPDEST) that pass `storage_kv: None` and so
162        // produce `log.storage == None`; those are left untouched.
163        if log.storage.is_some() {
164            log.storage = Some(self.cumulative_storage.clone());
165        }
166
167        self.last_step_index = Some(self.logs.len());
168        self.logs.push(log);
169    }
170
171    /// Patches the entry recorded by the most recent `pre_step_capture` with the
172    /// actual gas cost, the post-execution refund counter, and any step-level
173    /// error string. Called immediately after the opcode handler returns.
174    ///
175    /// `refund_after` matches geth's structLogger timing: the refund counter
176    /// shown on an opcode's step is the value *after* the opcode's gas+refund
177    /// accounting has been applied. For opcodes that don't mutate the refund
178    /// counter (every opcode except SSTORE and pre-London SELFDESTRUCT) this is
179    /// a no-op since the captured pre-op refund already equals the post-op one.
180    ///
181    /// No-op when the most recent `pre_step_capture` did not push (limit reached).
182    /// Synthesized entries (e.g. fused JUMPDEST) push directly into `logs` without
183    /// updating `last_step_index`, so this still patches the correct parent entry.
184    pub fn finalize_step(&mut self, gas_cost: u64, refund_after: u64, error: Option<&str>) {
185        let Some(idx) = self.last_step_index else {
186            return;
187        };
188        if let Some(log) = self.logs.get_mut(idx) {
189            log.gas_cost = gas_cost;
190            log.refund = refund_after;
191            log.error = error.map(str::to_owned);
192        }
193    }
194
195    /// Pushes a fully-formed synthetic step (used for fused JUMPDEST under JUMP/JUMPI).
196    ///
197    /// Does **not** update `last_step_index`, so the pending `finalize_step` for the
198    /// parent opcode continues to patch the parent's entry. The limit cap is honored
199    /// — synthetic pushes are dropped once `cfg.limit` is reached.
200    pub fn synthesize_step(&mut self, step: OpcodeStep) {
201        if self.cfg.limit > 0 && self.logs.len() >= self.cfg.limit {
202            return;
203        }
204        self.logs.push(step);
205    }
206
207    /// Pushes a synthetic step and makes it the target of the next `finalize_step`.
208    ///
209    /// Used when the synthetic step is the one that faulted, so the dispatch
210    /// loop's post-step hook patches the error onto it rather than onto the
211    /// parent opcode that fused it. Returns `false` when the `limit` cap dropped
212    /// the push.
213    ///
214    /// Either way the next `finalize_step` will not reach the parent: on a drop
215    /// the patch index is cleared, the same way `pre_step_capture` clears it at
216    /// the cap. Leaving it on the parent would write the dropped step's error
217    /// onto a record that is inside the cap and error-free in the uncapped log,
218    /// so a capped log would no longer be a prefix of the uncapped one.
219    pub fn synthesize_faulting_step(&mut self, step: OpcodeStep) -> bool {
220        if self.cfg.limit > 0 && self.logs.len() >= self.cfg.limit {
221            self.last_step_index = None;
222            return false;
223        }
224        self.last_step_index = Some(self.logs.len());
225        self.logs.push(step);
226        true
227    }
228
229    /// Assembles the final `OpcodeTraceResult` after the transaction finishes.
230    pub fn take_result(&mut self) -> OpcodeTraceResult {
231        OpcodeTraceResult {
232            pass: self.error.is_none(),
233            gas_used: self.gas_used,
234            output: std::mem::take(&mut self.output),
235            steps: std::mem::take(&mut self.logs),
236        }
237    }
238}
239
240/// Constructs an [`OpcodeStep`] from raw VM state. Shared between the
241/// dispatch-loop hook (`pre_step_capture`) and synthetic-step builders
242/// (e.g. fused JUMPDEST under JUMP/JUMPI). Callers pass `gas_cost = 0` when
243/// they intend to patch it later in `finalize_step`; synthetic steps pass the
244/// known cost directly.
245#[expect(
246    clippy::too_many_arguments,
247    reason = "all fields are required per-step state captured from VM"
248)]
249pub fn build_step(
250    cfg: &OpcodeTracerConfig,
251    pc: u64,
252    opcode: u8,
253    gas: u64,
254    gas_cost: u64,
255    depth: u32,
256    refund: u64,
257    stack_view: &[U256],
258    memory_view: &[u8],
259    mem_size: u64,
260    return_data: &Bytes,
261    storage_kv: Option<(H256, H256)>,
262) -> OpcodeStep {
263    // Stack: Some(vec) when capture enabled; None when disabled (emits JSON null).
264    let stack = if !cfg.disable_stack {
265        Some(stack_view.to_vec())
266    } else {
267        None
268    };
269
270    // Memory: chunked 32-byte slices when enabled; field omitted otherwise.
271    // When enabled and memory is empty, emit `Some(vec![])` so the field
272    // stays present (an empty array signals "captured, just empty").
273    let memory = if cfg.enable_memory {
274        if memory_view.is_empty() {
275            Some(vec![])
276        } else {
277            let chunks = memory_view
278                .chunks(32)
279                .map(|c| {
280                    let mut arr = [0u8; 32];
281                    if let Some(dst) = arr.get_mut(..c.len()) {
282                        dst.copy_from_slice(c);
283                    }
284                    MemoryChunk(arr)
285                })
286                .collect();
287            Some(chunks)
288        }
289    } else {
290        None
291    };
292
293    // Storage: presence/absence of `storage_kv` is what signals "this step
294    // touches storage". Callers from `pre_step_capture` overwrite this with a
295    // snapshot of the tracer's cumulative storage map; callers from synthetic-
296    // step paths (e.g. fused JUMPDEST) pass `None` and get `None` here.
297    let storage = storage_kv.map(|(key, value)| {
298        let mut m = BTreeMap::new();
299        m.insert(key, value);
300        m
301    });
302
303    // returnData: actual bytes when enabled; empty Bytes otherwise.
304    let return_data_field = if cfg.enable_return_data {
305        return_data.clone()
306    } else {
307        Bytes::new()
308    };
309
310    OpcodeStep {
311        pc,
312        op: opcode,
313        gas,
314        gas_cost,
315        mem_size,
316        depth,
317        return_data: return_data_field,
318        refund,
319        stack,
320        memory,
321        storage,
322        error: None,
323    }
324}