A Complete Guide to ERC20 Fuzz and Invariant Testing Using Foundry

Pari Tomar
Sphere Audits
Published in
13 min readMar 18, 2024

Foundry is a smart contract development toolchain. It manages the dependencies, compiles your project, runs tests, deploys, and lets you interact with the chain from the command line and via Solidity scripts.

Therefore, this comprehensive guide will delve into ERC20 fuzzing and invariant testing using Foundry, allowing developers and auditors with the knowledge to secure their smart contracts effectively.

Focus of This Article

  • Writing an ERC20 contract without relying on third-party libraries
  • Implementing fuzz tests for the ERC20 contract
  • Developing invariant tests for the ERC20 contract

By the end of this article, you’ll be equipped with the expertise to write fuzz tests and understand how you can secure your smart contracts.

Prerequisites

Before diving into the technicalities, ensure you have Foundry installed by following the official documentation. The code for this article is available in our GitHub repository — feel free to star it if you find it useful.

With Foundry ready, let’s begin writing our ERC20 smart contract.

Writing Our ERC20 Smart Contract

Initiate a Foundry Project

To kickstart your journey into smart contract development with Foundry, you first need to initialize a new project. Begin by executing the following command in your terminal:

forge init sphere-audits-foundry-guide

This command sets up a default Foundry project named sphere-audits-foundry-guide. Foundry's structure organizes your smart contracts, tests, and scripts efficiently, making development smoother.

Structuring Your ERC20 Smart Contract

Within the newly created Foundry project, your next step involves laying down the foundation for your ERC20 token. Start by organizing your workspace:

  1. Creating Contract Files: Navigate to the src directory, and create a new file ERC20.sol within src/contracts. This file will contain the logic of your ERC20 token.
  2. Defining Interfaces: Under the same directory, create a folder named interfaces and within it, a file named IERC20.sol. This interface file outlines the standard functions and events of an ERC20 token as defined by the Ethereum community.

IERC20.sol

The IERC20.sol interface establishes the blueprint for ERC20 tokens, specifying the necessary events, functions, and signatures. This includes token transfer functionalities, allowance mechanisms for secure token spending, and view functions to access token data.

