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    /// Assembles the final `OpcodeTraceResult` after the transaction finishes.
208    pub fn take_result(&mut self) -> OpcodeTraceResult {
209        OpcodeTraceResult {
210            pass: self.error.is_none(),
211            gas_used: self.gas_used,
212            output: std::mem::take(&mut self.output),
213            steps: std::mem::take(&mut self.logs),
214        }
215    }
216}
217
218/// Constructs an [`OpcodeStep`] from raw VM state. Shared between the
219/// dispatch-loop hook (`pre_step_capture`) and synthetic-step builders
220/// (e.g. fused JUMPDEST under JUMP/JUMPI). Callers pass `gas_cost = 0` when
221/// they intend to patch it later in `finalize_step`; synthetic steps pass the
222/// known cost directly.
223#[expect(
224    clippy::too_many_arguments,
225    reason = "all fields are required per-step state captured from VM"
226)]
227pub fn build_step(
228    cfg: &OpcodeTracerConfig,
229    pc: u64,
230    opcode: u8,
231    gas: u64,
232    gas_cost: u64,
233    depth: u32,
234    refund: u64,
235    stack_view: &[U256],
236    memory_view: &[u8],
237    mem_size: u64,
238    return_data: &Bytes,
239    storage_kv: Option<(H256, H256)>,
240) -> OpcodeStep {
241    // Stack: Some(vec) when capture enabled; None when disabled (emits JSON null).
242    let stack = if !cfg.disable_stack {
243        Some(stack_view.to_vec())
244    } else {
245        None
246    };
247
248    // Memory: chunked 32-byte slices when enabled; field omitted otherwise.
249    // When enabled and memory is empty, emit `Some(vec![])` so the field
250    // stays present (an empty array signals "captured, just empty").
251    let memory = if cfg.enable_memory {
252        if memory_view.is_empty() {
253            Some(vec![])
254        } else {
255            let chunks = memory_view
256                .chunks(32)
257                .map(|c| {
258                    let mut arr = [0u8; 32];
259                    if let Some(dst) = arr.get_mut(..c.len()) {
260                        dst.copy_from_slice(c);
261                    }
262                    MemoryChunk(arr)
263                })
264                .collect();
265            Some(chunks)
266        }
267    } else {
268        None
269    };
270
271    // Storage: presence/absence of `storage_kv` is what signals "this step
272    // touches storage". Callers from `pre_step_capture` overwrite this with a
273    // snapshot of the tracer's cumulative storage map; callers from synthetic-
274    // step paths (e.g. fused JUMPDEST) pass `None` and get `None` here.
275    let storage = storage_kv.map(|(key, value)| {
276        let mut m = BTreeMap::new();
277        m.insert(key, value);
278        m
279    });
280
281    // returnData: actual bytes when enabled; empty Bytes otherwise.
282    let return_data_field = if cfg.enable_return_data {
283        return_data.clone()
284    } else {
285        Bytes::new()
286    };
287
288    OpcodeStep {
289        pc,
290        op: opcode,
291        gas,
292        gas_cost,
293        mem_size,
294        depth,
295        return_data: return_data_field,
296        refund,
297        stack,
298        memory,
299        storage,
300        error: None,
301    }
302}