-
Notifications
You must be signed in to change notification settings - Fork 1.8k
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(forge): Invariant Testing v2 #1572
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesomeee thank you @joshieDo! Only skimmed the code / haven't tested it yet but left a few thoughts.
Also, do we check the invariant during the post-setUp state before executing any additional calls? This was mentioned in #69 because "sometimes it doesn’t hold off the bat but then that changes after txs or if all the txs revert then the invariant will show as passed when it may have totally failed if ever called"
evm/src/invariant_fuzz.rs
Outdated
} | ||
|
||
pub fn invariant_strat( | ||
depth: usize, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's make sure to expose the depth in the config. Perhaps invariant_runs
and invariant_depth
, so you can control it separately from fuzz_runs
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I agree with this, we should have a param separate from fuzz_runs
evm/src/invariant_fuzz.rs
Outdated
.boxed() | ||
} | ||
|
||
fn select_random_contract( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In dapptools, the way this worked was:
hevm will invoke any state mutating function from all addresses returned by a call to
targetContracts()
, if such a function exists in the testing contracts. If no such method exists, it will invoke methods from any non-testing contract available after thesetUp()
function has been run, checking theinvariant*
after each run.
I think currently all test and non-test contracts are included by default, but I like hevm's approach and would suggest sticking with that (open to alternative ideas though)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it will invoke methods from any non-testing contract available after the setUp() function has been run, checking the invariant* after each run.
Unless I misunderstood something, this is what is happening right now. Any non testing contract created during the setUp
function is used.
all addresses returned by a call to targetContracts()
As a way to filter-out contracts we don't want to fuzz? Because if it includes addresses from a forked state, then it requires the use of etherscan
edit: added targetContracts() as a filter-out mechanism. etherscan can probably be added in the future
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unless I misunderstood something, this is what is happening right now. Any non testing contract created during the
setUp
function is used.
Ah got it, ok perfect 👌
As a way to filter-out contracts we don't want to fuzz? Because if it includes addresses from a forked state, then it requires the use of etherscan
edit: added targetContracts() as a filter-out mechanism. etherscan can probably be added in the future
Yep exactly! Sometimes considering all non-test contracts deployed during setUp
is too broad and you want to limit it. That sounds great though. Lack of etherscan support seems ok because you can workaround it by adding a local contract that does something like the below, and including that contract in your targetContracts()
array.
contract DaiHarness {
function transfer(address from, address to, uint256 amount) public {
vm.prank(from);
dai.transfer(to, amount);
}
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
oh nice
evm/src/invariant_fuzz.rs
Outdated
// We need to compose all the strategies generated for each parameter in all | ||
// possible combinations | ||
let strats = func.inputs.iter().map(|input| fuzz_param(&input.kind)).collect::<Vec<_>>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@brockelmore Have we talked about additional invariant-specific strategies for generating calldata? I think it'd be interesting to add:
- All stack/memory values, and/or
- The output of calling view methods which have no inputs
to the dict for the lifetime of one invariant run, then remove them for the subsequent run to avoid flooding the dict. Maybe a separate issue/PR though.
evm/src/invariant_fuzz.rs
Outdated
} | ||
|
||
/// Fuzzes the provided function, assuming it is available at the contract at `address` | ||
/// If `should_fail` is set to `true`, then it will stop only when there's a success |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This sounds like it refers to something like testFail
, but we probably don't need to support invariantFail
style tests—is there a use case for that?
evm/src/invariant_fuzz.rs
Outdated
let mid = self.cases.len() / 2; | ||
self.cases.get(mid).map(|c| c.gas).unwrap_or_default() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seems this doesn't handle when cases
has an even length
evm/src/invariant_fuzz.rs
Outdated
stipend, | ||
}); | ||
} else { | ||
// call failed, continue on |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Say the 3rd call in a sequence reverts (i.e. the fuzz call itself reverts, not a failed invariant), do we generate a new call or move on to the fourth call in the sequence? Looks like the latter, so wondering if we should have a flag to instead generate a new call, to help mitigate situations where many calls in the sequence revert?
For reference, relevant portion of dapptools docs:
Note that a revert in any of the randomly generated call will not trigger a test failure. The goal of invariant tests is to find a state change that results in a violation of the assertions defined in the body of the test method, and since reverts do not result in a state change, they can be safely ignored. Reverts within the body of the invariant* test method will however still cause a test failure.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i wonder if it really makes a difference tho, since it's basically a random walk ? at what Nth would you stop generating new calls?
ideally you would just increase the depth to counteract that, or better, improve your setUp
with good targetContracts
and targetSenders
although having a report on the number of reverted calls might be interesting, so the user can adjust his settings accordingly.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
although having a report on the number of reverted calls might be interesting, so the user can adjust his settings accordingly.
Good points, this seems like a good compromise. Have the option to report min/max/mean/median number of reverts.
Another idea is to have a targetSelectors()
method which returns a tuple of (address, bytes4[])[]
so you can have fine grained control over the allowed methods (echidna has similar functionality where you can allow/block function selectors).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ooooh I like the idea of targetSelectors()
5d2dab0
to
b838233
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
few things
evm/src/invariant_fuzz.rs
Outdated
pub fn fuzz_calldata(addr: Address, func: Function) -> impl Strategy<Value = (Address, Bytes)> { | ||
// We need to compose all the strategies generated for each parameter in all | ||
// possible combinations | ||
let strats = func.inputs.iter().map(|input| fuzz_param(&input.kind)).collect::<Vec<_>>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use fuzz_param_from_state
and pass in state dictionary here, and union two strategies strategies with state weight and random weight like we do in normal fuzz tests
testdata/fuzz/InvariantFuzz.t.sol
Outdated
@@ -0,0 +1,60 @@ | |||
// SPDX-License-Identifier: Unlicense | |||
pragma solidity >=0.8.0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add this as a test:
contract InvariantBreaker {
bool public flag0 = true;
bool public flag1 = true;
function set0(int val) public returns (bool){
if (val % 100 == 0)
flag0 = false;
return flag0;
}
function set1(int val) public returns (bool){
if (val % 10 == 0 && !flag0)
flag1 = false;
return flag1;
}
}
contract InvariantTest is Test {
InvariantBreaker inv;
function setUp() public {
inv = new InvariantBreaker();
}
function invariant_neverFalse() public {
require(inv.flag1());
}
}
Showcases the need to do multi-tx + specific inputs
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A stress test I dont know that we will solve:
contract Foo {
int256 private x;
int256 private y;
constructor () public {
x = 0;
y = 0;
}
function Bar() public view returns (int256) {
if (x == 42) {
assert(false);
return 1;
}
return 0;
}
function SetY(int256 ny) public {
y = ny;
}
function IncX() public {
x++;
}
function CopyY() public {
x = y;
}
}
contract ContractTest is Test {
Foo foo;
function setUp() public {
foo = new Foo();
}
function invariant_canBar() public {
(bool success, ) = address(foo).call(abi.encodePacked(foo.Bar.selector));
require(success);
}
}
forge/src/runner.rs
Outdated
)); | ||
break | ||
} else { | ||
// todo should we show |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I lean towards no but argument could definitely be made to show calls to invariants that pass. I think we should not include them right now based on my previous experience showing them and it being a bit confusing/crowded
evm/src/invariant_fuzz.rs
Outdated
Address::from_slice( | ||
&hex::decode("000000000000000000636F6e736F6c652e6c6f67").unwrap(), | ||
) && | ||
(selected.is_empty() || selected.contains(addr)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nice. glad this is optional. I hate dapptool's ux there, but agree its necessary
evm/src/invariant_fuzz.rs
Outdated
} | ||
} | ||
|
||
pub fn invariant_strat( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
lets move all strategy related things to its own file in foundry/evm/src/fuzz/strategies
evm/src/invariant_fuzz.rs
Outdated
let senders_: Vec<Address> = senders.clone(); | ||
|
||
if !senders.is_empty() { | ||
// todo should we do an union ? 80% selected 15% random + 0x0 address by default |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yes should do union
also: |
Just chatted with @brockelmore about this and he had a good point that you're more likely to forget to include a contract in the |
We should make sure deterministic invariant testing is supported using #1658 |
Yeah, should be there with the recent merge. I also added it to the invariant runner. But they're sharing... is that fine or would you wish a different var? |
Sharing seems fine to keep it simple, I can't really think of a use case where you'd need them to be separate. One oddity is we use |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
great, only a couple of smol nits style nits.
even though lot of the lower-level stuff is a bit verbose, just because of the evm/test API, this is now readable enough to make sense of it
very nice
evm/src/fuzz/invariant/mod.rs
Outdated
}; | ||
mod call_override; | ||
pub use call_override::{set_up_inner_replay, RandomCallGenerator}; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@joshieDo whenever you feel good with this we can ship it. let's also open an issue on the book w/ the things we want to track? there's a lot to process here, the targetContracts pattern, the patterns from @lucas-manuel's https://github.com/maple-labs/revenue-distribution-token/tree/e0eca03c2ff05c36000a097de678543d7234f7cc/contracts/test tests etc. |
good to go! |
@joshieDo just the merge conflicts remaining and good to go |
evm/src/fuzz/strategies/state.rs
Outdated
/// A set of arbitrary 32 byte data from the VM used to generate values for the strategy. | ||
/// | ||
/// Wrapped in a shareable container. | ||
pub type EvmFuzzState = Rc<RefCell<HashSet<[u8; 32]>>>; | ||
pub type EvmFuzzState = Arc<RwLock<HashSet<[u8; 32]>>>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just noting here for merge conflict that it is important that this becomes a BTreeSet
, otherwise the fuzzer is not deterministic when you give it a seed
Test failure seems unrelated? Also happening on master |
Yeah, unrelated |
merge whenever! |
lfg |
* 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>
Alright, opening for review since it's growing too much. Let's iterate from here.
Summary:
invariant
. eg:invariant_neverFalse()
depth
of 15 and 256invariant_runs
will have a total of 3840 calls (excluding the invariant checks), if everything succeeds.sender_address
,target_address
andcalldata
.3.1 This data is generated by different
proptest::TestRunner
.3.2 The data is influenced by a dictionary that is filled with new values throughout the runs.
setUp()
and before anything is called.4.1 Collect stack/memory data to add to the fuzzing dictionary.
4.2 Collect newly created contracts and add them as potential
target_address
.4.3 Make calls to the test contract
invariant*
functions. If any fails, we exit the run.4.4 If all invariants have been broken, then exit asap.
Filtering
To better bound the test space there are certain filters that can be used:
targetContracts(): address[]
: a contract address will be chosen from this list.targetSenders(): address[]
: 80% chance that a contract address will be chosen from this list.targetSelectors(): (address, bytes4[])[]
: adds totargetContracts
list, and only the selectors provided will be called.excludeContracts(): address[]
: contract addresses from the setup process which are to be excluded from being targeted.Unsafe/External call overriding
invariant_call_override
allows for an external call to be overridden to simulate unsafe calls. This feature isfalse
by default, since it requires some work, although it's already functional. (eg.testdata/fuzz/invariant/InvariantReentrancy.t.sol
).More: #1572 (comment)
Examples
Follow-up (in no particular order)
invariant_runs
for now...)fuzz_seed
torng_seed
old summary
However, since the repo diverged quite a bit from the `sputnik` times, I wanted to get some potential feedback, since I'm still getting acquainted with the fuzzing modules, and I'm probably missing some things and/or design choices.Missing
forgetest!()
Summary
invariant_depth
targetContracts()
. if not found, then just fuzz every contract created atsetUp
targetSenders()
. if not found, then just fuzz the senderexcludeContracts()
targetSelectors()
invariant_fail_on_revert
Future/Now?
ref #849