TESTING ERC-20 TOKENS — Foundry

The Foundry project is a new kid on the block, but it has quickly gained popularity thanks to its Rust implementation of dapp tools

Harbor
5 min readNov 16, 2022

Maybe you are new to programming and are just starting to learn Solidity. One annoyance for you might have been that you were basically required to learn a second language (JavaScript/TypeScript) to write tests. The Foundry framework makes writing tests easier, allowing you to use fewer lines of code and never be bothered by BigNumber.js / bn.js. Aside from being very fast, it’s also written in Rust and very usable, even though it’s quite new.

Install Foundry

The commands required to install Foundry vary depending on your system. Click here to see the details.

Create a new Project

Using forge init, we can create new projects. You can choose between bare-bones projects and templates.

$ forge init --template https://github.com/FrankieIsLost/forge-template

Implementing an ERC-20

The next step is to create an ERC-20 contract and some tests for it. To begin, let’s install the Openzeppelin contracts and update the standard library with Forge:

$ forge install OpenZeppelin/openzeppelin-contracts@v4.5.0
$ forge update foundry-rs/forge-std

You now need to add the library to the existing remappings file:

forge-std/=lib/forge-std/src/
openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/

Create a new contract using Openzeppelin contracts. Rename the existing file to MyERC20.sol and the testing file to MyErc20.t.sol.

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;

import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol";

contract MyERC20 is ERC20 {
constructor() ERC20("Name", "SYM") {
this;
}
}

Establish a testing base setup

To set up the contract, we need to define the setUp function in the file MyErc20.t.sol. We have a setUp function in the foundry. As we define the setUp function, the contract enters another state, and some addresses are created. Aside from the ERC20 contract itself, we will import things from the forge-std, ds-test, and utils.

Our base setUp function uses the util functions that came with the template, which let us create a few user addresses that hold Ether. Let’s call the first one Alice and the second one Bob.

Using the Vm contract, we can modify low-level EVM stuff, such as labeling an address, so that it can be identified easily with the label in the stack trace.

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;

import {console} from "forge-std/console.sol";
import {stdStorage, StdStorage, Test} from "forge-std/Test.sol";

import {Utils} from "./utils/Utils.sol";
import {MyERC20} from "../MyERC20.sol";

contract BaseSetup is MyERC20, DSTest {
Utils internal utils;
address payable[] internal users;

address internal alice;
address internal bob;

function setUp() public virtual {
utils = new Utils();
users = utils.createUsers(5);

alice = users[0];
vm.label(alice, "Alice");
bob = users[1];
vm.label(bob, "Bob");
}
}

It’s now time to set things up so tokens can be transferred…

contract WhenTransferringTokens is BaseSetup {
uint256 internal maxTransferAmount = 12e18;

function setUp() public virtual override {
BaseSetup.setUp();
console.log("When transferring tokens");
}

function transferToken(
address from,
address to,
uint256 transferAmount
) public returns (bool) {
vm.prank(from);
return this.transfer(to, transferAmount);
}
}

Token transfers can now be set up. As you can see, this setup can be similar to JavaScript’s mocha testing with beforeEach and describe. However, it’s all Solidity with a public setUp function and contracts. Do not forget to call the base setup function when performing the setup.

Stack traces will include console.log as well, so you can console.log what scenario you are currently in.

In our tests, we now also have a simple transfer function we can use. Please note that in order for vm.prank to work, you must make an actual call, so use this.transfer instead of just transfer.

Token Transfer Tests

We create two scenarios: the first with adequate funds, the second with insufficient funds

Be sure to call the previous setup during the setup. Alternatively, use super(), but I like being explicit.

Our next move will be to use assertion helpers from the ds-test library. With the ds-test library, we can use assertion helpers to assert equality (assertEq), less than (assertLe) and greater than (assertGe) as well as options with decimals for tokens.

