Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(invariant): add basic metrics report #9158

Merged
merged 3 commits into from
Oct 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions crates/config/src/invariant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ pub struct InvariantConfig {
pub gas_report_samples: u32,
/// Path where invariant failures are recorded and replayed.
pub failure_persist_dir: Option<PathBuf>,
/// Whether to collect and display fuzzed selectors metrics.
pub show_metrics: bool,
}

impl Default for InvariantConfig {
Expand All @@ -48,6 +50,7 @@ impl Default for InvariantConfig {
max_assume_rejects: 65536,
gas_report_samples: 256,
failure_persist_dir: None,
show_metrics: false,
}
}
}
Expand All @@ -65,6 +68,7 @@ impl InvariantConfig {
max_assume_rejects: 65536,
gas_report_samples: 256,
failure_persist_dir: Some(cache_dir),
show_metrics: false,
}
}

Expand Down Expand Up @@ -103,6 +107,7 @@ impl InlineConfigParser for InvariantConfig {
conf_clone.failure_persist_dir = Some(PathBuf::from(value))
}
"shrink-run-limit" => conf_clone.shrink_run_limit = parse_config_u32(key, value)?,
"show-metrics" => conf_clone.show_metrics = parse_config_bool(key, value)?,
_ => Err(InlineConfigParserError::InvalidConfigProperty(key.to_string()))?,
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/evm/evm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ proptest.workspace = true
thiserror.workspace = true
tracing.workspace = true
indicatif = "0.17"
serde.workspace = true
47 changes: 45 additions & 2 deletions crates/evm/evm/src/executors/invariant/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ use proptest::{
use result::{assert_after_invariant, assert_invariants, can_continue};
use revm::primitives::HashMap;
use shrink::shrink_sequence;
use std::{cell::RefCell, collections::btree_map::Entry, sync::Arc};
use std::{
cell::RefCell,
collections::{btree_map::Entry, HashMap as Map},
sync::Arc,
};

mod error;
pub use error::{InvariantFailures, InvariantFuzzError};
Expand All @@ -42,6 +46,7 @@ pub use replay::{replay_error, replay_run};

mod result;
pub use result::InvariantFuzzTestResult;
use serde::{Deserialize, Serialize};

mod shrink;
use crate::executors::EvmError;
Expand Down Expand Up @@ -101,6 +106,17 @@ sol! {
}
}

/// Contains invariant metrics for a single fuzzed selector.
#[derive(Default, Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct InvariantMetrics {
// Count of fuzzed selector calls.
pub calls: usize,
// Count of fuzzed selector reverts.
pub reverts: usize,
// Count of fuzzed selector discards (through assume cheatcodes).
pub discards: usize,
}

/// Contains data collected during invariant test runs.
pub struct InvariantTestData {
// Consumed gas and calldata of every successful fuzz call.
Expand All @@ -115,6 +131,8 @@ pub struct InvariantTestData {
pub last_call_results: Option<RawCallResult>,
// Coverage information collected from all fuzzed calls.
pub coverage: Option<HitMaps>,
// Metrics for each fuzzed selector.
pub metrics: Map<String, InvariantMetrics>,

// Proptest runner to query for random values.
// The strategy only comes with the first `input`. We fill the rest of the `inputs`
Expand Down Expand Up @@ -153,6 +171,7 @@ impl InvariantTest {
gas_report_traces: vec![],
last_call_results,
coverage: None,
metrics: Map::default(),
branch_runner,
});
Self { fuzz_state, targeted_contracts, execution_data }
Expand Down Expand Up @@ -191,6 +210,24 @@ impl InvariantTest {
}
}

/// Update metrics for a fuzzed selector, extracted from tx details.
/// Always increments number of calls; discarded runs (through assume cheatcodes) are tracked
/// separated from reverts.
pub fn record_metrics(&self, tx_details: &BasicTxDetails, reverted: bool, discarded: bool) {
if let Some(metric_key) =
self.targeted_contracts.targets.lock().fuzzed_metric_key(tx_details)
{
let test_metrics = &mut self.execution_data.borrow_mut().metrics;
let invariant_metrics = test_metrics.entry(metric_key).or_default();
invariant_metrics.calls += 1;
if discarded {
invariant_metrics.discards += 1;
} else if reverted {
invariant_metrics.reverts += 1;
}
}
}

