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
- Introduction
- Prerequisites
- Benefits of fork testing?
- What are Foundry Cheatcodes?
- Project Setup and Testing
- 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 {
}
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;
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);
}
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];
}
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;
}
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];
}
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;
}
}
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");
}
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");
}
}
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";
Add a test contract to the Savings.t.sol file. Let the test contract inherit from the Test.sol
.
contract TestSavings is Test {
}
Add the variables listed below to the contract.
uint256 mainnetFork;
string MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL");
Savings public savings;
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 of
forge 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();
}
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 currentblock.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);
}
}
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)