Here’s a glimpse of what the IERC20.sol interface includes. You can copy and paste into your IERC20.sol file:

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.7;
/// @title Interface of the ERC20 standard as defined in the EIP, including EIP-2612 permit functionality.
interface IERC20 {
/**
* @dev Emitted when one account has set the allowance of another account over their tokens.
* @param owner_ Account that tokens are approved from.
* @param spender_ Account that tokens are approved for.
* @param amount_ Amount of tokens that have been approved.
*/
event Approval(
address indexed owner_,
address indexed spender_,
uint256 amount_
);
/**
* @dev Emitted when tokens have moved from one account to another.
* @param owner_ Account that tokens have moved from.
* @param recipient_ Account that tokens have moved to.
* @param amount_ Amount of tokens that have been transferred.
*/
event Transfer(
address indexed owner_,
address indexed recipient_,
uint256 amount_
);
/**
* @dev Function that allows one account to set the allowance of another account over their tokens.
* Emits an {Approval} event.
* @param spender_ Account that tokens are approved for.
* @param amount_ Amount of tokens that have been approved.
* @return success_ Boolean indicating whether the operation succeeded.
*/
function approve(
address spender_,
uint256 amount_
) external returns (bool success_);
/**
* @dev Function that allows one account to decrease the allowance of another account over their tokens.
* Emits an {Approval} event.
* @param spender_ Account that tokens are approved for.
* @param subtractedAmount_ Amount to decrease approval by.
* @return success_ Boolean indicating whether the operation succeeded.
*/
function decreaseAllowance(
address spender_,
uint256 subtractedAmount_
) external returns (bool success_);
/**
* @dev Function that allows one account to increase the allowance of another account over their tokens.
* Emits an {Approval} event.
* @param spender_ Account that tokens are approved for.
* @param addedAmount_ Amount to increase approval by.
* @return success_ Boolean indicating whether the operation succeeded.
*/
function increaseAllowance(
address spender_,
uint256 addedAmount_
) external returns (bool success_);
/**
* @dev Approve by signature.
* @param owner_ Owner address that signed the permit.
* @param spender_ Spender of the permit.
* @param amount_ Permit approval spend limit.
* @param deadline_ Deadline after which the permit is invalid.
* @param v_ ECDSA signature v component.
* @param r_ ECDSA signature r component.
* @param s_ ECDSA signature s component.
*/
function permit(
address owner_,
address spender_,
uint amount_,
uint deadline_,
uint8 v_,
bytes32 r_,
bytes32 s_
) external;
/**
* @dev Moves an amount of tokens from `msg.sender` to a specified account.
* Emits a {Transfer} event.
* @param recipient_ Account that receives tokens.
* @param amount_ Amount of tokens that are transferred.
* @return success_ Boolean indicating whether the operation succeeded.
*/
function transfer(
address recipient_,
uint256 amount_
) external returns (bool success_);
/**
* @dev Moves a pre-approved amount of tokens from a sender to a specified account.
* Emits a {Transfer} event.
* Emits an {Approval} event.
* @param owner_ Account that tokens are moving from.
* @param recipient_ Account that receives tokens.
* @param amount_ Amount of tokens that are transferred.
* @return success_ Boolean indicating whether the operation succeeded.
*/
function transferFrom(
address owner_,
address recipient_,
uint256 amount_
) external returns (bool success_);
/**
* @dev Returns the allowance that one account has given another over their tokens.
* @param owner_ Account that tokens are approved from.
* @param spender_ Account that tokens are approved for.
* @return allowance_ Allowance that one account has given another over their tokens.
*/
function allowance(
address owner_,
address spender_
) external view returns (uint256 allowance_);
/**
* @dev Returns the amount of tokens owned by a given account.
* @param account_ Account that owns the tokens.
* @return balance_ Amount of tokens owned by a given account.
*/
function balanceOf(
address account_
) external view returns (uint256 balance_);
/**
* @dev Returns the decimal precision used by the token.
* @return decimals_ The decimal precision used by the token.
*/
function decimals() external view returns (uint8 decimals_);
/**
* @dev Returns the signature domain separator.
* @return domainSeparator_ The signature domain separator.
*/
function DOMAIN_SEPARATOR()
external
view
returns (bytes32 domainSeparator_);
/**
* @dev Returns the name of the token.
* @return name_ The name of the token.
*/
function name() external view returns (string memory name_);
/**
* @dev Returns the nonce for the given owner.
* @param owner_ The address of the owner account.
* @return nonce_ The nonce for the given owner.
*/
function nonces(address owner_) external view returns (uint256 nonce_);
/**
* @dev Returns the permit type hash.
* @return permitTypehash_ The permit type hash.
*/
function PERMIT_TYPEHASH() external view returns (bytes32 permitTypehash_);
/**
* @dev Returns the symbol of the token.
* @return symbol_ The symbol of the token.
*/
function symbol() external view returns (string memory symbol_);
/**
* @dev Returns the total amount of tokens in existence.
* @return totalSupply_ The total amount of tokens in existence.
*/
function totalSupply() external view returns (uint256 totalSupply_);
}

