ethrex_levm/tracing.rs
1pub use crate::opcode_tracer::{LevmOpcodeTracer, OpcodeTracerConfig};
2use crate::{
3 errors::{ContextResult, InternalError, TxResult, VMError},
4 memory::CallData,
5 vm::VM,
6};
7use bytes::Bytes;
8use ethrex_common::{
9 Address, U256,
10 tracing::{CallLog, CallTraceFrame, CallType},
11 types::Log,
12};
13
14/// Geth's callTracer (https://geth.ethereum.org/docs/developers/evm-tracing/built-in-tracers)
15/// Use `LevmCallTracer::disabled()` when tracing is not wanted.
16#[derive(Debug, Default)]
17pub struct LevmCallTracer {
18 /// Stack for tracer callframes, at the end of execution there will be only one element.
19 pub callframes: Vec<CallTraceFrame>,
20 /// If true, trace only the top call (a.k.a. the external transaction)
21 pub only_top_call: bool,
22 /// If true, trace logs
23 pub with_log: bool,
24 /// If active is set to false it won't trace.
25 pub active: bool,
26 /// Next block-absolute log index to assign (geth's `log.Index`). Seeded with the
27 /// count of logs emitted by preceding txs in the block and bumped on each log.
28 pub next_log_index: u64,
29}
30
31impl LevmCallTracer {
32 /// `log_index_base` is the number of logs emitted by preceding txs in the block, so
33 /// the first log this tx traces gets geth's block-absolute index. Pass 0 when there
34 /// is no preceding context (e.g. the first tx, or `with_log` disabled).
35 pub fn new(only_top_call: bool, with_log: bool, log_index_base: u64) -> Self {
36 LevmCallTracer {
37 callframes: vec![],
38 only_top_call,
39 with_log,
40 active: true,
41 next_log_index: log_index_base,
42 }
43 }
44
45 /// This is to keep LEVM's code clean, like `self.tracer.enter(...)`,
46 /// instead of something more complex or uglier when we don't want to trace.
47 /// (For now that we only implement one tracer it may be the most convenient solution)
48 pub fn disabled() -> Self {
49 LevmCallTracer {
50 active: false,
51 ..Default::default()
52 }
53 }
54
55 /// Starts trace call.
56 pub fn enter(
57 &mut self,
58 call_type: CallType,
59 from: Address,
60 to: Address,
61 value: U256,
62 gas: u64,
63 input: &CallData,
64 ) -> Result<(), VMError> {
65 if !self.active {
66 return Ok(());
67 }
68
69 // geth traces STATICCALL with a nil value; every other call type carries one.
70 let value = if matches!(call_type, CallType::STATICCALL) {
71 None
72 } else {
73 Some(value)
74 };
75
76 let callframe = CallTraceFrame {
77 call_type,
78 from,
79 to: Some(to),
80 value,
81 gas,
82 input: input.to_bytes()?,
83 ..Default::default()
84 };
85
86 self.callframes.push(callframe);
87 Ok(())
88 }
89
90 /// Exits trace call.
91 /// Has no validations because it's a private method.
92 fn exit(
93 &mut self,
94 gas_used: u64,
95 output: Bytes,
96 error: Option<String>,
97 revert_reason: Option<String>,
98 ) -> Result<(), InternalError> {
99 let mut callframe = self.callframes.pop().ok_or(InternalError::CallFrame)?;
100
101 process_output(&mut callframe, gas_used, output, error, revert_reason);
102
103 // Append executed callframe to parent callframe if appropriate.
104 if let Some(parent_callframe) = self.callframes.last_mut() {
105 parent_callframe.calls.push(callframe);
106 } else {
107 self.callframes.push(callframe);
108 };
109 Ok(())
110 }
111
112 /// Exits trace call using the ContextResult.
113 pub fn exit_context(
114 &mut self,
115 ctx_result: &ContextResult,
116 is_top_call: bool,
117 ) -> Result<(), InternalError> {
118 if !self.active {
119 return Ok(());
120 }
121 if is_top_call {
122 // After finishing transaction execution clear all logs of callframes that reverted,
123 // then assign block-absolute indices to the survivors in emission order.
124 //
125 // The top frame's own `error` is still unset here — it is applied by the `exit`
126 // call at the end of this function, and moving the sweep after that is not an
127 // option because `exit` pops the frame. So its failure is read from
128 // `ctx_result` and seeded as the sweep's `parent_failed`; without that, a
129 // transaction whose top-level frame reverts would keep its logs, while geth
130 // drops them. Sub-frames are unaffected either way: they exited earlier, so
131 // their own `error` is already set.
132 let top_failed = matches!(ctx_result.result, TxResult::Revert(_));
133 clear_reverted_logs(self.current_callframe_mut()?, top_failed);
134 let mut next_index = self.next_log_index;
135 assign_log_indices(self.current_callframe_mut()?, &mut next_index);
136 self.next_log_index = next_index;
137 // onlyTopCall drops the subcalls only now: the full tree is needed above so
138 // the unreported nested logs still consume their block-absolute indices.
139 if self.only_top_call {
140 let callframe = self.current_callframe_mut()?;
141 callframe.calls.clear();
142 for log in &mut callframe.logs {
143 log.position = 0;
144 }
145 }
146 }
147 // The top-level frame reports the transaction's total gas used, matching the
148 // receipt (post-refund). geth does the same: `callstack[0].GasUsed = receipt.GasUsed`.
149 // `ctx_result.gas_used` is the EIP-7778 block-accounting value on Amsterdam+ and the
150 // pre-refund total elsewhere; `gas_spent` is always the post-refund amount the sender
151 // pays. Inner frames keep their own consumed gas (`gas_used`).
152 let gas_used = if is_top_call {
153 ctx_result.gas_spent
154 } else {
155 ctx_result.gas_used
156 };
157 // geth's `processOutput`: a successful frame carries the return data; a REVERT
158 // with data exposes the data and decodes any `Error(string)` revert reason; any
159 // other failure (exceptional halt / empty revert) carries neither.
160 let output = ctx_result.output.clone();
161 let (output, error, revert_reason) = match ctx_result.result {
162 TxResult::Revert(ref err) => {
163 let error = Some(geth_error_string(err));
164 if err.is_revert_opcode() && !output.is_empty() {
165 (output.clone(), error, decode_revert_reason(&output))
166 } else {
167 (Bytes::new(), error, None)
168 }
169 }
170 _ => (output, None, None),
171 };
172
173 self.exit(gas_used, output, error, revert_reason)
174 }
175
176 /// Exits trace call when CALL or CREATE opcodes return early or in case SELFDESTRUCT is called.
177 pub fn exit_early(
178 &mut self,
179 gas_used: u64,
180 error: Option<String>,
181 ) -> Result<(), InternalError> {
182 if !self.active {
183 return Ok(());
184 }
185 // Early-out reasons are internal tokens (e.g. "OutOfFund"); normalize to the
186 // geth-compatible error string the callTracer reports.
187 let error = error.map(|token| geth_error_from_token(&token));
188 self.exit(gas_used, Bytes::new(), error, None)
189 }
190
191 /// Registers log when opcode log is executed.
192 /// Note: Logs of callframes that reverted will be removed at end of execution.
193 pub fn log(&mut self, log: &Log) -> Result<(), InternalError> {
194 if !self.active || !self.with_log {
195 return Ok(());
196 }
197 let callframe = self.current_callframe_mut()?;
198
199 let log = CallLog {
200 address: log.address,
201 topics: log.topics.clone(),
202 data: log.data.clone(),
203 // Placeholder: the block-absolute index is assigned in `assign_log_indices`
204 // after reverted logs are pruned, so surviving logs stay gap-free like geth.
205 index: 0,
206 position: match callframe.calls.len().try_into() {
207 Ok(pos) => pos,
208 Err(_) => return Err(InternalError::TypeConversion),
209 },
210 };
211
212 callframe.logs.push(log);
213 Ok(())
214 }
215
216 fn current_callframe_mut(&mut self) -> Result<&mut CallTraceFrame, InternalError> {
217 self.callframes.last_mut().ok_or(InternalError::CallFrame)
218 }
219}
220
221fn process_output(
222 callframe: &mut CallTraceFrame,
223 gas_used: u64,
224 output: Bytes,
225 error: Option<String>,
226 revert_reason: Option<String>,
227) {
228 callframe.gas_used = gas_used;
229 callframe.output = output;
230 // geth drops `to` on a failed CREATE/CREATE2 (no contract was deployed).
231 if error.is_some() && matches!(callframe.call_type, CallType::CREATE | CallType::CREATE2) {
232 callframe.to = None;
233 }
234 callframe.error = error;
235 callframe.revert_reason = revert_reason;
236}
237
238/// Maps a LEVM [`VMError`] to the error string geth's callTracer emits (from
239/// `core/vm/errors.go`).
240///
241/// The static errors match geth byte-for-byte. `stack underflow`, `stack overflow`
242/// and `invalid opcode` are geth-formatted with operands (`stack underflow (1 <=> 2)`,
243/// `invalid opcode: STOP`) that LEVM's unit variants don't carry, so we emit geth's
244/// base wording for those. Variants without a geth analogue keep LEVM's own message.
245fn geth_error_string(err: &VMError) -> String {
246 use crate::errors::ExceptionalHalt::*;
247 let mapped = match err {
248 VMError::RevertOpcode => "execution reverted",
249 VMError::ExceptionalHalt(halt) => match halt {
250 OutOfGas => "out of gas",
251 InvalidJump => "invalid jump destination",
252 OpcodeNotAllowedInStaticContext => "write protection",
253 AddressAlreadyOccupied => "contract address collision",
254 ContractOutputTooBig => "max code size exceeded",
255 InvalidContractPrefix => "invalid code: must not begin with 0xef",
256 // geth formats these with operands we don't carry — emit its base wording.
257 StackUnderflow => "stack underflow",
258 StackOverflow => "stack overflow",
259 InvalidOpcode => "invalid opcode",
260 // No direct geth analogue; keep LEVM's message.
261 VeryLargeNumber | OutOfBounds | Precompile(_) => return err.to_string(),
262 },
263 _ => return err.to_string(),
264 };
265 mapped.to_string()
266}
267
268/// Maps a CALL/CREATE early-out token (passed to [`LevmCallTracer::exit_early`]) to
269/// the geth-compatible error string the callTracer reports.
270fn geth_error_from_token(token: &str) -> String {
271 match token {
272 "OutOfFund" => "insufficient balance for transfer",
273 "MaxDepth" => "max call depth exceeded",
274 "MaxNonce" => "nonce uint64 overflow",
275 "CreateAccExists" => "contract address collision",
276 other => return other.to_string(),
277 }
278 .to_string()
279}
280
281/// Decodes an ABI-encoded `Error(string)` revert payload into its message, mirroring
282/// geth's `abi.UnpackRevert`. Returns `None` for any payload that isn't a canonical
283/// `Error(string)` (e.g. `Panic(uint256)` or custom errors), matching geth.
284fn decode_revert_reason(output: &[u8]) -> Option<String> {
285 // selector("Error(string)") = 0x08c379a0
286 const SELECTOR: [u8; 4] = [0x08, 0xc3, 0x79, 0xa0];
287 if output.get(..4)? != SELECTOR {
288 return None;
289 }
290 let word_as_usize = |start: usize| -> Option<usize> {
291 let word = output.get(start..start.checked_add(32)?)?;
292 usize::try_from(U256::from_big_endian(word)).ok()
293 };
294 // head: offset to the string's dynamic-data region (relative to the args block,
295 // i.e. after the 4-byte selector).
296 let len_start = 4usize.checked_add(word_as_usize(4)?)?;
297 let len = word_as_usize(len_start)?;
298 let data_start = len_start.checked_add(32)?;
299 let data = output.get(data_start..data_start.checked_add(len)?)?;
300 String::from_utf8(data.to_vec()).ok()
301}
302
303/// Assigns each surviving log its block-absolute `index` (geth's `log.Index`) by walking
304/// the frame tree in emission order and counting from `*next`. A log with `position == p`
305/// was emitted after `p` subcalls completed, so within a frame the order is: logs at
306/// position 0, subcall 0, logs at position 1, subcall 1, …, trailing logs. Because
307/// reverted logs were already pruned, the survivors get contiguous, gap-free indices —
308/// matching geth, which decrements `logSize` on revert.
309fn assign_log_indices(callframe: &mut CallTraceFrame, next: &mut u64) {
310 let mut logs = callframe.logs.iter_mut().peekable();
311 for (call_idx, subcall) in callframe.calls.iter_mut().enumerate() {
312 let call_idx = u64::try_from(call_idx).unwrap_or(u64::MAX);
313 while let Some(log) = logs.next_if(|l| l.position == call_idx) {
314 log.index = *next;
315 *next = next.saturating_add(1);
316 }
317 assign_log_indices(subcall, next);
318 }
319 for log in logs {
320 log.index = *next;
321 *next = next.saturating_add(1);
322 }
323}
324
325/// Clears logs of any frame that failed *or* whose ancestor failed, since a reverted
326/// frame rolls back its whole subtree's state (including subcall logs). Mirrors geth's
327/// `clearFailedLogs`, which threads a `parentFailed` flag down the tree.
328fn clear_reverted_logs(callframe: &mut CallTraceFrame, parent_failed: bool) {
329 let failed = parent_failed || callframe.error.is_some();
330 if failed {
331 callframe.logs.clear();
332 }
333 for subcall in &mut callframe.calls {
334 clear_reverted_logs(subcall, failed);
335 }
336}
337
338impl<'a> VM<'a> {
339 /// This method is intended to be accessed after transaction execution
340 pub fn get_trace_result(&mut self) -> Result<CallTraceFrame, VMError> {
341 self.tracer
342 .callframes
343 .pop()
344 .ok_or(InternalError::CallFrame.into())
345 }
346}