Skip to content

invariant tests can panic or return incorrect information due to state change during test execution #9764

@nbaztec

Description

@nbaztec

Component

Forge

Have you ensured that all of these are up to date?

  • Foundry
  • Foundryup

What version of Foundry are you on?

forge Version: 0.3.1-dev Commit SHA: 28e6ff1

What version of Foundryup are you on?

No response

What command(s) is the bug in?

forge test

Operating System

Linux

Describe the bug

While generating an invariant strategy in fuzz_param_from_state, the values are generated from the dictionary state:

pub fn fuzz_param_from_state(
    param: &DynSolType,
    state: &EvmFuzzState,
) -> BoxedStrategy<DynSolValue> {
    // Value strategy that uses the state.
    let value = || {
        let state = state.clone();
        let param = param.clone();
        // Generate a bias and use it to pick samples or non-persistent values (50 / 50).
        // Use `Index` instead of `Selector` when selecting a value to avoid iterating over the
        // entire dictionary.
        any::<(bool, prop::sample::Index)>().prop_map(move |(bias, index)| {
            let state = state.dictionary_read();
            let values = if bias { state.samples(&param) } else { None }
                .unwrap_or_else(|| state.values())
                .as_slice();
            values[index.index(values.len())]
        })
    };

In case of addresses, we are filter mapping them (correctly) if they are pre-deployed library addresses and we filter them with None:

    // Convert the value based on the parameter type
    match *param {
        DynSolType::Address => {
            let deployed_libs = state.deployed_libs.clone();
            value()
                .prop_filter_map("filter address fuzzed from state", move |value| {
                    let fuzzed_addr = Address::from_word(value);
                    // Do not use addresses of deployed libraries as fuzz input.
                    // See <https://github.com/foundry-rs/foundry/issues/8639>.
                    if !deployed_libs.contains(&fuzzed_addr) {
                        Some(DynSolValue::Address(fuzzed_addr))
                    } else {
                        None
                    }
                })
                .boxed()
        }

During invariant test execution, we update the dictionary state via collect_data - this has a side-effect of invalidating the rng state for the proptest case generator.

The case tree is generated when the run is invoked. The proptest library expects from here on that case.current() be deterministic - which is computed from the dictionary_state established above. However for tests like invariant_fork_handler_block where the tests are meant to fail, the proptest library tries to construct the TestError while using case.current().

This becomes problematic as the state has now been updated, so case.current() will always return a different input than what was used for the test. In an extreme case if the address just happens to be a pre-deployed library, the computation will yield a None and subsequently panic. Note that the values in a filter_map are only rejected when a new tree is constructed, but this call is actually the result of proptest trying to retrieve case.current() - which will return None and subsequently fail here

Solutions

  • If possible we should avoid changing the rng state during test execution.
  • Patch proptest to no longer rely on case.current() after test execution assuming it can be non-deterministic.

Happy to file a PR once we decide on a way forward.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Status

    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions