Skip to main content

cast/
rpc_trace.rs

1//! Conversion from geth `callTracer` output into a [`CallTraceArena`].
2//!
3//! This lets traces fetched over RPC (via `debug_traceCall` / `debug_traceTransaction` with the
4//! `callTracer`) be decoded and rendered with the same machinery used for locally executed traces.
5//! `callTracer` does not record opcode-level steps, so [`CallTrace::steps`] is left empty;
6//! everything the call-tree view needs (calls, value, gas, logs, revert reasons) is preserved.
7//!
8//! Also hosts the shared classification of the RPC rejections a `debug_trace*` request can hit,
9//! so `cast call --debug-trace-call` and `cast run --debug-trace-transaction` surface the same
10//! actionable hints.
11
12use alloy_primitives::{Address, Bytes, LogData, U256};
13use alloy_rpc_types::trace::geth::{CallFrame, CallLogFrame};
14use alloy_transport::TransportError;
15use foundry_evm::traces::{
16    CallKind, CallLog, CallTrace, CallTraceArena, CallTraceNode, TraceMemberOrder,
17};
18use revm::interpreter::InstructionResult;
19
20/// Builds a [`CallTraceArena`] from a geth `callTracer` [`CallFrame`] tree.
21pub fn call_frame_to_arena(root: &CallFrame) -> CallTraceArena {
22    call_frame_to_arena_with_root_address(root, None)
23}
24
25/// Builds a [`CallTraceArena`] and overrides the root frame's address when the tracer omitted it.
26pub fn call_frame_to_arena_with_root_address(
27    root: &CallFrame,
28    root_address: Option<Address>,
29) -> CallTraceArena {
30    let mut arena = CallTraceArena::default();
31    let nodes = arena.nodes_mut();
32    nodes.clear();
33    push_frame(nodes, root, None, 0);
34    if let Some(root_address) = root_address
35        && let Some(root) = nodes.first_mut()
36        && root.trace.address.is_zero()
37    {
38        root.trace.address = root_address;
39    }
40    arena
41}
42
43/// Returns `true` if `err` is a JSON-RPC method-not-found rejection (code -32601), which is how
44/// nodes without the `debug` namespace reject `debug_trace*` requests.
45pub fn is_method_not_found_error(err: &TransportError) -> bool {
46    err.as_error_resp().is_some_and(|resp| resp.code == -32601)
47}
48
49/// Returns `true` if `err` looks like a missing-historical-state rejection: an archive-depth
50/// error, usually with a generic code (-32000) distinguishable only by message, hit whenever a
51/// `debug_trace*` request targets a block whose state a full node has pruned.
52pub fn is_missing_state_error(err: &TransportError) -> bool {
53    let message = err
54        .as_error_resp()
55        .map(|resp| resp.message.to_ascii_lowercase())
56        .unwrap_or_else(|| err.to_string().to_ascii_lowercase());
57    [
58        "missing trie node",
59        "required historical state",
60        "historical state",
61        "header not found",
62        "missing state",
63    ]
64    .iter()
65    .any(|needle| message.contains(*needle))
66}
67
68/// Pushes `frame` and all of its children into `nodes`, returning the index of the pushed node.
69fn push_frame(
70    nodes: &mut Vec<CallTraceNode>,
71    frame: &CallFrame,
72    parent: Option<usize>,
73    depth: usize,
74) -> usize {
75    let idx = nodes.len();
76
77    let success = frame.error.is_none() && frame.revert_reason.is_none();
78
79    // A `SELFDESTRUCT` frame is not an ordinary call: geth encodes `from` as the destructed
80    // contract, `to` as the refund target and `value` as the transferred balance (the inverse of
81    // `CallTraceNode::geth_selfdestruct_call_trace`). Mirror the local trace representation, which
82    // records the selfdestruct through the dedicated fields and an
83    // `InstructionResult::SelfDestruct` status, so the destructed contract (not the
84    // beneficiary) is identified and `is_selfdestruct` holds. The transferred balance lives in
85    // `selfdestruct_transferred_value`, so the call `value` stays zero.
86    let is_selfdestruct = frame.typ == "SELFDESTRUCT";
87    let status = if is_selfdestruct {
88        Some(InstructionResult::SelfDestruct)
89    } else {
90        Some(status_from_frame(frame))
91    };
92
93    // `callTracer` reports an unclassified halt (invalid opcode, a provider-specific quirk) only in
94    // the `error` string. When the frame failed but returned no data, surface that string (or the
95    // decoded `revert_reason`, preferred) as the output so the renderer shows it instead of a
96    // coarse `EvmError: Revert`.
97    let mut output = frame.output.clone().unwrap_or_default();
98    if output.is_empty()
99        && !success
100        && let Some(text) = frame.revert_reason.as_deref().or(frame.error.as_deref())
101    {
102        output = Bytes::copy_from_slice(text.as_bytes());
103    }
104
105    let trace = CallTrace {
106        depth,
107        success,
108        caller: frame.from,
109        address: if is_selfdestruct { frame.from } else { frame.to.unwrap_or_default() },
110        maybe_precompile: None,
111        selfdestruct_address: is_selfdestruct.then_some(frame.from),
112        selfdestruct_refund_target: if is_selfdestruct { frame.to } else { None },
113        selfdestruct_transferred_value: if is_selfdestruct { frame.value } else { None },
114        kind: call_kind(&frame.typ),
115        value: if is_selfdestruct { U256::ZERO } else { frame.value.unwrap_or_default() },
116        data: frame.input.clone(),
117        output,
118        gas_used: frame.gas_used.saturating_to(),
119        gas_limit: frame.gas.saturating_to(),
120        gas_refund_counter: 0,
121        status,
122        steps: Vec::new(),
123        decoded: None,
124    };
125
126    let logs = frame.logs.iter().map(call_log).collect::<Vec<_>>();
127
128    nodes.push(CallTraceNode {
129        parent,
130        children: Vec::new(),
131        idx,
132        trace,
133        logs,
134        ordering: Vec::new(),
135    });
136
137    let mut children = Vec::with_capacity(frame.calls.len());
138    for child in &frame.calls {
139        children.push(push_frame(nodes, child, Some(idx), depth + 1));
140    }
141
142    // Reconstruct the interleaving of logs and child calls in linear time. A log's `position` is
143    // the number of child calls emitted before it, so bucketing the logs by position places each
144    // one after that many calls. `TraceMemberOrder::Call`/`Log` index into the node's local
145    // `children`/`logs` vectors. A position past the last call is clamped to the end so the log is
146    // never dropped.
147    let num_calls = children.len();
148    let mut logs_by_position: Vec<Vec<usize>> = vec![Vec::new(); num_calls + 1];
149    for (li, log) in frame.logs.iter().enumerate() {
150        let position = (log.position.unwrap_or(0) as usize).min(num_calls);
151        logs_by_position[position].push(li);
152    }
153    let mut ordering = Vec::with_capacity(num_calls + frame.logs.len());
154    for (i, logs_at_position) in logs_by_position.iter().enumerate() {
155        for &li in logs_at_position {
156            ordering.push(TraceMemberOrder::Log(li));
157        }
158        if i < num_calls {
159            ordering.push(TraceMemberOrder::Call(i));
160        }
161    }
162
163    nodes[idx].children = children;
164    nodes[idx].ordering = ordering;
165    idx
166}
167
168/// Maps a `callTracer` frame to the [`InstructionResult`] used for the rendered `[status]` label.
169///
170/// `callTracer` only exposes a coarse, human-readable `error` string (plus an optional
171/// `revert_reason`), not a machine status code, so we recognise the two halts geth and reth report
172/// reliably (an explicit revert and running out of gas) and fall back to
173/// [`InstructionResult::Revert`] for anything else. `push_frame` preserves the frame's `error` /
174/// `revert_reason` text in the trace output, and the call is coloured by [`CallTrace::success`], so
175/// an imperfect status never hides a failure or the original error message.
176fn status_from_frame(frame: &CallFrame) -> InstructionResult {
177    if frame.error.is_none() && frame.revert_reason.is_none() {
178        return InstructionResult::Return;
179    }
180    if frame.revert_reason.is_some() {
181        return InstructionResult::Revert;
182    }
183    match frame.error.as_deref() {
184        Some(err) if err.contains("out of gas") => InstructionResult::OutOfGas,
185        // "execution reverted" and any other unclassified halt render as a revert.
186        _ => InstructionResult::Revert,
187    }
188}
189
190/// Maps a geth `callTracer` call type string to a [`CallKind`].
191fn call_kind(typ: &str) -> CallKind {
192    match typ {
193        "STATICCALL" => CallKind::StaticCall,
194        "DELEGATECALL" => CallKind::DelegateCall,
195        "CALLCODE" => CallKind::CallCode,
196        "AUTHCALL" => CallKind::AuthCall,
197        "CREATE" => CallKind::Create,
198        "CREATE2" => CallKind::Create2,
199        // "CALL", "SELFDESTRUCT" and anything unknown render as a plain call.
200        _ => CallKind::Call,
201    }
202}
203
204/// Maps a geth `callTracer` log frame to a [`CallLog`].
205fn call_log(log: &CallLogFrame) -> CallLog {
206    CallLog {
207        address: log.address.unwrap_or_default(),
208        raw_log: LogData::new_unchecked(
209            log.topics.clone().unwrap_or_default(),
210            log.data.clone().unwrap_or_default(),
211        ),
212        decoded: None,
213        position: log.position.unwrap_or_default(),
214        index: log.index.unwrap_or_default(),
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use alloy_primitives::{address, b256, bytes};
222
223    /// A geth `callTracer` `SELFDESTRUCT` frame encodes `from` as the destructed contract, `to` as
224    /// the refund target and `value` as the transferred balance (the inverse of
225    /// `CallTraceNode::geth_selfdestruct_call_trace`). It must convert into a node that identifies
226    /// the destructed contract (not the beneficiary) and carries the selfdestruct fields, so
227    /// `is_selfdestruct()` holds and the status renders as `[SelfDestruct]`.
228    #[test]
229    fn converts_selfdestruct_frame() {
230        let destructed = address!("1111111111111111111111111111111111111111");
231        let beneficiary = address!("2222222222222222222222222222222222222222");
232        let frame = CallFrame {
233            from: destructed,
234            to: Some(beneficiary),
235            value: Some(U256::from(9u64)),
236            typ: "SELFDESTRUCT".to_string(),
237            ..Default::default()
238        };
239
240        let arena = call_frame_to_arena(&frame);
241        let trace = &arena.nodes()[0].trace;
242
243        // The destructed contract is the identified address, not the refund target.
244        assert_eq!(trace.address, destructed);
245        assert_eq!(trace.selfdestruct_address, Some(destructed));
246        assert_eq!(trace.selfdestruct_refund_target, Some(beneficiary));
247        assert_eq!(trace.selfdestruct_transferred_value, Some(U256::from(9u64)));
248        assert_eq!(trace.status, Some(InstructionResult::SelfDestruct));
249        assert!(trace.is_selfdestruct());
250    }
251
252    #[test]
253    fn fills_missing_root_create_address() {
254        let created = address!("3333333333333333333333333333333333333333");
255        let frame = CallFrame { typ: "CREATE".to_string(), ..Default::default() };
256
257        let arena = call_frame_to_arena_with_root_address(&frame, Some(created));
258
259        assert_eq!(arena.nodes()[0].trace.address, created);
260        assert_eq!(arena.nodes()[0].trace.kind, CallKind::Create);
261    }
262
263    /// A nested `callTracer` frame (root CALL -> child STATICCALL) with a log on the root,
264    /// mirroring a real `debug_traceCall` response, must convert into a well-formed two-node
265    /// arena.
266    #[test]
267    fn converts_nested_call_frame() {
268        let frame = CallFrame {
269            from: address!("1111111111111111111111111111111111111111"),
270            to: Some(address!("2222222222222222222222222222222222222222")),
271            gas: U256::from(100_000u64),
272            gas_used: U256::from(21_000u64),
273            input: bytes!("dead"),
274            output: Some(bytes!("beef")),
275            value: Some(U256::from(7u64)),
276            typ: "CALL".to_string(),
277            logs: vec![CallLogFrame {
278                address: Some(address!("2222222222222222222222222222222222222222")),
279                topics: Some(vec![]),
280                data: Some(bytes!("00")),
281                position: Some(1),
282                index: Some(0),
283            }],
284            calls: vec![CallFrame {
285                from: address!("2222222222222222222222222222222222222222"),
286                to: Some(address!("3333333333333333333333333333333333333333")),
287                gas: U256::from(50_000u64),
288                gas_used: U256::from(5_000u64),
289                input: bytes!("cafe"),
290                typ: "STATICCALL".to_string(),
291                ..Default::default()
292            }],
293            ..Default::default()
294        };
295
296        let arena = call_frame_to_arena(&frame);
297        let nodes = arena.nodes();
298        assert_eq!(nodes.len(), 2, "root + one child");
299
300        let root = &nodes[0];
301        assert_eq!(root.parent, None);
302        assert_eq!(root.children, vec![1]);
303        assert_eq!(root.trace.kind, CallKind::Call);
304        assert_eq!(root.trace.caller, frame.from);
305        assert_eq!(root.trace.value, U256::from(7u64));
306        assert_eq!(root.trace.gas_used, 21_000);
307        assert!(root.trace.success);
308        assert_eq!(root.logs.len(), 1);
309
310        // The log has position 1, so it must be ordered after the single child call.
311        assert_eq!(root.ordering, vec![TraceMemberOrder::Call(0), TraceMemberOrder::Log(0)]);
312
313        let child = &nodes[1];
314        assert_eq!(child.parent, Some(0));
315        assert_eq!(child.trace.depth, 1);
316        assert_eq!(child.trace.kind, CallKind::StaticCall);
317    }
318
319    /// `callTracer` error strings must map onto the status used for the rendered `[status]` label:
320    /// a clean call returns, an explicit revert and a `revert_reason` map to `Revert`, an
321    /// out-of-gas halt maps to `OutOfGas`, and any other halt falls back to `Revert`.
322    #[test]
323    fn maps_frame_status() {
324        let ok = CallFrame { typ: "CALL".to_string(), ..Default::default() };
325        assert_eq!(status_from_frame(&ok), InstructionResult::Return);
326
327        let reverted = CallFrame {
328            typ: "CALL".to_string(),
329            error: Some("execution reverted".to_string()),
330            revert_reason: Some("boom".to_string()),
331            ..Default::default()
332        };
333        assert_eq!(status_from_frame(&reverted), InstructionResult::Revert);
334
335        let oog = CallFrame {
336            typ: "CALL".to_string(),
337            error: Some("out of gas".to_string()),
338            ..Default::default()
339        };
340        assert_eq!(status_from_frame(&oog), InstructionResult::OutOfGas);
341
342        let other = CallFrame {
343            typ: "CALL".to_string(),
344            error: Some("invalid opcode: opcode 0xfe not defined".to_string()),
345            ..Default::default()
346        };
347        assert_eq!(status_from_frame(&other), InstructionResult::Revert);
348    }
349
350    /// An unclassified halt with no return data (e.g. an invalid opcode) must keep its original
351    /// `error` string as the trace output, so the renderer surfaces it instead of a coarse
352    /// `EvmError: Revert`.
353    #[test]
354    fn surfaces_error_string_in_output() {
355        let frame = CallFrame {
356            from: address!("1111111111111111111111111111111111111111"),
357            to: Some(address!("2222222222222222222222222222222222222222")),
358            typ: "CALL".to_string(),
359            error: Some("invalid opcode: opcode 0xfe not defined".to_string()),
360            ..Default::default()
361        };
362
363        let arena = call_frame_to_arena(&frame);
364        let root = &arena.nodes()[0];
365
366        assert!(!root.trace.success);
367        assert_eq!(
368            core::str::from_utf8(&root.trace.output[..]).unwrap(),
369            "invalid opcode: opcode 0xfe not defined"
370        );
371    }
372
373    /// A log whose `position` points past the last child call must be clamped to the end rather
374    /// than dropped, and a `position` of zero must order the log before the first call.
375    #[test]
376    fn clamps_out_of_range_log_position() {
377        let frame = CallFrame {
378            from: address!("1111111111111111111111111111111111111111"),
379            to: Some(address!("2222222222222222222222222222222222222222")),
380            typ: "CALL".to_string(),
381            logs: vec![
382                CallLogFrame { position: Some(0), index: Some(0), ..Default::default() },
383                CallLogFrame { position: Some(5), index: Some(1), ..Default::default() },
384            ],
385            calls: vec![CallFrame { typ: "CALL".to_string(), ..Default::default() }],
386            ..Default::default()
387        };
388
389        let arena = call_frame_to_arena(&frame);
390        let root = &arena.nodes()[0];
391
392        assert_eq!(root.logs.len(), 2, "no log dropped");
393        // position 0 -> before the only call; position 5 -> clamped to after it.
394        assert_eq!(
395            root.ordering,
396            vec![TraceMemberOrder::Log(0), TraceMemberOrder::Call(0), TraceMemberOrder::Log(1),]
397        );
398    }
399
400    /// A log whose `position` falls strictly between two child calls must be ordered between them.
401    /// The single-child cases above only exercise before-first and after-last, so an off-by-one in
402    /// the `Call(i)` / `Log(li)` indexing would otherwise go unnoticed.
403    #[test]
404    fn orders_log_between_two_children() {
405        let frame = CallFrame {
406            from: address!("1111111111111111111111111111111111111111"),
407            to: Some(address!("2222222222222222222222222222222222222222")),
408            typ: "CALL".to_string(),
409            // position 1 -> one child emitted before the log, so it lands between the two children.
410            logs: vec![CallLogFrame { position: Some(1), index: Some(0), ..Default::default() }],
411            calls: vec![
412                CallFrame { typ: "CALL".to_string(), ..Default::default() },
413                CallFrame { typ: "CALL".to_string(), ..Default::default() },
414            ],
415            ..Default::default()
416        };
417
418        let arena = call_frame_to_arena(&frame);
419        let root = &arena.nodes()[0];
420
421        assert_eq!(arena.nodes().len(), 3, "root + two children");
422        assert_eq!(root.children, vec![1, 2]);
423        assert_eq!(
424            root.ordering,
425            vec![TraceMemberOrder::Call(0), TraceMemberOrder::Log(0), TraceMemberOrder::Call(1),]
426        );
427    }
428
429    /// `call_kind` must map every geth `callTracer` call-type string to the right `CallKind`, and
430    /// treat anything unknown as a plain call. The conversion tests only exercise
431    /// `CALL`/`STATICCALL`, so a swapped or dropped arm would otherwise go unnoticed.
432    #[test]
433    fn maps_call_kind() {
434        assert_eq!(call_kind("CALL"), CallKind::Call);
435        assert_eq!(call_kind("STATICCALL"), CallKind::StaticCall);
436        assert_eq!(call_kind("DELEGATECALL"), CallKind::DelegateCall);
437        assert_eq!(call_kind("CALLCODE"), CallKind::CallCode);
438        assert_eq!(call_kind("AUTHCALL"), CallKind::AuthCall);
439        assert_eq!(call_kind("CREATE"), CallKind::Create);
440        assert_eq!(call_kind("CREATE2"), CallKind::Create2);
441        // "SELFDESTRUCT" and any unknown type render as a plain call.
442        assert_eq!(call_kind("SELFDESTRUCT"), CallKind::Call);
443        assert_eq!(call_kind("NOT_A_REAL_TYPE"), CallKind::Call);
444    }
445
446    /// `call_log` must map each `callTracer` log field to the right place. Distinct topics, data,
447    /// position and index catch a swapped or dropped field (e.g. topics/data or position/index).
448    #[test]
449    fn maps_call_log_fields() {
450        let frame_log = CallLogFrame {
451            address: Some(address!("3333333333333333333333333333333333333333")),
452            topics: Some(vec![
453                b256!("0x00000000000000000000000000000000000000000000000000000000000000aa"),
454                b256!("0x00000000000000000000000000000000000000000000000000000000000000bb"),
455            ]),
456            data: Some(bytes!("dead")),
457            position: Some(2),
458            index: Some(5),
459        };
460
461        let log = call_log(&frame_log);
462        assert_eq!(log.address, address!("3333333333333333333333333333333333333333"));
463        assert_eq!(
464            log.raw_log.topics(),
465            &[
466                b256!("0x00000000000000000000000000000000000000000000000000000000000000aa"),
467                b256!("0x00000000000000000000000000000000000000000000000000000000000000bb"),
468            ]
469        );
470        assert_eq!(log.raw_log.data, bytes!("dead"));
471        assert_eq!(log.position, 2);
472        assert_eq!(log.index, 5);
473    }
474}