Implementing the ERC20 Logic

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;
import {IERC20} from "./interfaces/IERC20.sol";
/**
* @title ERC-20 implementation.
*/
contract ERC20 is IERC20 {
////////////////////////////////////////////////////////////
// VARIABLES //
////////////////////////////////////////////////////////////
string public override name;
string public override symbol;
uint8 public immutable override decimals;
uint256 public override totalSupply;
mapping(address => uint256) public override balanceOf;
mapping(address => mapping(address => uint256)) public override allowance;
// PERMIT_TYPEHASH = keccak256("Permit(address owner, address spender, uint256 value, uint256 nonce, uint256 deadline)");
bytes32 public constant override PERMIT_TYPEHASH =
0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
mapping(address => uint256) public override nonces;
////////////////////////////////////////////////////////////
// CONSTRUCTOR //
////////////////////////////////////////////////////////////
/**
* @param name_ The name of the token.
* @param symbol_ The symbol of the token.
* @param decimals_ The decimal precision used by the token.
*/
constructor(string memory name_, string memory symbol_, uint8 decimals_) {
name = name_;
symbol = symbol_;
decimals = decimals_;
}
////////////////////////////////////////////////////////////
// EXTERNAL FUNCTION //
////////////////////////////////////////////////////////////
function approve(
address spender_,
uint256 amount_
) public virtual override returns (bool success_) {
_approve(msg.sender, spender_, amount_);
return true;
}
function decreaseAllowance(
address spender_,
uint256 subtractedAmount_
) public virtual override returns (bool success_) {
_decreaseAllowance(msg.sender, spender_, subtractedAmount_);
return true;
}
function increaseAllowance(
address spender_,
uint256 addedAmount_
) public virtual override returns (bool success_) {
_approve(
msg.sender,
spender_,
allowance[msg.sender][spender_] + addedAmount_
);
return true;
}
function permit(
address owner_,
address spender_,
uint256 amount_,
uint256 deadline_,
uint8 v_,
bytes32 r_,
bytes32 s_
) public virtual override {
require(deadline_ >= block.timestamp, "ERC20:P:EXPIRED");
require(
uint256(s_) <=
uint256(
0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0
) &&
(v_ == 27 || v_ == 28),
"ERC20:P:MALLEABLE"
);
unchecked {
bytes32 digest_ = keccak256(
abi.encodePacked(
"\\x19\\x01",
DOMAIN_SEPARATOR(),
keccak256(
abi.encode(
PERMIT_TYPEHASH,
owner_,
spender_,
amount_,
nonces[owner_]++,
deadline_
)
)
)
);
address recoveredAddress_ = ecrecover(digest_, v_, r_, s_);
require(
recoveredAddress_ == owner_ && owner_ != address(0),
"ERC20:P:INVALID_SIGNATURE"
);
}
_approve(owner_, spender_, amount_);
}
function transfer(
address recipient_,
uint256 amount_
) public virtual override returns (bool success_) {
_transfer(msg.sender, recipient_, amount_);
return true;
}
function transferFrom(
address owner_,
address recipient_,
uint256 amount_
) public virtual override returns (bool success_) {
_decreaseAllowance(owner_, msg.sender, amount_);
_transfer(owner_, recipient_, amount_);
return true;
}
function DOMAIN_SEPARATOR()
public
view
override
returns (bytes32 domainSeparator_)
{
return
keccak256(
abi.encode(
keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
),
keccak256(bytes(name)),
keccak256(bytes("1")),
block.chainid,
address(this)
)
);
}
////////////////////////////////////////////////////////////
// INTERNAL FUNCTIONS //
////////////////////////////////////////////////////////////
function _approve(
address owner_,
address spender_,
uint256 amount_
) internal {
emit Approval(owner_, spender_, allowance[owner_][spender_] = amount_);
}
function _burn(address owner_, uint256 amount_) internal {
balanceOf[owner_] -= amount_;
unchecked {
totalSupply -= amount_;
}
emit Transfer(owner_, address(0), amount_);
}
function _decreaseAllowance(
address owner_,
address spender_,
uint256 subtractedAmount_
) internal {
uint256 spenderAllowance = allowance[owner_][spender_];
if (spenderAllowance != type(uint256).max) {
_approve(owner_, spender_, spenderAllowance - subtractedAmount_);
}
}
function _mint(address recipient_, uint256 amount_) internal {
totalSupply += amount_;
unchecked {
balanceOf[recipient_] += amount_;
}
emit Transfer(address(0), recipient_, amount_);
}
function _transfer(
address owner_,
address recipient_,
uint256 amount_
) internal {
balanceOf[owner_] -= amount_;
unchecked {
balanceOf[recipient_] += amount_;
}
emit Transfer(owner_, recipient_, amount_);
}
}

This contract will inherit the IERC20 interface, ensuring compliance with the ERC20 standard. Here's an overview of the implementation strategy:

  1. State Variables: Define key properties of the token, such as name, symbol, and totalSupply.
  2. Mapping: Utilize Solidity’s mapping to track balances and allowances, providing a way to efficiently manage the state.
  3. Constructor: Initialize your token with a name, symbol, and initial supply.
  4. ERC20 Functions: Implement the functions declared in the IERC20 interface, including how tokens can be transferred, how allowances are managed, and how users can query token data.

Critical sections of the contract cover:

  • Transfers: Allow token holders to transfer tokens securely.
  • Allowances: Enable token holders to grant permission to others to spend a specific amount of tokens on their behalf.
  • Minting and Burning: Provide functions to increase or decrease the token supply, adhering to the predefined rules of the token.

Fuzz Testing With Foundry

Now, let’s dive into setting up our tests in the tests/ERC20.t.sol file, which we prepared earlier. This file is pivotal for unit testing our smart contract using the Foundry framework.

The initial setup is structured as follows:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.7;
import "../src/ERC20.sol";
import "forge-std/Test.sol";
contract ERC20Test is Test {

}

In this setup, we first specify the Solidity version (0.8.7) to ensure compatibility with our ERC20 contract. The importance of version alignment cannot be overstated, as it prevents potential inconsistencies or compilation errors.

We proceed by importing two crucial components:

  1. The ERC20 contract from our project, enabling its functionalities to be tested.
  2. The Test contract from Foundry's forge-std library, which is a cornerstone for writing comprehensive tests. It provides an array of utilities and functions such as assert for various checks (e.g., equality, inequality) and preparatory functions setUp for initializing test conditions.

