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