Invariant Validation through Fuzz Testing: A Case Study on DFX Finance
Tags: solidity, web3, ethereum
Published: 2023-08-29 00:03:55


## Introduction

While reading this [article on ImmunFi](https://medium.com/immunefi/dfx-finance-rounding-error-bugfix-review-17ba5ffb4114) about a white-hat hacker that found a rounding error in the DFX protocol when a pool contained a token with 2 decimals, I had an idea to test if this bug could be found using a property based fuzz test. This article is an overview of how I identified the protocol invariant and setup a fuzz test against the deployed contracts on Polygon.

## Protocol Invariant for DFX Finance

[DFX](https://dfx.finance) is an implementation of the Shell protocol, which is outlined in this [white paper](https://github.com/cowri/shell-solidity-v1/blob/master/Shell_White_Paper_v1.0.pdf). Equation 5 of the paper defines the protocol invariant, which states that the utility {% katex inline %}U{% endkatex %} should either increase or stay the same following a transaction that changes the reserves.
{% katex %}
{U(x) \over T} <= {U(x+x') \over T + y}
{% endkatex %}

{% katex inline %}x{% endkatex %} represents the reserves before a change and {% katex inline %}x'{% endkatex %} the amount of tokens deposited or withdrawn.

While the DFX smart contracts do not expose any public methods to compute this invariant, I found the implementation encapsulated within the [Curve Math contract](https://github.com/dfx-finance/protocol-v2/blob/main/src/CurveMath.sol). To compute the invariant for this test, I copied the implementation of these functions to a new smart contract in my [repo](https://github.com/khegeman/woke-dfx-fuzz/blob/main/tests/contracts/Invariant.sol).

## The Fuzz Test

Below is the basic structure of the fuzz test I created using the [Woke](https://ackeeblockchain.com/woke/docs/latest/). A flow is a method that performs test actions (for those familiar with Hypothesis, a flow is a [rule](https://hypothesis.readthedocs.io/en/latest/stateful.html#rules)) using randomized inputs. A sequence is a collection of one or more flows that are randomly ordered. Before each sequence begins, the `pre_sequence` is called, this is used for some initialization such as deploying the contract I created that computes the invariant. Two flows are defined, `deposit` and `withdraw` which call the corresponding methods on the DFX Curve [contract](https://polygonscan.com/address/0x2385D7aB31F5a470B1723675846cb074988531da). After each flow is called, the utility invariant is executed to verify that that the transactions in the prior flow did not cause the utility to decrease.

```python
class DFXFuzzTest(FuzzTest):
    _config: DFXChainConfig
    _invariant: Invariant
    _curve: Curve

    #hypothesis style random data generators
    st_amount = st.random_int(
            min=20000000000000000000, max=200000000000000000000, edge_values_prob=0.05
        )
    st_percent = st.random_float(min=0, max=1)    

    def __init__(self, chainConfig: DFXChainConfig):
        self._config = chainConfig

    def pre_sequence(self) -> None:
        """Initialize the environment for the contract.

        This function attaches to a deployed contract and calculates the initial invariant.

        Note:
            pre_sequence is called before the first flow to setup the required state
        """
        self._curve = self._config.pool
        self._invariant = Invariant.deploy()
        self._previous_inv = self._calcInvariant()

    @flow()
    def deposit(self, st_amount : uint):
        self._impl_deposit(st_amount)

    @flow()
    def withdraw(self, st_percent : float):        
        self._impl_withdraw(st_percent)

    @invariant(period=1)
    def utility(self) -> None:
        """Check and update the utility invariant
    
        This function asserts that the invariant (as calculated by _calcInvariant) 
        has not decreased. This is to ensure that "utility" in the pool is not decreasing.
    
        Raises:
            AssertionError: If the invariant has decreased.
        """
        inv = self._calcInvariant()
        assert inv >= self._previous_inv, "utility decreased."
        self._previous_inv = inv
    
```

## Running the Test

The function below is used to setup the fuzz test for execution. The `connect` decorator points the fuzz test at an [Alchemy](https://www.alchemy.com) node instance for Polygon, using the `@` syntax to specify a specific block to fork the network using [Anvil]([anvil Reference - Foundry Book](https://book.getfoundry.sh/reference/anvil/).

`DFXChainConfig` is a small class I created to store chain-specific parameters, such as all the contract addresses for the Curve contract and ERC20 tokens. Using this setup allows me to easily run the same test on Ethereum mainnet or other network.

```python
@default_chain.connect(fork=f"{os.getenv('POL_RPC_URL')}@{39422000}")
def test_polygon_before():
    default_chain.set_default_accounts(default_chain.accounts[0])

    PolygonChain = DFXChainConfig(
        pool=Curve(Address("0x2385D7aB31F5a470B1723675846cb074988531da")),
        EURS=IERC20(Address("0xE111178A87A3BFf0c8d18DECBa5798827539Ae99")),
        USDC=IERC20(Address("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")),
        USDC_w=Address("0xBA12222222228d8Ba445958a75a0704d566BF2C8"),
        EURS_w=Address("0x38d693ce1df5aadf7bc62595a37d667ad57922e5"),
        add=0,
    )

    DFXFuzzTest(PolygonChain).run(sequences_count=1, flows_count=30)
```

Finally, to execute the test with Woke. `-s 44` specifies a seed for the random number generator. Using the same seed yields a reproducible result and `-n 1` runs the test in 1 process.

```
woke fuzz -s 44 -n 1 tests/test_polygon.py
```

The test immediately fails on the first deposit, confirming that the invariant was violated by the deposit.

![Failed test output](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sqse81pwoat8go37nu1w.png)

To verify the fix, I set up a second test using a recent block number and found that the test now passed, confirming the bug was resolved.

```python
@default_chain.connect(fork=f"{os.getenv('POL_RPC_URL')}@{45204868}")
def test_polygon_after():
    
    default_chain.set_default_accounts(default_chain.accounts[0])

    PolygonChain = DFXChainConfig(
        pool=Curve(Address("0x2385D7aB31F5a470B1723675846cb074988531da")),
        EURS=IERC20(Address("0xE111178A87A3BFf0c8d18DECBa5798827539Ae99")),
        USDC=IERC20(Address("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")),
        USDC_w=Address("0xBA12222222228d8Ba445958a75a0704d566BF2C8"),
        EURS_w=Address("0x38d693ce1df5aadf7bc62595a37d667ad57922e5"),
        add=100,
    )

    DFXFuzzTest(PolygonChain).run(sequences_count=1, flows_count=30)
```

This tests runs through randomized deposits and withdraws with no errors, indicating that the bug was fixed.

## Conclusion

This article gives an overview of how to create and execute a property based fuzz test for Solidity contracts. The tests can be run against production contracts by using a local fork of the network. Building property tests to verify correct behavior when deploying to new blockchains and tokens is a vital piece of the security puzzle.

The full code for the test in this article is available on [Github](https://github.com/khegeman/woke-dfx-fuzz)