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(forge): Invariant Testing v2 #1572

Merged
merged 95 commits into from
Aug 4, 2022
Merged

Conversation

joshieDo
Copy link
Collaborator

@joshieDo joshieDo commented May 9, 2022

Alright, opening for review since it's growing too much. Let's iterate from here.

Summary:

  1. Functions should start with invariant. eg: invariant_neverFalse()
  2. Invariants have the following parameters on the config file
    /// The number of runs that must execute for each invariant test group.
    pub invariant_runs: u32,
    /// The number of calls executed to attempt to break invariants in one run.
    pub invariant_depth: u32,
    /// Fails the invariant fuzzing if a reversion occurs
    pub invariant_fail_on_revert: bool,
    /// Allows randomly overriding an external call when running invariant tests
    pub invariant_call_override: bool,
  1. An invariant with depth of 15 and 256 invariant_runs will have a total of 3840 calls (excluding the invariant checks), if everything succeeds.
  2. We currently fuzz the sender_address, target_address and calldata.
    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.
  3. Invariants are checked right after setUp() and before anything is called.
  4. After each call we:
    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 to targetContracts 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 is false by default, since it requires some work, although it's already functional. (eg. testdata/fuzz/invariant/InvariantReentrancy.t.sol).
More: #1572 (comment)

Examples

testdata/fuzz/invariant/
├── InvariantInnerContract.t.sol
├── InvariantReentrancy.t.sol
├── InvariantTest1.t.sol
└── target
    ├── ExcludeContracts.t.sol
    ├── TargetContracts.t.sol
    ├── TargetSelectors.t.sol
    └── TargetSenders.t.sol

1 directory, 7 files

Follow-up (in no particular order)

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!()
  • clean-up
  • ...

Summary

  • invariant_depth
  • targetContracts(). if not found, then just fuzz every contract created at setUp
  • targetSenders(). if not found, then just fuzz the sender
  • excludeContracts()
  • targetSelectors()
  • invariant_fail_on_revert
  • identify created contracts and fuzz them too

Future/Now?

  • random msg.value

ref #849

@joshieDo joshieDo changed the base branch from brock/invariant to master May 9, 2022 22:20
Copy link
Collaborator

@mds1 mds1 left a 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"

}

pub fn invariant_strat(
depth: usize,
Copy link
Collaborator

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?

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

.boxed()
}

fn select_random_contract(
Copy link
Collaborator

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 the setUp() function has been run, checking the invariant* 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)

Copy link
Collaborator Author

@joshieDo joshieDo May 10, 2022

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

Copy link
Collaborator

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);
  }
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh nice

Comment on lines 355 to 357
// 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<_>>();
Copy link
Collaborator

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:

  1. All stack/memory values, and/or
  2. 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.

}

/// 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
Copy link
Collaborator

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?

Comment on lines 267 to 268
let mid = self.cases.len() / 2;
self.cases.get(mid).map(|c| c.gas).unwrap_or_default()
Copy link
Collaborator

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

stipend,
});
} else {
// call failed, continue on
Copy link
Collaborator

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.

Copy link
Collaborator Author

@joshieDo joshieDo May 10, 2022

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.

Copy link
Collaborator

@mds1 mds1 May 10, 2022

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).

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()

@onbjerg onbjerg added the T-feature Type: feature label May 10, 2022
Copy link
Member

@brockelmore brockelmore left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

few things

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<_>>();
Copy link
Member

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

@@ -0,0 +1,60 @@
// SPDX-License-Identifier: Unlicense
pragma solidity >=0.8.0;
Copy link
Member

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

Copy link
Member

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);
    }
}

));
break
} else {
// todo should we show
Copy link
Member

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

Address::from_slice(
&hex::decode("000000000000000000636F6e736F6c652e6c6f67").unwrap(),
) &&
(selected.is_empty() || selected.contains(addr))
Copy link
Member

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

}
}

pub fn invariant_strat(
Copy link
Member

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

let senders_: Vec<Address> = senders.clone();

if !senders.is_empty() {
// todo should we do an union ? 80% selected 15% random + 0x0 address by default
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes should do union

evm/src/invariant_fuzz.rs Outdated Show resolved Hide resolved
@brockelmore
Copy link
Member

also: update identified contracts after each call (instead of just after setup) < this would be nice to have. In general i think using targetContracts & targetSenders is mostly an antipattern. We should likely do unions for most of these with "random" from state/true random

@mds1
Copy link
Collaborator

mds1 commented May 11, 2022

In general i think using targetContracts & targetSenders is mostly an antipattern

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 targetContracts list, when often the reason to use it is there's just a handful you want to exclude from being called. So I think we should also add support for a function excludeContracts() external returns (address[]) as well.

@mds1
Copy link
Collaborator

mds1 commented Aug 2, 2022

We should make sure deterministic invariant testing is supported using #1658

@joshieDo
Copy link
Collaborator Author

joshieDo commented Aug 2, 2022

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?

@joshieDo joshieDo requested a review from mattsse August 2, 2022 15:36
@mds1
Copy link
Collaborator

mds1 commented Aug 2, 2022

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 fuzz_ and invariant_ prefixes in the config to namespace things (which I like), so having it called fuzz_seed is a bit misleading. I'd propose either calling it rng_seed OR splitting into both fuzz_seed and invariant_seed just to keep the config separation logical

Copy link
Member

@mattsse mattsse left a 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

};
mod call_override;
pub use call_override::{set_up_inner_replay, RandomCallGenerator};

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change

evm/src/fuzz/strategies/invariants.rs Outdated Show resolved Hide resolved
evm/src/fuzz/strategies/invariants.rs Outdated Show resolved Hide resolved
@gakonst
Copy link
Member

gakonst commented Aug 2, 2022

@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.

@joshieDo
Copy link
Collaborator Author

joshieDo commented Aug 3, 2022

good to go!

@gakonst
Copy link
Member

gakonst commented Aug 3, 2022

@joshieDo just the merge conflicts remaining and good to go

/// 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]>>>;
Copy link
Member

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

@joshieDo
Copy link
Collaborator Author

joshieDo commented Aug 3, 2022

Test failure seems unrelated? Also happening on master

@onbjerg
Copy link
Member

onbjerg commented Aug 3, 2022

Yeah, unrelated

@mattsse
Copy link
Member

mattsse commented Aug 4, 2022

merge whenever!

@gakonst gakonst merged commit 262bdf2 into foundry-rs:master Aug 4, 2022
@gakonst
Copy link
Member

gakonst commented Aug 4, 2022

lfg

iFrostizz pushed a commit to iFrostizz/foundry that referenced this pull request Nov 9, 2022
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
T-feature Type: feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

8 participants