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