Skip to content

Commit

Permalink
feature(invariant) - persist and replay failure (#7899)
Browse files Browse the repository at this point in the history
* feature(invariant) - persist and replay failure

* Fix unit test

* Changes after review:
- replace test cache rm macro with closure
- use commons for load / persist failure sequence

* Changes after review: display proper message if replayed sequence reverts before checking invariant

* Changes after review: simplify check sequence logic
  • Loading branch information
grandizzy authored May 21, 2024
1 parent 1b08ae4 commit c9ae920
Show file tree
Hide file tree
Showing 14 changed files with 343 additions and 164 deletions.
37 changes: 34 additions & 3 deletions crates/config/src/invariant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ use crate::{
},
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// Contains for invariant testing
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InvariantConfig {
/// The number of runs that must execute for each invariant test group.
pub runs: u32,
Expand All @@ -31,6 +32,8 @@ pub struct InvariantConfig {
pub max_assume_rejects: u32,
/// Number of runs to execute and include in the gas report.
pub gas_report_samples: u32,
/// Path where invariant failures are recorded and replayed.
pub failure_persist_dir: Option<PathBuf>,
}

impl Default for InvariantConfig {
Expand All @@ -44,10 +47,36 @@ impl Default for InvariantConfig {
shrink_run_limit: 2usize.pow(18_u32),
max_assume_rejects: 65536,
gas_report_samples: 256,
failure_persist_dir: None,
}
}
}

impl InvariantConfig {
/// Creates invariant configuration to write failures in `{PROJECT_ROOT}/cache/fuzz` dir.
pub fn new(cache_dir: PathBuf) -> Self {
InvariantConfig {
runs: 256,
depth: 15,
fail_on_revert: false,
call_override: false,
dictionary: FuzzDictionaryConfig { dictionary_weight: 80, ..Default::default() },
shrink_run_limit: 2usize.pow(18_u32),
max_assume_rejects: 65536,
gas_report_samples: 256,
failure_persist_dir: Some(cache_dir),
}
}

/// Returns path to failure dir of given invariant test contract.
pub fn failure_dir(self, contract_name: &str) -> PathBuf {
self.failure_persist_dir
.unwrap()
.join("failures")
.join(contract_name.split(':').last().unwrap())
}
}

impl InlineConfigParser for InvariantConfig {
fn config_key() -> String {
INLINE_CONFIG_INVARIANT_KEY.into()
Expand All @@ -60,8 +89,7 @@ impl InlineConfigParser for InvariantConfig {
return Ok(None)
}

// self is Copy. We clone it with dereference.
let mut conf_clone = *self;
let mut conf_clone = self.clone();

for pair in overrides {
let key = pair.0;
Expand All @@ -71,6 +99,9 @@ impl InlineConfigParser for InvariantConfig {
"depth" => conf_clone.depth = parse_config_u32(key, value)?,
"fail-on-revert" => conf_clone.fail_on_revert = parse_config_bool(key, value)?,
"call-override" => conf_clone.call_override = parse_config_bool(key, value)?,
"failure-persist-dir" => {
conf_clone.failure_persist_dir = Some(PathBuf::from(value))
}
_ => Err(InlineConfigParserError::InvalidConfigProperty(key.to_string()))?,
}
}
Expand Down
27 changes: 18 additions & 9 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use foundry_compilers::{
},
cache::SOLIDITY_FILES_CACHE_FILENAME,
compilers::{solc::SolcVersionManager, CompilerVersionManager},
error::{SolcError, SolcIoError},
error::SolcError,
remappings::{RelativeRemapping, Remapping},
CompilerConfig, ConfigurableArtifacts, EvmVersion, Project, ProjectPathsConfig, Solc,
SolcConfig,
Expand Down Expand Up @@ -793,13 +793,17 @@ impl Config {
pub fn cleanup(&self, project: &Project) -> Result<(), SolcError> {
project.cleanup()?;

// Remove fuzz cache directory.
if let Some(fuzz_cache) = &self.fuzz.failure_persist_dir {
let path = project.root().join(fuzz_cache);
if path.exists() {
std::fs::remove_dir_all(&path).map_err(|e| SolcIoError::new(e, path))?;
// Remove fuzz and invariant cache directories.
let remove_test_dir = |test_dir: &Option<PathBuf>| {
if let Some(test_dir) = test_dir {
let path = project.root().join(test_dir);
if path.exists() {
let _ = fs::remove_dir_all(&path);
}
}
}
};
remove_test_dir(&self.fuzz.failure_persist_dir);
remove_test_dir(&self.invariant.failure_persist_dir);

Ok(())
}
Expand Down Expand Up @@ -1958,7 +1962,7 @@ impl Default for Config {
path_pattern: None,
path_pattern_inverse: None,
fuzz: FuzzConfig::new("cache/fuzz".into()),
invariant: Default::default(),
invariant: InvariantConfig::new("cache/invariant".into()),
always_use_create_2_factory: false,
ffi: false,
prompt_timeout: 120,
Expand Down Expand Up @@ -4461,7 +4465,12 @@ mod tests {
let loaded = Config::load().sanitized();
assert_eq!(
loaded.invariant,
InvariantConfig { runs: 512, depth: 10, ..Default::default() }
InvariantConfig {
runs: 512,
depth: 10,
failure_persist_dir: Some(PathBuf::from("cache/invariant")),
..Default::default()
}
);

Ok(())
Expand Down
13 changes: 4 additions & 9 deletions crates/evm/evm/src/executors/fuzz/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,15 +175,10 @@ impl FuzzedExecutor {
} else {
vec![]
};
result.counterexample = Some(CounterExample::Single(BaseCounterExample {
sender: None,
addr: None,
signature: None,
contract_name: None,
traces: call.traces,
calldata,
args,
}));

result.counterexample = Some(CounterExample::Single(
BaseCounterExample::from_fuzz_call(calldata, args, call.traces),
));
}
_ => {}
}
Expand Down
1 change: 1 addition & 0 deletions crates/evm/evm/src/executors/invariant/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ mod result;
pub use result::InvariantFuzzTestResult;

mod shrink;
pub use shrink::check_sequence;

sol! {
interface IInvariantTest {
Expand Down
46 changes: 25 additions & 21 deletions crates/evm/evm/src/executors/invariant/replay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use foundry_evm_core::constants::CALLER;
use foundry_evm_coverage::HitMaps;
use foundry_evm_fuzz::{
invariant::{BasicTxDetails, InvariantContract},
BaseCounterExample, CounterExample,
BaseCounterExample,
};
use foundry_evm_traces::{load_contracts, TraceKind, Traces};
use parking_lot::RwLock;
Expand All @@ -28,7 +28,7 @@ pub fn replay_run(
traces: &mut Traces,
coverage: &mut Option<HitMaps>,
inputs: Vec<BasicTxDetails>,
) -> Result<Option<CounterExample>> {
) -> Result<Vec<BaseCounterExample>> {
// We want traces for a failed case.
executor.set_tracing(true);

Expand Down Expand Up @@ -60,31 +60,34 @@ pub fn replay_run(
));

// Create counter example to be used in failed case.
counterexample_sequence.push(BaseCounterExample::create(
counterexample_sequence.push(BaseCounterExample::from_invariant_call(
tx.sender,
tx.call_details.target,
&tx.call_details.calldata,
&ided_contracts,
call_result.traces,
));

// Replay invariant to collect logs and traces.
let error_call_result = executor.call_raw(
CALLER,
invariant_contract.address,
invariant_contract
.invariant_function
.abi_encode_input(&[])
.expect("invariant should have no inputs")
.into(),
U256::ZERO,
)?;
traces.push((TraceKind::Execution, error_call_result.traces.clone().unwrap()));
logs.extend(error_call_result.logs);
}

Ok((!counterexample_sequence.is_empty())
.then_some(CounterExample::Sequence(counterexample_sequence)))
// Replay invariant to collect logs and traces.
// We do this only once at the end of the replayed sequence.
// Checking after each call doesn't add valuable info for passing scenario
// (invariant call result is always success) nor for failed scenarios
// (invariant call result is always success until the last call that breaks it).
let invariant_result = executor.call_raw(
CALLER,
invariant_contract.address,
invariant_contract
.invariant_function
.abi_encode_input(&[])
.expect("invariant should have no inputs")
.into(),
U256::ZERO,
)?;
traces.push((TraceKind::Execution, invariant_result.traces.clone().unwrap()));
logs.extend(invariant_result.logs);

Ok(counterexample_sequence)
}

/// Replays the error case, shrinks the failing sequence and collects all necessary traces.
Expand All @@ -98,15 +101,16 @@ pub fn replay_error(
logs: &mut Vec<Log>,
traces: &mut Traces,
coverage: &mut Option<HitMaps>,
) -> Result<Option<CounterExample>> {
) -> Result<Vec<BaseCounterExample>> {
match failed_case.test_error {
// Don't use at the moment.
TestError::Abort(_) => Ok(None),
TestError::Abort(_) => Ok(vec![]),
TestError::Fail(_, ref calls) => {
// Shrink sequence of failed calls.
let calls = shrink_sequence(failed_case, calls, &executor)?;

set_up_inner_replay(&mut executor, &failed_case.inner_sequence);

// Replay calls to get the counterexample and to collect logs, traces and coverage.
replay_run(
invariant_contract,
Expand Down
63 changes: 33 additions & 30 deletions crates/evm/evm/src/executors/invariant/shrink.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::executors::{invariant::error::FailedInvariantCaseData, Executor};
use alloy_primitives::U256;
use alloy_primitives::{Address, Bytes, U256};
use foundry_evm_core::constants::CALLER;
use foundry_evm_fuzz::invariant::BasicTxDetails;
use proptest::bits::{BitSetLike, VarBitSet};
Expand Down Expand Up @@ -97,28 +97,39 @@ pub(crate) fn shrink_sequence(
let mut shrinker = CallSequenceShrinker::new(calls.len());
for _ in 0..failed_case.shrink_run_limit {
// Check candidate sequence result.
match check_sequence(failed_case, executor.clone(), calls, shrinker.current().collect()) {
match check_sequence(
executor.clone(),
calls,
shrinker.current().collect(),
failed_case.addr,
failed_case.func.clone(),
failed_case.fail_on_revert,
) {
// If candidate sequence still fails then shrink more if possible.
Ok(false) if !shrinker.simplify() => break,
Ok((false, _)) if !shrinker.simplify() => break,
// If candidate sequence pass then restore last removed call and shrink other
// calls if possible.
Ok(true) if !shrinker.complicate() => break,
Ok((true, _)) if !shrinker.complicate() => break,
_ => {}
}
}

Ok(shrinker.current().map(|idx| &calls[idx]).cloned().collect())
}

/// Checks if the shrinked sequence fails test, if it does then we can try simplifying more.
fn check_sequence(
failed_case: &FailedInvariantCaseData,
/// Checks if the given call sequence breaks the invariant.
/// Used in shrinking phase for checking candidate sequences and in replay failures phase to test
/// persisted failures.
/// Returns the result of invariant check and if sequence was entirely applied.
pub fn check_sequence(
mut executor: Executor,
calls: &[BasicTxDetails],
sequence: Vec<usize>,
) -> eyre::Result<bool> {
let mut sequence_failed = false;
// Apply the shrinked candidate sequence.
test_address: Address,
test_function: Bytes,
fail_on_revert: bool,
) -> eyre::Result<(bool, bool)> {
// Apply the call sequence.
for call_index in sequence {
let tx = &calls[call_index];
let call_result = executor.call_raw_committing(
Expand All @@ -127,30 +138,22 @@ fn check_sequence(
tx.call_details.calldata.clone(),
U256::ZERO,
)?;
if call_result.reverted && failed_case.fail_on_revert {
if call_result.reverted && fail_on_revert {
// Candidate sequence fails test.
// We don't have to apply remaining calls to check sequence.
sequence_failed = true;
break;
return Ok((false, false));
}
}
// Return without checking the invariant if we already have failing sequence.
if sequence_failed {
return Ok(false);
};

// Check the invariant for candidate sequence.
// If sequence fails then we can continue with shrinking - the removed call does not affect
// failure.
//
// If sequence doesn't fail then we have to restore last removed call and continue with next
// call - removed call is a required step for reproducing the failure.
let mut call_result =
executor.call_raw(CALLER, failed_case.addr, failed_case.func.clone(), U256::ZERO)?;
Ok(executor.is_raw_call_success(
failed_case.addr,
Cow::Owned(call_result.state_changeset.take().unwrap()),
&call_result,
false,
// Check the invariant for call sequence.
let mut call_result = executor.call_raw(CALLER, test_address, test_function, U256::ZERO)?;
Ok((
executor.is_raw_call_success(
test_address,
Cow::Owned(call_result.state_changeset.take().unwrap()),
&call_result,
false,
),
true,
))
}
Loading

0 comments on commit c9ae920

Please sign in to comment.