contract WhenAliceHasSufficientFunds is WhenTransferringTokens {
uint256 internal mintAmount = maxTransferAmount;

function setUp() public override {
WhenTransferringTokens.setUp();
console.log("When Alice has sufficient funds");
_mint(alice, mintAmount);
}

function itTransfersAmountCorrectly(
address from,
address to,
uint256 amount
) public {
uint256 fromBalance = balanceOf(from);
bool success = transferToken(from, to, amount);

assertTrue(success);
assertEqDecimal(
balanceOf(from),
fromBalance - amount, decimals()
);
assertEqDecimal(
balanceOf(to),
transferAmount, decimals()
);
}

function testTransferAllTokens() public {
uint256 t = maxTransferAmount;
itTransfersAmountCorrectly(alice, bob, t);
}

function testTransferHalfTokens() public {
uint256 t = maxTransferAmount / 2;
itTransfersAmountCorrectly(alice, bob, amount);
}

function testTransferOneToken() public {
itTransfersAmountCorrectly(alice, bob, 1);
}
}
contract WhenAliceHasInsufficientFunds is WhenTransferringTokens {
uint256 internal mintAmount = maxTransferAmount - 1e18;

function setUp() public override {
WhenTransferringTokens.setUp();
console.log("When Alice has insufficient funds");
_mint(alice, mintAmount);
}

function itRevertsTransfer(
address from,
address to,
uint256 amount,
string memory expRevertMessage
) public {
vm.expectRevert(abi.encodePacked(expRevertMessage));
transferToken(from, to, amount);
}






function testCannotTransferMoreThanAvailable() public {
itRevertsTransfer({
from: alice,
to: bob,
amount: maxTransferAmount,
expRevertMessage: "[...] exceeds balance"
});
}

function testCannotTransferToZero() public {
itRevertsTransfer({
from: alice,
to: address(0),
amount: mintAmount,
expRevertMessage: "[...] zero address"
});
}
}

Mocking a Call

With vm, you can mock a call, for instance, if this token transfer receives a call with a transfer to bob and amount, then just return false. Additionally, using clearMockedCalls (), you can clear mocks.

function testTransferWithMockedCall() public {
vm.prank(alice);
vm.mockCall(
address(this),
abi.encodeWithSelector(
this.transfer.selector,
bob,
maxTransferAmount
),
abi.encode(false)
);
bool success = this.transfer(bob, maxTransferAmount);
assertTrue(!success);
vm.clearMockedCalls();
}

The Direct Retrieval of Data

Use the stdStorage feature if you wish to get data directly from the state. For instance, if you wish to read the balance directly from state, you will determine the storage slot and load it using vm.load.

using stdStorage for StdStorage;

function testFindMapping() public {
uint256 slot = stdstore
.target(address(this))
.sig(this.balanceOf.selector)
.with_key(alice)
.find();
bytes32 data = vm.load(address(this), bytes32(slot));
assertEqDecimal(uint256(data), mintAmount, decimals());
}

Fuzz Testing

Forge allows you to use fuzzing. All you need to do is create a test function with input variables, and Forge will do the fuzz testing for you. You can restrict the range to specific input types if you need to have specific constraints; alternatively, you can use vm.assume to eliminate single values and/or modulo to restrict the input to a precise range.

function testTransferFuzzing(uint64 amount) public {
vm.assume(amount != 0);
itTransfersAmountCorrectly(
alice,
bob,
amount % maxTransferAmount
);
}

Running Tests

$ forge test -vvvvv

Forge tests can be executed at different verbosity settings. The number of vs should be increased to 5:

2: Print all test logs
3: Print execution traces for tests that failed.
4: Print setup traces for failed tests as well as execution traces for all tests.
5: Print the setup and execution traces for each test.

Ref: https://medium.com/buildbear/web3-beginner-how-to-use-foundry-to-test-an-erc20-contract-with-fuzzing-3f456e8a10f5

--

--

Harbor

A testing infrastructure company that provides production ready staging environments for web3.0 companies.