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}