forge/
gas_report.rs

1//! Gas reports.
2
3use crate::{
4    constants::{CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS},
5    traces::{CallTraceArena, CallTraceDecoder, CallTraceNode, DecodedCallData},
6};
7use alloy_primitives::map::HashSet;
8use comfy_table::{Cell, Color, Table, modifiers::UTF8_ROUND_CORNERS, presets::ASCII_MARKDOWN};
9use foundry_common::{TestFunctionExt, calc, shell};
10use foundry_evm::traces::CallKind;
11
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14use std::{collections::BTreeMap, fmt::Display};
15
16/// Represents the gas report for a set of contracts.
17#[derive(Clone, Debug, Default, Serialize, Deserialize)]
18pub struct GasReport {
19    /// Whether to report any contracts.
20    report_any: bool,
21    /// Contracts to generate the report for.
22    report_for: HashSet<String>,
23    /// Contracts to ignore when generating the report.
24    ignore: HashSet<String>,
25    /// Whether to include gas reports for tests.
26    include_tests: bool,
27    /// All contracts that were analyzed grouped by their identifier
28    /// ``test/Counter.t.sol:CounterTest
29    pub contracts: BTreeMap<String, ContractInfo>,
30}
31
32impl GasReport {
33    pub fn new(
34        report_for: impl IntoIterator<Item = String>,
35        ignore: impl IntoIterator<Item = String>,
36        include_tests: bool,
37    ) -> Self {
38        let report_for = report_for.into_iter().collect::<HashSet<_>>();
39        let ignore = ignore.into_iter().collect::<HashSet<_>>();
40        let report_any = report_for.is_empty() || report_for.contains("*");
41        Self { report_any, report_for, ignore, include_tests, ..Default::default() }
42    }
43
44    /// Whether the given contract should be reported.
45    #[instrument(level = "trace", skip(self), ret)]
46    fn should_report(&self, contract_name: &str) -> bool {
47        if self.ignore.contains(contract_name) {
48            let contains_anyway = self.report_for.contains(contract_name);
49            if contains_anyway {
50                // If the user listed the contract in 'gas_reports' (the foundry.toml field) a
51                // report for the contract is generated even if it's listed in the ignore
52                // list. This is addressed this way because getting a report you don't expect is
53                // preferable than not getting one you expect. A warning is printed to stderr
54                // indicating the "double listing".
55                let _ = sh_warn!(
56                    "{contract_name} is listed in both 'gas_reports' and 'gas_reports_ignore'."
57                );
58            }
59            return contains_anyway;
60        }
61        self.report_any || self.report_for.contains(contract_name)
62    }
63
64    /// Analyzes the given traces and generates a gas report.
65    pub async fn analyze(
66        &mut self,
67        arenas: impl IntoIterator<Item = &CallTraceArena>,
68        decoder: &CallTraceDecoder,
69    ) {
70        for node in arenas.into_iter().flat_map(|arena| arena.nodes()) {
71            self.analyze_node(node, decoder).await;
72        }
73    }
74
75    async fn analyze_node(&mut self, node: &CallTraceNode, decoder: &CallTraceDecoder) {
76        let trace = &node.trace;
77
78        if trace.address == CHEATCODE_ADDRESS || trace.address == HARDHAT_CONSOLE_ADDRESS {
79            return;
80        }
81
82        let Some(name) = decoder.contracts.get(&node.trace.address) else { return };
83        let contract_name = name.rsplit(':').next().unwrap_or(name);
84
85        if !self.should_report(contract_name) {
86            return;
87        }
88        let contract_info = self.contracts.entry(name.to_string()).or_default();
89        let is_create_call = trace.kind.is_any_create();
90
91        // Record contract deployment size.
92        if is_create_call {
93            trace!(contract_name, "adding create size info");
94            contract_info.size = trace.data.len();
95        }
96
97        // Only include top-level calls which account for calldata and base (21.000) cost.
98        // Only include Calls and Creates as only these calls are isolated in inspector.
99        if trace.depth > 1 && (trace.kind == CallKind::Call || is_create_call) {
100            return;
101        }
102
103        let decoded = || decoder.decode_function(&node.trace);
104
105        if is_create_call {
106            trace!(contract_name, "adding create gas info");
107            contract_info.gas = trace.gas_used;
108        } else if let Some(DecodedCallData { signature, .. }) = decoded().await.call_data {
109            let name = signature.split('(').next().unwrap();
110            // ignore any test/setup functions
111            if self.include_tests || !name.test_function_kind().is_known() {
112                trace!(contract_name, signature, "adding gas info");
113                let gas_info = contract_info
114                    .functions
115                    .entry(name.to_string())
116                    .or_default()
117                    .entry(signature.clone())
118                    .or_default();
119                gas_info.frames.push(trace.gas_used);
120            }
121        }
122    }
123
124    /// Finalizes the gas report by calculating the min, max, mean, and median for each function.
125    #[must_use]
126    pub fn finalize(mut self) -> Self {
127        trace!("finalizing gas report");
128        for contract in self.contracts.values_mut() {
129            for sigs in contract.functions.values_mut() {
130                for func in sigs.values_mut() {
131                    func.frames.sort_unstable();
132                    func.min = func.frames.first().copied().unwrap_or_default();
133                    func.max = func.frames.last().copied().unwrap_or_default();
134                    func.mean = calc::mean(&func.frames);
135                    func.median = calc::median_sorted(&func.frames);
136                    func.calls = func.frames.len() as u64;
137                }
138            }
139        }
140        self
141    }
142}
143
144impl Display for GasReport {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
146        if shell::is_json() {
147            writeln!(f, "{}", &self.format_json_output())?;
148        } else {
149            for (name, contract) in &self.contracts {
150                if contract.functions.is_empty() {
151                    trace!(name, "gas report contract without functions");
152                    continue;
153                }
154
155                let table = self.format_table_output(contract, name);
156                writeln!(f, "\n{table}")?;
157            }
158        }
159
160        Ok(())
161    }
162}
163
164impl GasReport {
165    fn format_json_output(&self) -> String {
166        serde_json::to_string(
167            &self
168                .contracts
169                .iter()
170                .filter_map(|(name, contract)| {
171                    if contract.functions.is_empty() {
172                        trace!(name, "gas report contract without functions");
173                        return None;
174                    }
175
176                    let functions = contract
177                        .functions
178                        .iter()
179                        .flat_map(|(_, sigs)| {
180                            sigs.iter().map(|(sig, gas_info)| {
181                                let display_name = sig.replace(':', "");
182                                (display_name, gas_info)
183                            })
184                        })
185                        .collect::<BTreeMap<_, _>>();
186
187                    Some(json!({
188                        "contract": name,
189                        "deployment": {
190                            "gas": contract.gas,
191                            "size": contract.size,
192                        },
193                        "functions": functions,
194                    }))
195                })
196                .collect::<Vec<_>>(),
197        )
198        .unwrap()
199    }
200
201    fn format_table_output(&self, contract: &ContractInfo, name: &str) -> Table {
202        let mut table = Table::new();
203        if shell::is_markdown() {
204            table.load_preset(ASCII_MARKDOWN);
205        } else {
206            table.apply_modifier(UTF8_ROUND_CORNERS);
207        }
208
209        table.set_header(vec![Cell::new(format!("{name} Contract")).fg(Color::Magenta)]);
210
211        table.add_row(vec![
212            Cell::new("Deployment Cost").fg(Color::Cyan),
213            Cell::new("Deployment Size").fg(Color::Cyan),
214        ]);
215        table.add_row(vec![
216            Cell::new(contract.gas.to_string()),
217            Cell::new(contract.size.to_string()),
218        ]);
219
220        // Add a blank row to separate deployment info from function info.
221        table.add_row(vec![Cell::new("")]);
222
223        table.add_row(vec![
224            Cell::new("Function Name"),
225            Cell::new("Min").fg(Color::Green),
226            Cell::new("Avg").fg(Color::Yellow),
227            Cell::new("Median").fg(Color::Yellow),
228            Cell::new("Max").fg(Color::Red),
229            Cell::new("# Calls").fg(Color::Cyan),
230        ]);
231
232        contract.functions.iter().for_each(|(fname, sigs)| {
233            sigs.iter().for_each(|(sig, gas_info)| {
234                // Show function signature if overloaded else display function name.
235                let display_name =
236                    if sigs.len() == 1 { fname.to_string() } else { sig.replace(':', "") };
237
238                table.add_row(vec![
239                    Cell::new(display_name),
240                    Cell::new(gas_info.min.to_string()).fg(Color::Green),
241                    Cell::new(gas_info.mean.to_string()).fg(Color::Yellow),
242                    Cell::new(gas_info.median.to_string()).fg(Color::Yellow),
243                    Cell::new(gas_info.max.to_string()).fg(Color::Red),
244                    Cell::new(gas_info.calls.to_string()),
245                ]);
246            })
247        });
248
249        table
250    }
251}
252
253#[derive(Clone, Debug, Default, Serialize, Deserialize)]
254pub struct ContractInfo {
255    pub gas: u64,
256    pub size: usize,
257    /// Function name -> Function signature -> GasInfo
258    pub functions: BTreeMap<String, BTreeMap<String, GasInfo>>,
259}
260
261#[derive(Clone, Debug, Default, Serialize, Deserialize)]
262pub struct GasInfo {
263    pub calls: u64,
264    pub min: u64,
265    pub mean: u64,
266    pub median: u64,
267    pub max: u64,
268
269    #[serde(skip)]
270    pub frames: Vec<u64>,
271}