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