Skip to content

Commit 262bdf2

Browse files
feat(forge): Invariant Testing v2 (#1572)
* init * invariant testing kinda working * updates * fmt * wip * wip * wip * check if there is a fuzzer for invariants * less clones * add support for targetContracts on invariant tests * move load_contracts * add TestOptions and invariant_depth as param * pass TestOptions on fuzz tests * fuzz senders as well * light cleanup * make counterexample list concise * show reverts on invariants test reports * add excludeContracts() * refactor address fetching * move invariant to fuzz module * fuzz calldata from state changes * move block into assert_invariances * add union between selected senders and random * fix sender on get_addresses * wip * add targetSelectors * add fail_on_revert for invariant tests * dont stop on the first invariant failure on each case * create a new strategy tree if a new contract is created * only collect contract addresses from NewlyCreated * display contract and sig on displaying counter example * add documentation * generate the sequence lazily instead * wip * refactor invariants into multi file module * refactor get_addresses to get_list * add test cases * add reentrancy_strat * set reentrancy target as an union with random * merge master * make call_override a flag * add inspector_config() and inspector_config_mut() * always collect data, even without override set * docs * more docs * more docs * remove unnecessary changeset clone & docs * refactor +prepare_fuzzing * more explanations and better var names * replace TestKindGas for a more generic TestKindReport * add docs to strategies * smol fixes * format failure sequence * pass TestOptions instead of fuzzer to multicontractrunner * small fixes * make counterexample an enum * add InvariantFailures * turn add_function into get_function * improve error report on assert_invariants * simplify refs * only override_call_strat needs to be sboxed, revert others * fix invariant test regression * fix: set_replay after setting the last_sequence * fix: test_contract address comparison on call gen * check invariants before calling anything * improve doc on invariant_call_override * remove unused error map from testrunner * reset executor instead of db * add type alias InvariantPreparation * move InvariantExecutor into the same file * add return status * small refactor * const instead of static * merge fixes: backend + testoptions * use iterator for functions * FuzzRunIdentifiedContracts now uses Mutex * from_utf8_lossy instead of unsafe unchecked * use Mutex for runner of RandomCallGenerator * move RandomCallGenerator to its own module * write to fmt * small refactor: error.replay * remove newlines * add remaining is_invariant_test Co-authored-by: Brock <brock.elmore@gmail.com>
1 parent bbcb91a commit 262bdf2

36 files changed

+2012
-170
lines changed

Cargo.lock

+1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/src/cmd/forge/coverage.rs

+9-11
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use forge::{
2626
executor::{inspector::CheatsConfig, opts::EvmOpts},
2727
result::SuiteResult,
2828
trace::identifier::LocalTraceIdentifier,
29-
MultiContractRunnerBuilder,
29+
MultiContractRunnerBuilder, TestOptions,
3030
};
3131
use foundry_common::{evm::EvmArgs, fs};
3232
use foundry_config::{figment::Figment, Config};
@@ -251,29 +251,26 @@ impl CoverageArgs {
251251
config: Config,
252252
evm_opts: EvmOpts,
253253
) -> eyre::Result<()> {
254-
// Setup the fuzzer
255-
// TODO: Add CLI Options to modify the persistence
256-
let cfg = proptest::test_runner::Config {
257-
failure_persistence: None,
258-
cases: config.fuzz_runs,
259-
max_local_rejects: config.fuzz_max_local_rejects,
260-
max_global_rejects: config.fuzz_max_global_rejects,
254+
let test_options = TestOptions {
255+
fuzz_runs: config.fuzz_runs,
256+
fuzz_max_local_rejects: config.fuzz_max_local_rejects,
257+
fuzz_max_global_rejects: config.fuzz_max_global_rejects,
261258
..Default::default()
262259
};
263-
let fuzzer = proptest::test_runner::TestRunner::new(cfg);
260+
264261
let root = project.paths.root;
265262

266263
let env = evm_opts.evm_env_blocking();
267264

268265
// Build the contract runner
269266
let evm_spec = utils::evm_spec(&config.evm_version);
270267
let mut runner = MultiContractRunnerBuilder::default()
271-
.fuzzer(fuzzer)
272268
.initial_balance(evm_opts.initial_balance)
273269
.evm_spec(evm_spec)
274270
.sender(evm_opts.sender)
275271
.with_fork(evm_opts.get_fork(&config, env.clone()))
276272
.with_cheats_config(CheatsConfig::new(&config, &evm_opts))
273+
.with_test_options(test_options)
277274
.set_coverage(true)
278275
.build(root.clone(), output, env, evm_opts)?;
279276

@@ -283,7 +280,8 @@ impl CoverageArgs {
283280
let local_identifier = LocalTraceIdentifier::new(&runner.known_contracts);
284281

285282
// TODO: Coverage for fuzz tests
286-
let handle = thread::spawn(move || runner.test(&self.filter, Some(tx)).unwrap());
283+
let handle =
284+
thread::spawn(move || runner.test(&self.filter, Some(tx), Default::default()).unwrap());
287285
for mut result in rx.into_iter().flat_map(|(_, suite)| suite.test_results.into_values()) {
288286
if let Some(hit_map) = result.coverage.take() {
289287
for (_, trace) in &mut result.traces {

cli/src/cmd/forge/snapshot.rs

+15-19
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use crate::cmd::{
1010
use clap::{Parser, ValueHint};
1111
use ethers::types::U256;
1212
use eyre::Context;
13-
use forge::result::TestKindGas;
13+
use forge::result::TestKindReport;
1414
use once_cell::sync::Lazy;
1515
use regex::Regex;
1616
use std::{
@@ -210,7 +210,7 @@ impl SnapshotConfig {
210210
pub struct SnapshotEntry {
211211
pub contract_name: String,
212212
pub signature: String,
213-
pub gas_used: TestKindGas,
213+
pub gas_used: TestKindReport,
214214
}
215215

216216
impl FromStr for SnapshotEntry {
@@ -226,7 +226,9 @@ impl FromStr for SnapshotEntry {
226226
Some(SnapshotEntry {
227227
contract_name: file.as_str().to_string(),
228228
signature: sig.as_str().to_string(),
229-
gas_used: TestKindGas::Standard(gas.as_str().parse().unwrap()),
229+
gas_used: TestKindReport::Standard {
230+
gas: gas.as_str().parse().unwrap(),
231+
},
230232
})
231233
} else {
232234
cap.name("runs")
@@ -237,10 +239,10 @@ impl FromStr for SnapshotEntry {
237239
.map(|(runs, avg, med)| SnapshotEntry {
238240
contract_name: file.as_str().to_string(),
239241
signature: sig.as_str().to_string(),
240-
gas_used: TestKindGas::Fuzz {
242+
gas_used: TestKindReport::Fuzz {
241243
runs: runs.as_str().parse().unwrap(),
242-
median: med.as_str().parse().unwrap(),
243-
mean: avg.as_str().parse().unwrap(),
244+
median_gas: med.as_str().parse().unwrap(),
245+
mean_gas: avg.as_str().parse().unwrap(),
244246
},
245247
})
246248
}
@@ -274,13 +276,7 @@ fn write_to_snapshot_file(
274276
) -> eyre::Result<()> {
275277
let mut out = String::new();
276278
for test in tests {
277-
writeln!(
278-
out,
279-
"{}:{} {}",
280-
test.contract_name(),
281-
test.signature,
282-
test.result.kind.gas_used()
283-
)?;
279+
writeln!(out, "{}:{} {}", test.contract_name(), test.signature, test.result.kind.report())?;
284280
}
285281
Ok(fs::write(path, out)?)
286282
}
@@ -289,8 +285,8 @@ fn write_to_snapshot_file(
289285
#[derive(Debug, Clone, Eq, PartialEq)]
290286
pub struct SnapshotDiff {
291287
pub signature: String,
292-
pub source_gas_used: TestKindGas,
293-
pub target_gas_used: TestKindGas,
288+
pub source_gas_used: TestKindReport,
289+
pub target_gas_used: TestKindReport,
294290
}
295291

296292
impl SnapshotDiff {
@@ -321,7 +317,7 @@ fn check(tests: Vec<Test>, snaps: Vec<SnapshotEntry>) -> bool {
321317
if let Some(target_gas) =
322318
snaps.get(&(test.contract_name().to_string(), test.signature.clone())).cloned()
323319
{
324-
let source_gas = test.result.kind.gas_used();
320+
let source_gas = test.result.kind.report();
325321
if source_gas.gas() != target_gas.gas() {
326322
eprintln!(
327323
"Diff in \"{}::{}\": consumed \"{}\" gas, expected \"{}\" gas ",
@@ -363,7 +359,7 @@ fn diff(tests: Vec<Test>, snaps: Vec<SnapshotEntry>) -> eyre::Result<()> {
363359
})?;
364360

365361
diffs.push(SnapshotDiff {
366-
source_gas_used: test.result.kind.gas_used(),
362+
source_gas_used: test.result.kind.report(),
367363
signature: test.signature,
368364
target_gas_used,
369365
});
@@ -429,7 +425,7 @@ mod tests {
429425
SnapshotEntry {
430426
contract_name: "Test".to_string(),
431427
signature: "deposit()".to_string(),
432-
gas_used: TestKindGas::Standard(7222)
428+
gas_used: TestKindReport::Standard { gas: 7222 }
433429
}
434430
);
435431
}
@@ -443,7 +439,7 @@ mod tests {
443439
SnapshotEntry {
444440
contract_name: "Test".to_string(),
445441
signature: "deposit()".to_string(),
446-
gas_used: TestKindGas::Fuzz { runs: 256, median: 200, mean: 100 }
442+
gas_used: TestKindReport::Fuzz { runs: 256, median_gas: 200, mean_gas: 100 }
447443
}
448444
);
449445
}

cli/src/cmd/forge/test/mod.rs

+52-41
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use crate::{
88
compile::ProjectCompiler,
99
suggestions, utils,
1010
};
11+
use cast::fuzz::CounterExample;
1112
use clap::{AppSettings, Parser};
1213
use ethers::{solc::utils::RuntimeOrHandle, types::U256};
1314
use forge::{
@@ -19,11 +20,10 @@ use forge::{
1920
identifier::{EtherscanIdentifier, LocalTraceIdentifier},
2021
CallTraceDecoderBuilder, TraceKind,
2122
},
22-
MultiContractRunner, MultiContractRunnerBuilder,
23+
MultiContractRunner, MultiContractRunnerBuilder, TestOptions,
2324
};
2425
use foundry_common::evm::EvmArgs;
2526
use foundry_config::{figment, figment::Figment, Config};
26-
use proptest::test_runner::{RngAlgorithm, TestRng};
2727
use regex::Regex;
2828
use std::{collections::BTreeMap, path::PathBuf, sync::mpsc::channel, thread, time::Duration};
2929
use tracing::trace;
@@ -181,7 +181,7 @@ pub struct Test {
181181

182182
impl Test {
183183
pub fn gas_used(&self) -> u64 {
184-
self.result.kind.gas_used().gas()
184+
self.result.kind.report().gas()
185185
}
186186

187187
/// Returns the contract name of the artifact id
@@ -280,47 +280,47 @@ fn short_test_result(name: &str, result: &TestResult) {
280280
let status = if result.success {
281281
Paint::green("[PASS]".to_string())
282282
} else {
283-
let txt = match (&result.reason, &result.counterexample) {
284-
(Some(ref reason), Some(ref counterexample)) => {
285-
format!("[FAIL. Reason: {reason}. Counterexample: {counterexample}]")
286-
}
287-
(None, Some(ref counterexample)) => {
288-
format!("[FAIL. Counterexample: {counterexample}]")
289-
}
290-
(Some(ref reason), None) => {
291-
format!("[FAIL. Reason: {reason}]")
292-
}
293-
(None, None) => "[FAIL]".to_string(),
294-
};
283+
let reason = result
284+
.reason
285+
.as_ref()
286+
.map(|reason| format!("Reason: {reason}"))
287+
.unwrap_or_else(|| "Reason: Undefined.".to_string());
288+
289+
let counterexample = result
290+
.counterexample
291+
.as_ref()
292+
.map(|example| match example {
293+
CounterExample::Single(eg) => format!(" Counterexample: {eg}]"),
294+
CounterExample::Sequence(sequence) => {
295+
let mut inner_txt = String::new();
296+
297+
for checkpoint in sequence {
298+
inner_txt += format!("\t\t{checkpoint}\n").as_str();
299+
}
300+
format!("]\n\t[Sequence]\n{inner_txt}\n")
301+
}
302+
})
303+
.unwrap_or_else(|| "]".to_string());
295304

296-
Paint::red(txt)
305+
Paint::red(format!("[FAIL. {reason}{counterexample}"))
297306
};
298307

299-
println!("{} {} {}", status, name, result.kind.gas_used());
308+
println!("{} {} {}", status, name, result.kind.report());
300309
}
301310

302311
pub fn custom_run(args: TestArgs) -> eyre::Result<TestOutcome> {
303312
// Merge all configs
304313
let (config, mut evm_opts) = args.config_and_evm_opts()?;
305314

306-
// Setup the fuzzer
307-
// TODO: Add CLI Options to modify the persistence
308-
let cfg = proptest::test_runner::Config {
309-
failure_persistence: None,
310-
cases: config.fuzz_runs,
311-
max_local_rejects: config.fuzz_max_local_rejects,
312-
max_global_rejects: config.fuzz_max_global_rejects,
313-
..Default::default()
314-
};
315-
316-
let fuzzer = if let Some(ref fuzz_seed) = config.fuzz_seed {
317-
let mut bytes: [u8; 32] = [0; 32];
318-
fuzz_seed.to_big_endian(&mut bytes);
319-
trace!(target: "forge::test", "executing test command");
320-
let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &bytes);
321-
proptest::test_runner::TestRunner::new_with_rng(cfg, rng)
322-
} else {
323-
proptest::test_runner::TestRunner::new(cfg)
315+
let test_options = TestOptions {
316+
fuzz_runs: config.fuzz_runs,
317+
fuzz_max_local_rejects: config.fuzz_max_local_rejects,
318+
fuzz_max_global_rejects: config.fuzz_max_global_rejects,
319+
fuzz_seed: config.fuzz_seed,
320+
invariant_runs: config.invariant_runs,
321+
invariant_depth: config.invariant_depth,
322+
invariant_fail_on_revert: config.invariant_fail_on_revert,
323+
invariant_call_override: config.invariant_call_override,
324324
};
325325

326326
let mut filter = args.filter(&config);
@@ -350,20 +350,21 @@ pub fn custom_run(args: TestArgs) -> eyre::Result<TestOutcome> {
350350
let evm_spec = utils::evm_spec(&config.evm_version);
351351

352352
let mut runner = MultiContractRunnerBuilder::default()
353-
.fuzzer(fuzzer)
354353
.initial_balance(evm_opts.initial_balance)
355354
.evm_spec(evm_spec)
356355
.sender(evm_opts.sender)
357356
.with_fork(evm_opts.get_fork(&config, env.clone()))
358357
.with_cheats_config(CheatsConfig::new(&config, &evm_opts))
358+
.with_test_options(test_options)
359359
.build(project.paths.root, output, env, evm_opts)?;
360360

361361
if args.debug.is_some() {
362362
filter.test_pattern = args.debug;
363+
363364
match runner.count_filtered_tests(&filter) {
364365
1 => {
365366
// Run the test
366-
let results = runner.test(&filter, None)?;
367+
let results = runner.test(&filter, None, test_options)?;
367368

368369
// Get the result of the single test
369370
let (id, sig, test_kind, counterexample) = results.iter().map(|(id, SuiteResult{ test_results, .. })| {
@@ -375,7 +376,7 @@ pub fn custom_run(args: TestArgs) -> eyre::Result<TestOutcome> {
375376
// Build debugger args if this is a fuzz test
376377
let sig = match test_kind {
377378
TestKind::Fuzz(cases) => {
378-
if let Some(counterexample) = counterexample {
379+
if let Some(CounterExample::Single(counterexample)) = counterexample {
379380
counterexample.calldata.to_string()
380381
} else {
381382
cases.cases().first().expect("no fuzz cases run").calldata.to_string()
@@ -407,7 +408,16 @@ pub fn custom_run(args: TestArgs) -> eyre::Result<TestOutcome> {
407408
} else if args.list {
408409
list(runner, filter, args.json)
409410
} else {
410-
test(config, runner, verbosity, filter, args.json, args.allow_failure, args.gas_report)
411+
test(
412+
config,
413+
runner,
414+
verbosity,
415+
filter,
416+
args.json,
417+
args.allow_failure,
418+
test_options,
419+
args.gas_report,
420+
)
411421
}
412422
}
413423

@@ -438,6 +448,7 @@ fn test(
438448
filter: Filter,
439449
json: bool,
440450
allow_failure: bool,
451+
test_options: TestOptions,
441452
gas_reporting: bool,
442453
) -> eyre::Result<TestOutcome> {
443454
trace!(target: "forge::test", "running all tests");
@@ -462,7 +473,7 @@ fn test(
462473
}
463474

464475
if json {
465-
let results = runner.test(&filter, None)?;
476+
let results = runner.test(&filter, None, test_options)?;
466477
println!("{}", serde_json::to_string(&results)?);
467478
Ok(TestOutcome::new(results, allow_failure))
468479
} else {
@@ -483,7 +494,7 @@ fn test(
483494
let (tx, rx) = channel::<(String, SuiteResult)>();
484495

485496
// Run tests
486-
let handle = thread::spawn(move || runner.test(&filter, Some(tx)).unwrap());
497+
let handle = thread::spawn(move || runner.test(&filter, Some(tx), test_options).unwrap());
487498

488499
let mut results: BTreeMap<String, SuiteResult> = BTreeMap::new();
489500
let mut gas_report = GasReport::new(config.gas_reports);

cli/src/opts/multi_wallet.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ impl MultiWallet {
219219
}
220220
);
221221

222-
let mut error_msg = "".to_string();
222+
let mut error_msg = String::new();
223223

224224
// This is an actual used address
225225
if addresses.contains(&Config::DEFAULT_SENDER) {

cli/tests/it/config.rs

+4
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ forgetest!(can_extract_config_values, |prj: TestProject, mut cmd: TestCommand| {
6060
fuzz_max_local_rejects: 2000,
6161
fuzz_max_global_rejects: 100203,
6262
fuzz_seed: Some(1000.into()),
63+
invariant_runs: 256,
64+
invariant_depth: 15,
65+
invariant_fail_on_revert: false,
66+
invariant_call_override: false,
6367
ffi: true,
6468
sender: "00a329c0648769A73afAc7F9381D08FB43dBEA72".parse().unwrap(),
6569
tx_origin: "00a329c0648769A73afAc7F9F81E08FB43dBEA72".parse().unwrap(),

0 commit comments

Comments
 (0)