Writing Function-Level Invariants
This chapter will walk you through writing function-level fuzz tests for the deposit
function that we saw in the previous chapter.
Before we write the fuzz tests, let's look into how we would write a unit test for the deposit
function:
function testDeposit() public {
// The amount of tokens to deposit
uint256 amount = 10 ether;
// Retrieve balance of user before deposit
preBalance = depositContract.balances(address(this));
// Call the deposit contract (let's assume this contract has 10 ether)
depositContract.deposit{value: amount}();
// Assert post-conditions
assert(depositContract.balances(msg.sender) == preBalance + amount);
// Add other assertions here
}
What we will notice about the test above is that it fixes the value that is being sent. It is unable to test how the
deposit
function behaves across a variety of input spaces. Thus, a function-level fuzz test can be thought of as a
"unit test on steroids". Instead of fixing the amount
, we let the fuzzer control the amount
value to any number between
[0, type(uint256).max]
and see how the system behaves to that.
Note: One of the core differences between a traditional unit test versus a fuzz test is that a fuzz test accepts input arguments that the fuzzer can control.
Writing a Fuzz Test for the deposit
Function
Here is what a fuzz test for the deposit
function would look like:
function testDeposit(uint256 _amount) public {
// Let's bound the input to be _at most_ the ETH balance of this contract
// The amount value will now in between [0, address(this).balance]
uint256 amount = clampLte(_amount, address(this).balance);
// Retrieve balance of user before deposit
uint256 preBalance = depositContract.balances(address(this));
// Call the deposit contract with a variable amount
depositContract.deposit{value: _amount}();
// Assert post-conditions
assert(depositContract.balances(address(this)) == preBalance + amount);
// Add other assertions here
}
Notice that we bounded the _amount
variable to be less than or equal to the test contract's ETH balance.
This type of bounding is very common when writing fuzz tests. Bounding allows you to only test values that are reasonable.
If address(this)
doesn't have enough ETH, it does not make sense to try and call the deposit
function. Additionally,
although we only tested one of the function-level invariants from the previous chapter, writing the remaining
would follow a similar pattern as the one written above.
Running a function-level test with medusa
Let's now run the above example with medusa. Here is the test code:
contract DepositContract {
// @notice MAX_DEPOSIT_AMOUNT is the maximum amount that can be deposited into this contract
uint256 public constant MAX_DEPOSIT_AMOUNT = 1_000_000e18;
// @notice balances holds user balances
mapping(address => uint256) public balances;
// @notice totalDeposited represents the current deposited amount across all users
uint256 public totalDeposited;
// @notice Deposit event is emitted after a deposit occurs
event Deposit(address depositor, uint256 amount, uint256 totalDeposited);
// @notice deposit allows user to deposit into the system
function deposit() public payable {
// Make sure that the total deposited amount does not exceed the limit
uint256 amount = msg.value;
require(totalDeposited + amount <= MAX_DEPOSIT_AMOUNT);
// Update the user balance and total deposited
balances[msg.sender] += amount;
totalDeposited += amount;
emit Deposit(msg.sender, amount, totalDeposited);
}
}
contract TestDepositContract {
// @notice depositContract is an instance of DepositContract
DepositContract depositContract;
constructor() payable {
// Deploy the deposit contract
depositContract = new DepositContract();
}
// @notice testDeposit tests the DepositContract.deposit function
function testDeposit(uint256 _amount) public {
// Let's bound the input to be _at most_ the ETH balance of this contract
// The amount value will now in between [0, address(this).balance]
uint256 amount = clampLte(_amount, address(this).balance);
// Retrieve balance of user before deposit
uint256 preBalance = depositContract.balances(address(this));
// Call the deposit contract with a variable amount
depositContract.deposit{value: _amount}();
// Assert post-conditions
assert(depositContract.balances(address(this)) == preBalance + amount);
// Add other assertions here
}
// @notice clampLte returns a value between [a, b]
function clampLte(uint256 a, uint256 b) internal returns (uint256) {
if (!(a <= b)) {
uint256 value = a % (b + 1);
return value;
}
return a;
}
}
To run this test contract, download the project configuration file here,
rename it to medusa.json
, and run:
medusa fuzz --config medusa.json
The following changes were made to the default project configuration file to allow this test to run:
fuzzing.targetContracts
: Thefuzzing.targetContracts
value was updated to["TestDepositContract"]
.fuzzing.targetContractsBalances
: Thefuzzing.targetContractsBalances
was updated to["0xfffffffffffffffffffffffffffffff"]
to allow theTestDepositContract
contract to have an ETH balance allowing the fuzzer to correctly deposit funds into theDepositContract
.fuzzing.testLimit
: Thefuzzing.testLimit
was set to1_000
to shorten the duration of the fuzzing campign.fuzzing.callSequenceLength
: Thefuzzing.callSequenceLength
was set to1
so that theTestDepositContract
can be reset with its full ETH balance after each transaction.