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