Skip to content

Commit ce22450

Browse files
authored
fix(invariants): support vm.assume in invariant tests (#7309)
* fix(invariants): support vm.assume in invariant tests * fix * add .sol file * review fix
1 parent 36440d8 commit ce22450

File tree

9 files changed

+194
-84
lines changed

9 files changed

+194
-84
lines changed

crates/config/src/invariant.rs

+4
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ pub struct InvariantConfig {
3232
/// Useful for handlers that use cheatcodes as roll or warp
3333
/// Use it with caution, introduces performance penalty.
3434
pub preserve_state: bool,
35+
/// The maximum number of rejects via `vm.assume` which can be encountered during a single
36+
/// invariant run.
37+
pub max_assume_rejects: u32,
3538
}
3639

3740
impl Default for InvariantConfig {
@@ -45,6 +48,7 @@ impl Default for InvariantConfig {
4548
shrink_sequence: true,
4649
shrink_run_limit: 2usize.pow(18_u32),
4750
preserve_state: false,
51+
max_assume_rejects: 65536,
4852
}
4953
}
5054
}

crates/evm/evm/src/executors/invariant/error.rs

+23-3
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,27 @@ pub struct InvariantFuzzTestResult {
5353
}
5454

5555
#[derive(Clone, Debug)]
56-
pub struct InvariantFuzzError {
56+
pub enum InvariantFuzzError {
57+
Revert(FailedInvariantCaseData),
58+
BrokenInvariant(FailedInvariantCaseData),
59+
MaxAssumeRejects(u32),
60+
}
61+
62+
impl InvariantFuzzError {
63+
pub fn revert_reason(&self) -> Option<String> {
64+
match self {
65+
Self::BrokenInvariant(case_data) | Self::Revert(case_data) => {
66+
(!case_data.revert_reason.is_empty()).then(|| case_data.revert_reason.clone())
67+
}
68+
Self::MaxAssumeRejects(allowed) => Some(format!(
69+
"The `vm.assume` cheatcode rejected too many inputs ({allowed} allowed)"
70+
)),
71+
}
72+
}
73+
}
74+
75+
#[derive(Clone, Debug)]
76+
pub struct FailedInvariantCaseData {
5777
pub logs: Vec<Log>,
5878
pub traces: Option<CallTraceArena>,
5979
/// The proptest error occurred as a result of a test case.
@@ -74,7 +94,7 @@ pub struct InvariantFuzzError {
7494
pub shrink_run_limit: usize,
7595
}
7696

77-
impl InvariantFuzzError {
97+
impl FailedInvariantCaseData {
7898
pub fn new(
7999
invariant_contract: &InvariantContract<'_>,
80100
error_func: Option<&Function>,
@@ -93,7 +113,7 @@ impl InvariantFuzzError {
93113
.with_abi(invariant_contract.abi)
94114
.decode(call_result.result.as_ref(), Some(call_result.exit_reason));
95115

96-
InvariantFuzzError {
116+
Self {
97117
logs: call_result.logs,
98118
traces: call_result.traces,
99119
test_error: proptest::test_runner::TestError::Fail(

crates/evm/evm/src/executors/invariant/funcs.rs

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use super::{InvariantFailures, InvariantFuzzError};
1+
use super::{error::FailedInvariantCaseData, InvariantFailures, InvariantFuzzError};
22
use crate::executors::{Executor, RawCallResult};
33
use alloy_dyn_abi::JsonAbiExt;
44
use alloy_json_abi::Function;
@@ -50,15 +50,16 @@ pub fn assert_invariants(
5050
if is_err {
5151
// We only care about invariants which we haven't broken yet.
5252
if invariant_failures.error.is_none() {
53-
invariant_failures.error = Some(InvariantFuzzError::new(
53+
let case_data = FailedInvariantCaseData::new(
5454
invariant_contract,
5555
Some(func),
5656
calldata,
5757
call_result,
5858
&inner_sequence,
5959
shrink_sequence,
6060
shrink_run_limit,
61-
));
61+
);
62+
invariant_failures.error = Some(InvariantFuzzError::BrokenInvariant(case_data));
6263
return None
6364
}
6465
}

crates/evm/evm/src/executors/invariant/mod.rs

+77-58
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use eyre::{eyre, ContextCompat, Result};
99
use foundry_common::contracts::{ContractsByAddress, ContractsByArtifact};
1010
use foundry_config::{FuzzDictionaryConfig, InvariantConfig};
1111
use foundry_evm_core::{
12-
constants::{CALLER, CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS},
12+
constants::{CALLER, CHEATCODE_ADDRESS, HARDHAT_CONSOLE_ADDRESS, MAGIC_ASSUME},
1313
utils::{get_function, StateChangeset},
1414
};
1515
use foundry_evm_fuzz::{
@@ -38,11 +38,13 @@ use foundry_evm_fuzz::strategies::CalldataFuzzDictionary;
3838
mod funcs;
3939
pub use funcs::{assert_invariants, replay_run};
4040

41+
use self::error::FailedInvariantCaseData;
42+
4143
/// Alias for (Dictionary for fuzzing, initial contracts to fuzz and an InvariantStrategy).
4244
type InvariantPreparation = (
4345
EvmFuzzState,
4446
FuzzRunIdentifiedContracts,
45-
BoxedStrategy<Vec<BasicTxDetails>>,
47+
BoxedStrategy<BasicTxDetails>,
4648
CalldataFuzzDictionary,
4749
);
4850

@@ -143,7 +145,9 @@ impl<'a> InvariantExecutor<'a> {
143145
// during the run. We need another proptest runner to query for random
144146
// values.
145147
let branch_runner = RefCell::new(self.runner.clone());
146-
let _ = self.runner.run(&strat, |mut inputs| {
148+
let _ = self.runner.run(&strat, |first_input| {
149+
let mut inputs = vec![first_input];
150+
147151
// We stop the run immediately if we have reverted, and `fail_on_revert` is set.
148152
if self.config.fail_on_revert && failures.borrow().reverts > 0 {
149153
return Err(TestCaseError::fail("Revert occurred."))
@@ -158,7 +162,10 @@ impl<'a> InvariantExecutor<'a> {
158162
// Created contracts during a run.
159163
let mut created_contracts = vec![];
160164

161-
for current_run in 0..self.config.depth {
165+
let mut current_run = 0;
166+
let mut assume_rejects_counter = 0;
167+
168+
while current_run < self.config.depth {
162169
let (sender, (address, calldata)) = inputs.last().expect("no input generated");
163170

164171
// Executes the call from the randomly generated sequence.
@@ -172,65 +179,77 @@ impl<'a> InvariantExecutor<'a> {
172179
.expect("could not make raw evm call")
173180
};
174181

175-
// Collect data for fuzzing from the state changeset.
176-
let mut state_changeset =
177-
call_result.state_changeset.to_owned().expect("no changesets");
178-
179-
collect_data(
180-
&mut state_changeset,
181-
sender,
182-
&call_result,
183-
fuzz_state.clone(),
184-
&self.config.dictionary,
185-
);
182+
if call_result.result.as_ref() == MAGIC_ASSUME {
183+
inputs.pop();
184+
assume_rejects_counter += 1;
185+
if assume_rejects_counter > self.config.max_assume_rejects {
186+
failures.borrow_mut().error = Some(InvariantFuzzError::MaxAssumeRejects(
187+
self.config.max_assume_rejects,
188+
));
189+
return Err(TestCaseError::fail("Max number of vm.assume rejects reached."))
190+
}
191+
} else {
192+
// Collect data for fuzzing from the state changeset.
193+
let mut state_changeset =
194+
call_result.state_changeset.to_owned().expect("no changesets");
195+
196+
collect_data(
197+
&mut state_changeset,
198+
sender,
199+
&call_result,
200+
fuzz_state.clone(),
201+
&self.config.dictionary,
202+
);
186203

187-
if let Err(error) = collect_created_contracts(
188-
&state_changeset,
189-
self.project_contracts,
190-
self.setup_contracts,
191-
&self.artifact_filters,
192-
targeted_contracts.clone(),
193-
&mut created_contracts,
194-
) {
195-
warn!(target: "forge::test", "{error}");
196-
}
204+
if let Err(error) = collect_created_contracts(
205+
&state_changeset,
206+
self.project_contracts,
207+
self.setup_contracts,
208+
&self.artifact_filters,
209+
targeted_contracts.clone(),
210+
&mut created_contracts,
211+
) {
212+
warn!(target: "forge::test", "{error}");
213+
}
197214

198-
// Commit changes to the database.
199-
executor.backend.commit(state_changeset.clone());
200-
201-
fuzz_runs.push(FuzzCase {
202-
calldata: calldata.clone(),
203-
gas: call_result.gas_used,
204-
stipend: call_result.stipend,
205-
});
206-
207-
let RichInvariantResults { success: can_continue, call_result: call_results } =
208-
can_continue(
209-
&invariant_contract,
210-
call_result,
211-
&executor,
212-
&inputs,
213-
&mut failures.borrow_mut(),
214-
&targeted_contracts,
215-
state_changeset,
216-
self.config.fail_on_revert,
217-
self.config.shrink_sequence,
218-
self.config.shrink_run_limit,
219-
);
215+
// Commit changes to the database.
216+
executor.backend.commit(state_changeset.clone());
217+
218+
fuzz_runs.push(FuzzCase {
219+
calldata: calldata.clone(),
220+
gas: call_result.gas_used,
221+
stipend: call_result.stipend,
222+
});
223+
224+
let RichInvariantResults { success: can_continue, call_result: call_results } =
225+
can_continue(
226+
&invariant_contract,
227+
call_result,
228+
&executor,
229+
&inputs,
230+
&mut failures.borrow_mut(),
231+
&targeted_contracts,
232+
state_changeset,
233+
self.config.fail_on_revert,
234+
self.config.shrink_sequence,
235+
self.config.shrink_run_limit,
236+
);
237+
238+
if !can_continue || current_run == self.config.depth - 1 {
239+
*last_run_calldata.borrow_mut() = inputs.clone();
240+
}
220241

221-
if !can_continue || current_run == self.config.depth - 1 {
222-
*last_run_calldata.borrow_mut() = inputs.clone();
223-
}
242+
if !can_continue {
243+
break
244+
}
224245

225-
if !can_continue {
226-
break
246+
*last_call_results.borrow_mut() = call_results;
247+
current_run += 1;
227248
}
228249

229-
*last_call_results.borrow_mut() = call_results;
230-
231250
// Generates the next call from the run using the recently updated
232251
// dictionary.
233-
inputs.extend(
252+
inputs.push(
234253
strat
235254
.new_tree(&mut branch_runner.borrow_mut())
236255
.map_err(|_| TestCaseError::Fail("Could not generate case".into()))?
@@ -772,7 +791,7 @@ fn can_continue(
772791
failures.reverts += 1;
773792
// If fail on revert is set, we must return immediately.
774793
if fail_on_revert {
775-
let error = InvariantFuzzError::new(
794+
let case_data = FailedInvariantCaseData::new(
776795
invariant_contract,
777796
None,
778797
calldata,
@@ -781,8 +800,8 @@ fn can_continue(
781800
shrink_sequence,
782801
shrink_run_limit,
783802
);
784-
785-
failures.revert_reason = Some(error.revert_reason.clone());
803+
failures.revert_reason = Some(case_data.revert_reason.clone());
804+
let error = InvariantFuzzError::Revert(case_data);
786805
failures.error = Some(error);
787806

788807
return RichInvariantResults::new(false, None)

crates/evm/fuzz/src/strategies/invariants.rs

+1-2
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,10 @@ pub fn invariant_strat(
5959
contracts: FuzzRunIdentifiedContracts,
6060
dictionary_weight: u32,
6161
calldata_fuzz_config: CalldataFuzzDictionary,
62-
) -> impl Strategy<Value = Vec<BasicTxDetails>> {
62+
) -> impl Strategy<Value = BasicTxDetails> {
6363
// We only want to seed the first value, since we want to generate the rest as we mutate the
6464
// state
6565
generate_call(fuzz_state, senders, contracts, dictionary_weight, calldata_fuzz_config)
66-
.prop_map(|x| vec![x])
6766
}
6867

6968
/// Strategy to generate a transaction where the `sender`, `target` and `calldata` are all generated

crates/forge/src/runner.rs

+20-18
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ use foundry_evm::{
2525
fuzz::{invariant::InvariantContract, CounterExample},
2626
traces::{load_contracts, TraceKind},
2727
};
28-
use proptest::test_runner::{TestError, TestRunner};
28+
use proptest::test_runner::TestRunner;
2929
use rayon::prelude::*;
3030
use std::{
3131
collections::{BTreeMap, HashMap},
@@ -513,26 +513,28 @@ impl<'a> ContractRunner<'a> {
513513
let mut logs = logs.clone();
514514
let mut traces = traces.clone();
515515
let success = error.is_none();
516-
let reason = error
517-
.as_ref()
518-
.and_then(|err| (!err.revert_reason.is_empty()).then(|| err.revert_reason.clone()));
516+
let reason = error.as_ref().and_then(|err| err.revert_reason());
519517
let mut coverage = coverage.clone();
520518
match error {
521519
// If invariants were broken, replay the error to collect logs and traces
522-
Some(error @ InvariantFuzzError { test_error: TestError::Fail(_, _), .. }) => {
523-
match error.replay(
524-
self.executor.clone(),
525-
known_contracts,
526-
identified_contracts.clone(),
527-
&mut logs,
528-
&mut traces,
529-
) {
530-
Ok(c) => counterexample = c,
531-
Err(err) => {
532-
error!(%err, "Failed to replay invariant error");
533-
}
534-
};
535-
}
520+
Some(error) => match error {
521+
InvariantFuzzError::BrokenInvariant(case_data) |
522+
InvariantFuzzError::Revert(case_data) => {
523+
match case_data.replay(
524+
self.executor.clone(),
525+
known_contracts,
526+
identified_contracts.clone(),
527+
&mut logs,
528+
&mut traces,
529+
) {
530+
Ok(c) => counterexample = c,
531+
Err(err) => {
532+
error!(%err, "Failed to replay invariant error");
533+
}
534+
};
535+
}
536+
InvariantFuzzError::MaxAssumeRejects(_) => {}
537+
},
536538

537539
// If invariants ran successfully, replay the last run to collect logs and
538540
// traces.

0 commit comments

Comments
 (0)