Skip to main content

foundry_evm_traces/speedscope/
builder.rs

1//! Speedscope profile generation for EVM execution traces.
2//!
3//! This module converts EVM execution traces into the speedscope evented profile format.
4//! Gas consumption is used as the value unit, so flame graph widths represent gas usage.
5
6use super::schema::{EventedProfile, Frame, Profile, SpeedscopeFile, ValueUnit};
7use alloy_primitives::{hex::ToHexExt, map::HashMap};
8use revm_inspectors::tracing::{
9    CallTraceArena,
10    types::{CallTraceNode, CallTraceStep, DecodedTraceStep, TraceMemberOrder},
11};
12use std::borrow::Cow;
13
14struct SpeedscopeBuilder<'a> {
15    file: SpeedscopeFile<'a>,
16    profile: EventedProfile<'a>,
17    frame_cache: HashMap<String, usize>,
18    cumulative_gas: u64,
19}
20
21impl<'a> SpeedscopeBuilder<'a> {
22    fn new(name: String) -> Self {
23        Self {
24            file: SpeedscopeFile::new(name.clone()),
25            profile: EventedProfile::new(name, ValueUnit::None),
26            frame_cache: HashMap::default(),
27            cumulative_gas: 0,
28        }
29    }
30
31    fn build(mut self) -> SpeedscopeFile<'a> {
32        self.profile.set_end_value(self.cumulative_gas);
33        self.file.add_profile(Profile::Evented(self.profile));
34        self.file
35    }
36
37    fn process_call_node(&mut self, nodes: &[CallTraceNode], idx: usize) {
38        let node = &nodes[idx];
39        let frame_idx = self.frame_idx(call_frame_name(node));
40        let start_gas = self.cumulative_gas;
41        // Call gas is gross: refunds are only subtracted from the test's reported gas.
42        let gas_used = node.trace.gas_used;
43
44        self.profile.open_frame(frame_idx, self.cumulative_gas);
45
46        let mut step_exits = Vec::new();
47        for (order_idx, order) in node.ordering.iter().enumerate() {
48            match order {
49                TraceMemberOrder::Step(step_idx) => {
50                    self.exit_previous_steps(&mut step_exits, *step_idx);
51                    let is_call_step =
52                        matches!(node.ordering.get(order_idx + 1), Some(TraceMemberOrder::Call(_)));
53                    self.process_step(&node.trace.steps, *step_idx, is_call_step, &mut step_exits);
54                }
55                TraceMemberOrder::Call(child_idx) => {
56                    let child_node_idx = node.children[*child_idx];
57                    self.process_call_node(nodes, child_node_idx);
58                }
59                TraceMemberOrder::Log(_) => {}
60            }
61        }
62
63        while let Some(step_exit) = step_exits.pop() {
64            self.profile.close_frame(step_exit.frame_idx, self.cumulative_gas);
65        }
66
67        let consumed = self.cumulative_gas.saturating_sub(start_gas);
68        self.cumulative_gas = self.cumulative_gas.saturating_add(gas_used.saturating_sub(consumed));
69        self.profile.close_frame(frame_idx, self.cumulative_gas);
70    }
71
72    fn process_step(
73        &mut self,
74        steps: &[CallTraceStep],
75        step_idx: usize,
76        skip_gas: bool,
77        step_exits: &mut Vec<StepExit>,
78    ) {
79        let Some(step) = steps.get(step_idx) else { return };
80
81        if let Some(DecodedTraceStep::InternalCall(decoded, step_end_idx)) = step.decoded.as_deref()
82        {
83            let frame_idx = self.frame_idx(decoded.func_name.clone());
84            self.profile.open_frame(frame_idx, self.cumulative_gas);
85            step_exits.push(StepExit { step_idx: *step_end_idx, frame_idx });
86        }
87
88        if !skip_gas {
89            self.cumulative_gas = self.cumulative_gas.saturating_add(step.gas_cost);
90        }
91    }
92
93    fn exit_previous_steps(&mut self, step_exits: &mut Vec<StepExit>, step_idx: usize) {
94        while step_exits.last().is_some_and(|exit| exit.step_idx <= step_idx) {
95            let step_exit = step_exits.pop().unwrap();
96            self.profile.close_frame(step_exit.frame_idx, self.cumulative_gas);
97        }
98    }
99
100    fn frame_idx(&mut self, name: String) -> usize {
101        if let Some(idx) = self.frame_cache.get(name.as_str()) {
102            return *idx;
103        }
104
105        let idx = self.file.add_frame(Frame::new(Cow::Owned(name.clone())));
106        self.frame_cache.insert(name, idx);
107        idx
108    }
109}
110
111struct StepExit {
112    step_idx: usize,
113    frame_idx: usize,
114}
115
116/// Builds a speedscope profile from a call trace arena.
117///
118/// Walks the trace arena directly so the Time Order view preserves execution order.
119pub fn build<'a>(
120    arena: &CallTraceArena,
121    test_name: &str,
122    contract_name: &str,
123) -> SpeedscopeFile<'a> {
124    let name = format!("{contract_name}::{test_name}");
125    let mut builder = SpeedscopeBuilder::new(name);
126
127    if !arena.nodes().is_empty() {
128        builder.process_call_node(arena.nodes(), 0);
129    }
130
131    builder.build()
132}
133
134fn call_frame_name(node: &CallTraceNode) -> String {
135    if node.trace.kind.is_any_create() {
136        let contract_name =
137            node.trace.decoded.as_ref().and_then(|dc| dc.label.as_deref()).unwrap_or("Contract");
138        return format!("new {contract_name}");
139    }
140
141    let selector = node
142        .selector()
143        .map(|selector| selector.encode_hex_with_prefix())
144        .unwrap_or_else(|| "fallback".to_string());
145    let signature = node
146        .trace
147        .decoded
148        .as_ref()
149        .and_then(|dc| dc.call_data.as_ref())
150        .map(|dc| &dc.signature)
151        .unwrap_or(&selector);
152
153    if let Some(label) = node.trace.decoded.as_ref().and_then(|dc| dc.label.as_ref()) {
154        format!("{label}.{signature}")
155    } else {
156        signature.clone()
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use crate::{CallKind, CallTrace, DecodedCallData, DecodedCallTrace};
164    use revm::{bytecode::opcode::OpCode, interpreter::InstructionResult};
165    use snapbox::prelude::*;
166
167    fn trace_step(gas_cost: u64) -> CallTraceStep {
168        CallTraceStep {
169            pc: 0,
170            op: OpCode::STOP,
171            stack: None,
172            push_stack: None,
173            memory: None,
174            returndata: Default::default(),
175            gas_remaining: 0,
176            gas_refund_counter: 0,
177            gas_used: 0,
178            gas_cost,
179            state_gas_cost: None,
180            state_gas_reservoir: None,
181            state_gas_spent: 0,
182            storage_change: None,
183            status: Some(InstructionResult::Stop),
184            immediate_bytes: None,
185            decoded: None,
186        }
187    }
188
189    fn decoded_call(label: &str, signature: &str) -> Option<Box<DecodedCallTrace>> {
190        Some(Box::new(DecodedCallTrace {
191            label: Some(label.to_string()),
192            call_data: Some(DecodedCallData { signature: signature.to_string(), args: vec![] }),
193            return_data: None,
194        }))
195    }
196
197    #[test]
198    fn test_empty_profile() {
199        let arena = CallTraceArena::default();
200        let profile = build(&arena, "testExample", "TestContract");
201        let json = serde_json::to_string_pretty(&profile).unwrap();
202
203        snapbox::assert_data_eq!(
204            json.is_json(),
205            (snapbox::str![[r#"
206{
207  "$schema": "https://www.speedscope.app/file-format-schema.json",
208  "shared": {
209    "frames": [
210      {
211        "name": "fallback"
212      }
213    ]
214  },
215  "profiles": [
216    {
217      "type": "evented",
218      "name": "TestContract::testExample",
219      "unit": "none",
220      "startValue": 0,
221      "endValue": 0,
222      "events": [
223        {
224          "type": "O",
225          "frame": 0,
226          "at": 0
227        },
228        {
229          "type": "C",
230          "frame": 0,
231          "at": 0
232        }
233      ]
234    }
235  ],
236  "name": "TestContract::testExample",
237  "exporter": "foundry"
238}
239"#]])
240            .is_json(),
241        );
242    }
243
244    #[test]
245    fn test_build_preserves_parent_work_ordering() {
246        let mut arena = CallTraceArena::default();
247        {
248            let root = &mut arena.nodes_mut()[0];
249            root.trace = CallTrace {
250                kind: CallKind::Call,
251                gas_used: 400,
252                steps: vec![
253                    trace_step(20),
254                    trace_step(1_000_000),
255                    trace_step(30),
256                    trace_step(1_000_000),
257                    trace_step(50),
258                ],
259                decoded: decoded_call("Parent", "run()"),
260                ..Default::default()
261            };
262            root.children = vec![1, 2];
263            root.ordering = vec![
264                TraceMemberOrder::Step(0),
265                TraceMemberOrder::Step(1),
266                TraceMemberOrder::Call(0),
267                TraceMemberOrder::Step(2),
268                TraceMemberOrder::Step(3),
269                TraceMemberOrder::Call(1),
270                TraceMemberOrder::Step(4),
271            ];
272        }
273
274        arena.nodes_mut().push(CallTraceNode {
275            parent: Some(0),
276            idx: 1,
277            trace: CallTrace {
278                depth: 1,
279                kind: CallKind::Call,
280                gas_used: 100,
281                decoded: decoded_call("Child", "first()"),
282                ..Default::default()
283            },
284            ..Default::default()
285        });
286        arena.nodes_mut().push(CallTraceNode {
287            parent: Some(0),
288            idx: 2,
289            trace: CallTrace {
290                depth: 1,
291                kind: CallKind::Call,
292                gas_used: 200,
293                decoded: decoded_call("Child", "second()"),
294                ..Default::default()
295            },
296            ..Default::default()
297        });
298
299        let file = build(&arena, "test", "Test");
300        let json = serde_json::to_string_pretty(&file).unwrap();
301
302        snapbox::assert_data_eq!(
303            json.is_json(),
304            (snapbox::str![[r#"
305{
306  "$schema": "https://www.speedscope.app/file-format-schema.json",
307  "shared": {
308    "frames": [
309      {
310        "name": "Parent.run()"
311      },
312      {
313        "name": "Child.first()"
314      },
315      {
316        "name": "Child.second()"
317      }
318    ]
319  },
320  "profiles": [
321    {
322      "type": "evented",
323      "name": "Test::test",
324      "unit": "none",
325      "startValue": 0,
326      "endValue": 400,
327      "events": [
328        {
329          "type": "O",
330          "frame": 0,
331          "at": 0
332        },
333        {
334          "type": "O",
335          "frame": 1,
336          "at": 20
337        },
338        {
339          "type": "C",
340          "frame": 1,
341          "at": 120
342        },
343        {
344          "type": "O",
345          "frame": 2,
346          "at": 150
347        },
348        {
349          "type": "C",
350          "frame": 2,
351          "at": 350
352        },
353        {
354          "type": "C",
355          "frame": 0,
356          "at": 400
357        }
358      ]
359    }
360  ],
361  "name": "Test::test",
362  "exporter": "foundry"
363}
364"#]])
365            .is_json(),
366        );
367    }
368
369    #[test]
370    fn test_monotonic_events() {
371        let mut arena = CallTraceArena::default();
372        {
373            let root = &mut arena.nodes_mut()[0];
374            root.trace = CallTrace {
375                gas_used: 225,
376                steps: vec![trace_step(100), trace_step(75)],
377                decoded: decoded_call("A", "a()"),
378                ..Default::default()
379            };
380            root.children = vec![1];
381            root.ordering = vec![
382                TraceMemberOrder::Step(0),
383                TraceMemberOrder::Call(0),
384                TraceMemberOrder::Step(1),
385            ];
386        }
387        arena.nodes_mut().push(CallTraceNode {
388            parent: Some(0),
389            idx: 1,
390            trace: CallTrace {
391                depth: 1,
392                gas_used: 50,
393                decoded: decoded_call("B", "b()"),
394                ..Default::default()
395            },
396            ..Default::default()
397        });
398
399        let file = build(&arena, "test", "Test");
400
401        // Extract events
402        if let Profile::Evented(profile) = &file.profiles[0] {
403            let mut last_at = 0u64;
404            for event in &profile.events {
405                assert!(
406                    event.at >= last_at,
407                    "Event at {} is less than previous {}",
408                    event.at,
409                    last_at
410                );
411                last_at = event.at;
412            }
413        }
414    }
415}