DEV Community

Cover image for Smart Contract Fork Testing Using Foundry Cheatcodes
Eyitayo Itunu Babatope
Eyitayo Itunu Babatope

Posted on • Updated on

Smart Contract Fork Testing Using Foundry Cheatcodes

Introduction

Testing is important in smart contract development due to the immutable nature of smart contracts. Testing helps identify and resolve potential security vulnerabilities in smart contracts. Safeguard against unauthorized access.

Sometimes smart contract developers must interact with real-world data that testnet cannot provide. Hence, there is a need for fork testing. In this article, readers will learn how to conduct fork-testing in a foundry development environment.
 

Content

  1. Introduction
  2. Prerequisites
  3. Benefits of fork testing?
  4. What are Foundry Cheatcodes?
  5. Project Setup and Testing
  6. Conclusion.

Prerequisites

This tutorial requires foundry installation. 
Knowledge of Solidity programming and smart contract development.

Benefits of fork-testing

Fork testing mimics the production environment as much as possible. There is no need to use a testnet public faucet to get test coins for testing. 

  • It allows developers to debug in an environment that is as close to the production as possible.
  • It gives developers access to real-time data such as the current state of the blockchain which testnet cannot provide since testnet operates in an isolated environment.
  • It gives developers unprecedented control over smart contracts. Developers can mint or transfer tokens like they own the token creation smart contract.
  • Developers can create blockchain addresses easily.

What are Foundry Cheatcodes?

According to Foundry Documentation, Forking cheatcodes allow developers to fork blockchain programmatically using solidity instead of CLI arguments. Forking cheat codes support multiple forks and each fork has a unique uint256 identifier. The developer must assign a unique identifier when the developer creates the fork. Forking cheatcodes execute each test in its own standalone EVM. Forking cheatcodes isolate tests from one another, and execute tests after the setUp function. It implies that a test in forking cheatcodes mode must have a setup function.

Project setup and testing

To demonstrate fork testing, we will create a savings smart contract. The contract will allow users to save, set a deadline for withdrawal, and allows users to withdraw when the deadline has elapsed.
Open a CLI terminal and run the command below to scaffold a foundry project. Name the project fork_testing.
forge init fork_testing.
Navigate to the src folder create a file and name it Savings.sol.
Open the Savings.sol file and create a Savings contract as shown below.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13; 

contract Savings {

 }
Enter fullscreen mode Exit fullscreen mode

Create the under-listed variables and an event in the contract as shown below.

event Saver(address payer, uint256 amount);
mapping(address => uint256) public balances;
mapping(address => uint256) public tenor;
bool public openForWithdraw = false;
uint256 public contractBalance = address(this).balance;
Enter fullscreen mode Exit fullscreen mode

The variable  tenor maps the address of the saver to the duration in seconds the user wants to keep his token. The variable balance maps the address of a saver to the amount saved in the contract. The boolean variable openForWithdraw is set to false and does not allow the saver to withdraw before the tenor lapses. The contract will emit the Saver event when a user transfers or sends funds to the contract.

Next, add a receive () payable function that allows the contract to receive funds. The function will require the user to set a tenor before the user can send funds to the contract. The function will use the map balances to keep track of the funds sent by an address. On successful savings, the function emits the address and amount sent by a user. The receive() is as shown in the code below.

receive() external payable {
        require(tenor[msg.sender] > 0, "You must set a tenor before saving");
        balances[msg.sender] += msg.value;
        contractBalance = address(this).balance;
        emit Saver(msg.sender, msg.value);
    }

Enter fullscreen mode Exit fullscreen mode

Next add two functions to the contract, a setTenor() and a getTenor() view function. The setTenor() allows the user to set the duration the user wants to keep the funds and getTenor() retrieves the duration the user wants to keep the funds in the contract. The implementation of the functions is shown below.

function setTenor(address saver, uint256 _tenor) public {
        tenor[saver] = block.timestamp + _tenor;
    }

function getTenor(address saver) public view returns (uint256) {
        return tenor[saver];
    }
Enter fullscreen mode Exit fullscreen mode

Add a get balance() as shown below. The function returns the total funds received by the contract.

function getBalance() public view returns (uint256) {
        return address(this).balance;
    }
Enter fullscreen mode Exit fullscreen mode

Next, add a view function getIndividualBalance()  as shown below. The getIndividualBalance() returns the balance of the individual address.

function getIndividualBalances(
        address saver
    ) public view returns (uint256) {
        return balances[saver];
    }
Enter fullscreen mode Exit fullscreen mode

Add a timeLeft() view function that returns the time left before the tenor lapses. The implementation is shown below.

function timeLeft(address saver) public view returns (uint256) {
        if (block.timestamp >= tenor[saver]) {
            return 0;
        } else {
            return tenor[saver] - block.timestamp;
        }
    }
Enter fullscreen mode Exit fullscreen mode