The declaration contract ERC20Test is Test { } introduces a new contract, ERC20Testwhich inherits from Test. This inheritance pattern grants ERC20Test access to all functionalities of Test, streamlining the test-writing process. Currently, the contract's body is empty, indicating we've not yet implemented any tests.

The setUp() Function

In smart contract testing, it’s imperative to start each test with a clean slate. This is the role of the setUp() function in the Foundry framework.

Think of setUp() as the board game's setup phase, where you arrange all the pieces before the game begins. For each test, setUp() ensure a clean starting point by initializing the necessary elements for the test execution.

A key application of this function is the deployment of “mock” contracts. These mocks serve as stand-ins for actual contracts, allowing for scenario testing without impacting real tokens or contracts. They are, essentially, sandbox versions of the contracts under test.

Creating a MockERC20 Contract for Testing

To facilitate our testing, we’ll create a mock version of the ERC20 contract, named MockERC20. This mock contract will emulate a real token contract but is intended solely for testing purposes.

Create the mock contract in the following file: /tests/Mocks/MockERC20.sol, and populate it with this code:

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity 0.8.7;
import { ERC20 } from "../../src/ERC20.sol";
contract MockERC20 is ERC20 {
constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_, decimals_) {}
function mint(address recipient_, uint256 amount_) external {
_mint(recipient_, amount_);
}
function burn(address owner_, uint256 amount_) external {
_burn(owner_, amount_);
}
}

This mock contract includes functionalities to mint and burn tokens, providing a flexible testing environment for various scenarios.

Integrating the MockERC20 Contract in Our Tests

Back in our ERC20.t.sol file, we'll instantiate the MockERC20 token within our setUp function as follows:

contract ERC20Test is Test {
MockERC20 internal _token;
function setUp() public virtual {
_token = new MockERC20("SPHERE", "SPH", 18);
}
}

Here, we declare a variable _token to hold our mock token and initialize it with specific parameters ("SPHERE", "SPH", 18) to simulate a real token scenario. This setup paves the way for thorough and effective testing of our ERC20 contract functionalities.

Fuzz Testing Overview

Fuzz testing is a powerful technique to enhance the robustness of your smart contracts by simulating various inputs. It’s particularly effective for ensuring that your ERC20 token contract can gracefully handle a wide range of unexpected or edge-case inputs. Let’s dive into specific fuzz tests for our ERC20 token.

Fuzz Test 1: Testing Metadata

This first fuzz test aims to verify the ERC20 token’s ability to handle diverse inputs for its metadata attributes: name, symbol, and decimal precision.

By dynamically generating strings for the name and symbol, along with various byte sizes for decimals, we simulate numerous scenarios, ensuring the contract's metadata handling is both flexible and robust.

  function testFuzz_metadata(
string memory name_,
string memory symbol_,
uint8 decimals_
) public {
MockERC20 mockToken = new MockERC20(name_, symbol_, decimals_);

assertEq(mockToken.name(), name_);
assertEq(mockToken.symbol(), symbol_);
assertEq(mockToken.decimals(), decimals_);
}

Execute this test using the forge test command in your terminal. A successful test run will confirm that your contract's metadata handling is up to par.

Fuzz Test 2: Ensuring Mint Functionality

The minting function’s reliability is crucial for an ERC20 token. This test validates that tokens are correctly minted to an account, with both the total supply and the account’s balance reflecting the minted amount accurately.

  function testFuzz_mint(address account_, uint256 amount_) public {
_token.mint(account_, amount_);

assertEq(_token.totalSupply(), amount_);
assertEq(_token.balanceOf(account_), amount_);
}

Fuzz Test 3: Verifying Burn Functionality

Testing the burn function is essential to ensure that tokens can be accurately removed from circulation. This fuzz test checks that after minting tokens to an account and then burning a portion, the total supply and account balance decrease correctly.

 function testFuzz_burn(
address account_,
uint256 amount0_,
uint256 amount1_
) public {
if (amount1_ > amount0_) return;

_token.mint(account_, amount0_);
_token.burn(account_, amount1_);

assertEq(_token.totalSupply(), amount0_ - amount1_);
assertEq(_token.balanceOf(account_), amount0_ - amount1_);
}

Fuzz Test 4: Testing Approve Functionality

The ability to approve token allowances is fundamental to ERC20 tokens. This fuzz test ensures that an account can successfully approve a specified amount and that the allowance matches the approved amount precisely.

function testFuzz_approve(address account_, uint256 amount_) public {
assertTrue(_token.approve(account_, amount_));

assertEq(_token.allowance(address(this), account_), amount_);
}

