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