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