Skip to main content

forge/
gas_report.rs

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