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}