foundry_evm_traces/speedscope/
builder.rs1use 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 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
116pub 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 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}