/// End invariant test run by collecting results, cleaning collected artifacts and reverting
/// created fuzz state.
pub fn end_run(&self, run: InvariantTestRun, gas_samples: usize) {
Expand Down Expand Up @@ -331,10 +368,15 @@ impl<'a> InvariantExecutor<'a> {
TestCaseError::fail(format!("Could not make raw evm call: {e}"))
})?;

let discarded = call_result.result.as_ref() == MAGIC_ASSUME;
if self.config.show_metrics {
invariant_test.record_metrics(tx, call_result.reverted, discarded);
}

// Collect coverage from last fuzzed call.
invariant_test.merge_coverage(call_result.coverage.clone());

if call_result.result.as_ref() == MAGIC_ASSUME {
if discarded {
current_run.inputs.pop();
current_run.assume_rejects_counter += 1;
if current_run.assume_rejects_counter > self.config.max_assume_rejects {
Expand Down Expand Up @@ -443,6 +485,7 @@ impl<'a> InvariantExecutor<'a> {
last_run_inputs: result.last_run_inputs,
gas_report_traces: result.gas_report_traces,
coverage: result.coverage,
metrics: result.metrics,
})
}

Expand Down
6 changes: 4 additions & 2 deletions crates/evm/evm/src/executors/invariant/result.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use super::{
call_after_invariant_function, call_invariant_function, error::FailedInvariantCaseData,
InvariantFailures, InvariantFuzzError, InvariantTest, InvariantTestRun,
InvariantFailures, InvariantFuzzError, InvariantMetrics, InvariantTest, InvariantTestRun,
};
use crate::executors::{Executor, RawCallResult};
use alloy_dyn_abi::JsonAbiExt;
Expand All @@ -13,7 +13,7 @@ use foundry_evm_fuzz::{
FuzzedCases,
};
use revm_inspectors::tracing::CallTraceArena;
use std::borrow::Cow;
use std::{borrow::Cow, collections::HashMap};

/// The outcome of an invariant fuzz test
#[derive(Debug)]
Expand All @@ -30,6 +30,8 @@ pub struct InvariantFuzzTestResult {
pub gas_report_traces: Vec<Vec<CallTraceArena>>,
/// The coverage info collected during the invariant test runs.
pub coverage: Option<HitMaps>,
/// Fuzzed selectors metrics collected during the invariant test runs.
pub metrics: HashMap<String, InvariantMetrics>,
}

/// Enriched results of an invariant run check.
Expand Down
12 changes: 12 additions & 0 deletions crates/evm/fuzz/src/invariant/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,18 @@ impl TargetedContracts {
.filter(|(_, c)| !c.abi.functions.is_empty())
.flat_map(|(contract, c)| c.abi_fuzzed_functions().map(move |f| (contract, f)))
}

/// Identifies fuzzed contract and function based on given tx details and returns unique metric
/// key composed from contract identifier and function name.
pub fn fuzzed_metric_key(&self, tx: &BasicTxDetails) -> Option<String> {
self.inner.get(&tx.call_details.target).and_then(|contract| {
contract
.abi
.functions()
.find(|f| f.selector() == tx.call_details.calldata[..4])
.map(|function| format!("{}.{}", contract.identifier.clone(), function.name))
})
}
}