Fuzz Test 5: Increasing Allowance

This test evaluates the increaseAllowance functionality, ensuring that it accurately updates the allowance for an account when increased by a specific amount.

function testFuzz_increaseAllowance(
address account_,
uint256 initialAmount_,
uint256 addedAmount_
) public {
initialAmount_ = constrictToRange(
initialAmount_,
0,
type(uint256).max / 2
);
addedAmount_ = constrictToRange(addedAmount_, 0, type(uint256).max / 2);

_token.approve(account_, initialAmount_);

assertEq(_token.allowance(address(this), account_), initialAmount_);

assertTrue(_token.increaseAllowance(account_, addedAmount_));

assertEq(
_token.allowance(address(this), account_),
initialAmount_ + addedAmount_
);
}

Each of these fuzz tests plays a crucial role in safeguarding the integrity and reliability of your ERC20 token contract. To checkout all the test cases, make sure to check the repository.

Invariant Testing for ERC20 Contract

Invariant testing improves our understanding of smart contract resilience by examining the contract or system as a whole, beyond individual function checks. It’s a sophisticated testing technique that ensures the enduring stability and integrity of the smart contract under varied conditions.

Here’s how it fundamentally differs and adds value compared to conventional fuzz tests.

What is Invariant Testing?

Imagine running a fuzz test that bombards a function with random inputs to verify its robustness. Invariant testing scales this concept to the entire system.

Instead of testing a single function, it assesses “invariant properties” of a contract or interconnected system contracts.

These are the golden rules that should always hold true — like a vault always having enough to cover withdrawals or an ERC20 token’s total supply equating to the sum of all balances.

To begin with invariant testing, we will initiate a test contract with the setup function.

contract Invariant_ERC20Testing is Test {
MockERC20 internal _token;

function setUp() public {
_token = new MockERC20("SPHERE", "SPH", 18);
}
}

Invariant Test 1: Metadata Integrity

This test checks the ERC-20 token’s metadata (name, symbol, decimals) remains immutable post-deployment, safeguarding the token’s identity and specifications.

function invariant_metadataIsConstant() public {
assertEq(_token.name(), "SPHERE");
assertEq(_token.symbol(), "SPH");
assertEq(_token.decimals(), 18);
}

Invariant Test 2: Minting Mechanism

This test ensures the total supply accurately grows by the minted amount and the recipient’s balance correctly mirrors this addition.


function testInvariant_mintingAffectsTotalSupplyAndBalance(address to, uint256 amount) public {
vm.assume(to != address(0));

uint256 preSupply = _token.totalSupply();

_token.mint(to, amount);

uint256 postSupply = _token.totalSupply();
uint256 toBalance = _token.balanceOf(to);

assertEq(
postSupply,
preSupply + amount,
"Total supply did not increase correctly after minting"
);

assertEq(
toBalance,
amount,
"Recipient balance incorrect after minting"
);
}

Invariant Test 3: Transfer Integrity

This test ensures the transfer process works fine, with the sender’s balance decreasing, the receiver’s increasing, and the total supply remaining the same.

function testInvariant_transferCorrectlyUpdatesBalances(address sender, address receiver, uint256 mintAmount, uint256 transferAmount) public {
vm.assume(sender != address(0) && receiver != address(0) && sender != receiver);
vm.assume(mintAmount > 0 && transferAmount > 0 && mintAmount >= transferAmount);

vm.prank(sender);
_token.mint(sender, mintAmount);

uint256 initialSenderBalance = _token.balanceOf(sender);
uint256 initialReceiverBalance = _token.balanceOf(receiver);
uint256 initialTotalSupply = _token.totalSupply();

vm.prank(sender);
_token.transfer(receiver, transferAmount);

uint256 expectedSenderBalance = initialSenderBalance - transferAmount;
uint256 expectedReceiverBalance = initialReceiverBalance + transferAmount;

assertEq(_token.balanceOf(sender), expectedSenderBalance, "Sender balance incorrect after transfer");

assertEq(_token.balanceOf(receiver), expectedReceiverBalance, "Receiver balance incorrect after transfer");

assertEq(_token.totalSupply(), initialTotalSupply, "Total supply should remain constant after transfers");
}

We can write as many invariant tests as we want. Make sure to try writing more of them on your own.

Feel free to explore the complete codebase. And if this guide of testing has helped you in any way, consider sharing your support with a clap or a star on GitHub. :)

For any feedback or inquiries, feel free to reach out through Twitter or Linkedin.

--

--

Pari Tomar
Sphere Audits

Researches, talks, shares know-how on building a career in blockchain space