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, 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
115pub 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 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}