impl std::ops::Deref for TargetedContracts {
Expand Down
15 changes: 13 additions & 2 deletions crates/forge/bin/cmd/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ impl FromStr for GasSnapshotEntry {
runs: runs.as_str().parse().unwrap(),
calls: calls.as_str().parse().unwrap(),
reverts: reverts.as_str().parse().unwrap(),
metrics: HashMap::default(),
},
})
}
Expand Down Expand Up @@ -486,7 +487,12 @@ mod tests {
GasSnapshotEntry {
contract_name: "Test".to_string(),
signature: "deposit()".to_string(),
gas_used: TestKindReport::Invariant { runs: 256, calls: 100, reverts: 200 }
gas_used: TestKindReport::Invariant {
runs: 256,
calls: 100,
reverts: 200,
metrics: HashMap::default()
}
}
);
}
Expand All @@ -500,7 +506,12 @@ mod tests {
GasSnapshotEntry {
contract_name: "ERC20Invariants".to_string(),
signature: "invariantBalanceSum()".to_string(),
gas_used: TestKindReport::Invariant { runs: 256, calls: 3840, reverts: 2388 }
gas_used: TestKindReport::Invariant {
runs: 256,
calls: 3840,
reverts: 2388,
metrics: HashMap::default()
}
}
);
}
Expand Down
9 changes: 9 additions & 0 deletions crates/forge/bin/cmd/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ mod summary;
use quick_junit::{NonSuccessKind, Report, TestCase, TestCaseStatus, TestSuite};
use summary::TestSummaryReporter;

use crate::cmd::test::summary::print_invariant_metrics;
pub use filter::FilterArgs;
use forge::result::TestKind;

// Loads project's figment and merges the build cli arguments into it
foundry_config::merge_impl_figment_convert!(TestArgs, opts, evm_opts);
Expand Down Expand Up @@ -621,6 +623,13 @@ impl TestArgs {
if !silent {
sh_println!("{}", result.short_result(name))?;

// Display invariant metrics if invariant kind.
if let TestKind::Invariant { runs: _, calls: _, reverts: _, metrics } =
&result.kind
{
print_invariant_metrics(metrics);
}

// We only display logs at level 2 and above
if verbosity >= 2 {
// We only decode logs from Hardhat and DS-style console events
Expand Down
40 changes: 39 additions & 1 deletion crates/forge/bin/cmd/test/summary.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
use crate::cmd::test::TestOutcome;
use comfy_table::{
modifiers::UTF8_ROUND_CORNERS, Attribute, Cell, CellAlignment, Color, Row, Table,
modifiers::UTF8_ROUND_CORNERS, presets::ASCII_MARKDOWN, Attribute, Cell, CellAlignment, Color,
Row, Table,
};
use foundry_evm::executors::invariant::InvariantMetrics;
use itertools::Itertools;
use std::collections::HashMap;

/// A simple summary reporter that prints the test results in a table.
pub struct TestSummaryReporter {
Expand Down Expand Up @@ -91,3 +95,37 @@ impl TestSummaryReporter {
println!("\n{}", self.table);
}
}

/// Helper to create and render invariant metrics summary table:
/// | Contract | Selector | Calls | Reverts | Discards |
/// |-----------------------|----------------|-------|---------|----------|
/// | AnotherCounterHandler | doWork | 7451 | 123 | 4941 |
/// | AnotherCounterHandler | doWorkThing | 7279 | 137 | 4849 |
/// | CounterHandler | doAnotherThing | 7302 | 150 | 4794 |
/// | CounterHandler | doSomething | 7382 | 160 | 4830 |
pub(crate) fn print_invariant_metrics(test_metrics: &HashMap<String, InvariantMetrics>) {
if !test_metrics.is_empty() {
let mut table = Table::new();
table.load_preset(ASCII_MARKDOWN);
table.set_header(["Contract", "Selector", "Calls", "Reverts", "Discards"]);

for name in test_metrics.keys().sorted() {
if let Some((contract, selector)) =
name.split_once(':').and_then(|(_, contract)| contract.split_once('.'))
{
let mut row = Row::new();
row.add_cell(Cell::new(contract).set_alignment(CellAlignment::Left));
row.add_cell(Cell::new(selector).set_alignment(CellAlignment::Left));
if let Some(metrics) = test_metrics.get(name) {
row.add_cell(Cell::new(metrics.calls).set_alignment(CellAlignment::Center));
row.add_cell(Cell::new(metrics.reverts).set_alignment(CellAlignment::Center));
row.add_cell(Cell::new(metrics.discards).set_alignment(CellAlignment::Center));
}

table.add_row(row);
}
}

println!("{table}\n");
}
}
Loading
Loading