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