Lastly add a withdraw function, that allows the user to withdraw their funds if the tenor has elapsed. The implementation is shown below.

 function withdraw(uint amount, address withdrawer) public {
        if (timeLeft(withdrawer) <= 0) {
            openForWithdraw = true;
        }
        require(openForWithdraw, "It is not yet time to withdraw");
        require(
            balances[withdrawer] >= amount,
            "Balance less than amount to withdraw"
        );
        balances[withdrawer] -= amount;
        (bool success, ) = withdrawer.call{value: amount}("");
        require(success, "Unable to withdraw fund");
    }
Enter fullscreen mode Exit fullscreen mode

Below is the full code implementation of the Savings contract.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract Savings {
    event Saver(address payer, uint256 amount);
    mapping(address => uint256) public balances;
    mapping(address => uint256) public tenor;
    bool public openForWithdraw = false;
    uint256 public contractBalance = address(this).balance;

    // Collect funds in a payable `receive()` function and track individual `balances` with a mapping:
    // Add a `Saver(address,uint256, uint256, uint256)`
    receive() external payable {
        require(tenor[msg.sender] > 0, "You must set a tenor before saving");
        balances[msg.sender] += msg.value;
        contractBalance = address(this).balance;
        emit Saver(msg.sender, msg.value);
    }

    // Set the duration of time a user will save token in the contract.
    function setTenor(address saver, uint256 _tenor) public {
        tenor[saver] = block.timestamp + _tenor;
    }

    // Returns the duration of time a user is willing save funds in the contract.
    function getTenor(address saver) public view returns (uint256) {
        return tenor[saver];
    }

    // Returns the contract balance.
    function getBalance() public view returns (uint256) {
        return address(this).balance;
    }

    // Returns the balance saved in the contact by an address.
    function getIndividualBalances(
        address saver
    ) public view returns (uint256) {
        return balances[saver];
    }

    // Returns the time left before the tenor elapsed.
    function timeLeft(address saver) public view returns (uint256) {
        if (block.timestamp >= tenor[saver]) {
            return 0;
        } else {
            return tenor[saver] - block.timestamp;
        }
    }
    // Allows a user to withraw funds once the tenor has elapsed.
    function withdraw(uint amount, address withdrawer) public {
        if (timeLeft(withdrawer) <= 0) {
            openForWithdraw = true;
        }
        require(openForWithdraw, "It is not yet time to withdraw");
        require(
            balances[withdrawer] >= amount,
            "Balance less than amount to withdraw"
        );
        balances[withdrawer] -= amount;
        (bool success, ) = withdrawer.call{value: amount}("");
        require(success, "Unable to withdraw fund");
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing the Savings contract.

Open the test folder, create a .env file, and add a variable MAINET_RPC_URL. Copy and paste your mainnet RPC_URL as value as shown below.
MAINET_RPC_URL = ‘https://your_rpc_url’
Next, create a Savings.t.sol file. Open the file and specify the license and solidity compiler version. In the file, import the foundry Forge Standard Library, the Savings contract and forge standard output as shown below.  Forge Standard Library is a collection of contracts that makes Test contracts easy to write and test. The forge-std/Test.sol contains the forge standard experience.

 

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

//import dependencies
import "forge-std/Test.sol";
import "../src/Savings.sol";
import "forge-std/console.sol";
Enter fullscreen mode Exit fullscreen mode

Add a test contract to the Savings.t.sol file. Let the test contract inherit from the Test.sol.

contract TestSavings is Test {

}
Enter fullscreen mode Exit fullscreen mode

Add the variables listed below to the contract.

uint256 mainnetFork;
string MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL");
Savings public savings;
Enter fullscreen mode Exit fullscreen mode

The variable mainnetFork will hold the fork's unique identifier, and the string MAINNET_RPC_URL holds the RPC_URL loaded from the .env file using cheatcodevm.envString(). The vm is an instance offorge cheatcodes.

Next,  add receive() external payable{} to allow the test contract to receive funds. Add a setUp function to the contract, to create, select the mainnet fork and create an instance of the Savings contract as shown below.

 function setUp() public {
        mainnetFork = vm.createFork(MAINNET_RPC_URL);
        vm.selectFork(mainnetFork);
        savings = new Savings();
    }
Enter fullscreen mode Exit fullscreen mode

In the code above, we used the cheatcodes vm.createFork(MAINNET_RPC_URL) to fork the Ethereum mainnet blockchain and create a unique uint256 identifier which we assigned to the variable mainnetFork. Next, we select the fork with vm.selectFork(mainnetFork).

Since forge cheatcodes isolate tests from one another, we will create six test functions listed below.

  • function testInitialBalance() public view {}: To test if the Savings contract initial balance is zero.
  • function testSavingsWithoutTenor() public{}: To test if a user can send funds to the Savings contract without a tenor. The test should revert if a user did not set a tenor before sending funds to the Savings contract.
  • function testSetTenor() public {}: The test expects the tenor to be greater than the current block.timestamp.
  • function testSavings() public{}: Once, the user sets a tenor, the test expects the user to be able to save.
  • function testWithdrawBeforeTime() public {}: The user should not be able to withdraw funds before the tenor elapses.
  • function testWithdrawAfterTime() public {}: The user should be able to withdraw funds after the tenor elapses. The full code implementation of the test contract is shown below.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

//import dependencies
import "forge-std/Test.sol";
import "../src/Savings.sol";
import "forge-std/console.sol";

//Test Contract
contract TestSavings is Test {
    uint256 mainnetFork;
    string MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL");
    Savings public savings;

    // Allow the test contract to receive funds.
    receive() external payable {}

    // Initial configuration.
    function setUp() public {
        mainnetFork = vm.createFork(MAINNET_RPC_URL);
        vm.selectFork(mainnetFork);
        savings = new Savings();
    }

    // The initial balance should be 0.
    function testInitialBalance() public view {
        assertLt(savings.getBalance(), 1);
    }

    // User should not be able to save without setting a tenor.
    function testSavingsWithoutTenor() public {
        vm.deal(address(this), 2 ether);
        vm.expectRevert();
        (bool sent, ) = address(savings).call{value: 1 ether}("");
        require(sent, "Failed to send Ether");
    }

    // User should be able to set tenor.
    function testSetTenor() public {
        savings.setTenor(address(this), 30);
        uint tenor = savings.getTenor(address(this));
        assertGt(tenor, block.timestamp);
    }

    // User should be able to save, if the user has set a tenor.
    function testSavings() public {
        savings.setTenor(address(this), 30);
        vm.deal(address(this), 2 ether);
        (bool sent, ) = address(savings).call{value: 1 ether}("");
        require(sent, "Failed to send Ether");
        assertGt(savings.getIndividualBalances(address(this)), 0);
    }

    // User should not be able to with the tenor elapses.
    function testWithdrawBeforeTime() public {
        savings.setTenor(address(this), 30);
        vm.deal(address(this), 2 ether);
        (bool sent, ) = address(savings).call{value: 1 ether}("");
        console.log(sent);
        vm.expectRevert("It is not yet time to withdraw");
        savings.withdraw(0.5 ether, address(this));
    }

    // User should be able to withdraw after the tenor elapses.
    function testWithdrawAfterTime() public {
        savings.setTenor(address(this), 0);
        vm.deal(address(this), 1 ether);
        (bool sent, ) = address(savings).call{value: 1 ether}("");
        console.log(sent);
        uint256 oldBalance = address(this).balance;
        savings.withdraw(0.5 ether, address(this));
        uint256 newBalance = address(this).balance;
        assertGt(newBalance, oldBalance);
    }
}
Enter fullscreen mode Exit fullscreen mode

In the  testBalance() , we use the assertLt() to check if the contract balance is less than 1. The test should pass. 

The testSavingsWithoutTenor() test if a user can save without setting a tenor. In the function, we retrieve the balance of the Savings contract with savings.getBalance() and assign the return value to the variable initial balance. Then we set the balance of the test contract to 2 ether using vm.deal(). We expect that when a user tries to send funds to the Savings contract without a tenor, the transaction should revert. We use vm.expectRevert() to handle the revert. Then, we send 1 ether to the Savings contract from the test contract using (bool sent, ) = address(savings).call{value: 1 ether}(""). Next, we retrieve the balance of the Savings contract and assign the return value to the variable newBalance. Then, we test if the new balance of the savings contract is equal to the initial balance using assertEq(). We expect the test to pass.

The function testSetTenor tests if a user can set the tenor for funds to be stored in the savings contract. We set the tenor using setTenor() of the Savings contract with the address of the test contract and the duration of 30 seconds. The test expects the tenor should be greater than the current block.timestamp.

The  testSavingsWithTenor() tests if a user can save after the user sets a tenor. We set the tenor with the savings.setTenor to 30 seconds, set the balance of the test contract to 2 ether, then transfer 1 ether to the  Savings contract. We retrieve the balance mapped to the test contract address in the Savings contract with the getIndividualBalances function. We expect the retrieved balance to be greater than 0.

The testWithdrawBeforeTime() tests if a user can withdraw funds before the tenor elapses. We send 1 ether to the savings contract, then try to withdraw 0.5 ether from the Savings contract to the test contract with savings.withdraw(0.5 ether, address(this)). We expect the transaction to revert.  

The testWithdrawAfterTime() tests if a user can withdraw after the tenor has elapsed. We set the tenor to 0 seconds, retrieve the balance of the test contract and assign it to the variable oldBalance, then transfer 1 ether to the Savings contract,  and withdraw 0.5 ether from the Savings contract.  Thereafter, retrieve the balance of the test contract and assign the value to the variable newBalance. We expect that the newBalance should be greater than the oldBalance.

Run the tests with the command:
forge test
For trace result, run the command:
forge test -vvv
Or
forge test -vvvv

Conclusion

Testing is essential in smart contract development, it enables developers to discover errors, and security vulnerabilities in smart contracts before deployment. Once we deploy a smart contract, it becomes immutable. Fork testing is a way to test smart contracts in an environment that is as close to the production environment as possible. In this article, we have demonstrated how to carry out fork testing using Foundry cheatcodes. We have demonstrated how it is easy to fork an Ethereum mainnet and transfer ether from one contract to another without the need to use testnet faucet or tokens.

Like, Share, or Comment if you find this article interesting.

Top comments (0)