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