In this article, I will share my experience conducting a unit test on one of ModeSpray's smart contracts. This is the first test I have carried out, marking the beginning of my journey in web3 security, a path I started about a month ago. Documenting this process not only helps me consolidate what I have learned but can also serve as a guide for others just starting in this field.
As a Security Researcher, I have been analyzing various dApps in the Mode Network ecosystem, available in their registered applications here. During this analysis, I found ModeSpray and decided to focus on assessing the security of its smart contract. Below, I detail my approach and the results obtained during this test.
ModeSpray
Before diving into the test I performed, I want to explain what ModeSpray is.
Mode Spray is a dApp that simplifies the process of sending tokens on Mode. The user-friendly interface allows users to send tokens to multiple wallets in a single transaction. It uses an optimized process to cut down on the transaction fees incurred in transferring funds. You can use Mode Spray to pay contributors, send out grants or just transfer tokens to friends in one go super fast.
ModeSpray is open-source, and you can view the code here, in addition to being open to contributions.
Unit Test
A unit test is a test that verifies the correct functioning of an individual unit of code, such as a function. It evaluates the behavior of a single part of the code in isolation, ensuring that each component fulfills its intended purpose and allows for easy identification and resolution of errors.
I conducted the test with Foundry, following the article by RareSkills which helped reinforce my knowledge. For this article, I recommend having basic knowledge of Solidity.
Environment Setup
I will walk you through the process I followed to configure everything and start the test (this part is more for documenting the process, it can be skipped).
I had already installed Foundry. If you do not have it installed and want to install it, you can follow the instructions in the Foundry Documentation.
The setup process was as follows:
- Clone the repository
git clone “REPOSITORY_URL”
- Access the repository
cd modespray
- Open the repository in VSCode
code .
- Initialize a Foundry project
Open the terminal in VSCode and initialize a Foundry project.
forge init
- Install OpenZeppelin
forge install OpenZeppelin/openzeppelin-contracts
Smart Contract Description
The BaseSpray
contract, which inherits from OpenZeppelin's Ownable
, allows the distribution of ether among multiple recipients. Below, I provide a brief description of its functionality:
-
Constructor: Initializes the
Ownable
contract with aninitialOwner
address as the owner. -
disperseEther function:
- Parameters: Accepts two arrays in memory: _recipients (recipient addresses) and _amounts (amounts to distribute).
- Require:
- The _recipients and _amounts arrays must have the same length. If not, the function reverts.
- The _recipients array cannot be empty. If it is empty, the function reverts.
- Calculation of totalValue: Accumulates the sum of all _amounts values to determine the total amount to be distributed.
- Balance verification: Ensures that the contract's balance is sufficient to cover totalValue. If not, the function reverts with the message “Insufficient balance”.
- Ether distribution: Iterates over the recipients and makes the corresponding transfers.
- Event: Emits the TokenDispersed event with the transaction details.
// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.20;
import '@openzeppelin/contracts/access/Ownable.sol';
import '@openzeppelin/contracts/utils/Address.sol';
import '@openzeppelin/contracts/token/ERC20/ERC20.sol';
/// @custom:security-contact wolfcito.eth+security@gmail.com
contract BaseSpray is Ownable {
event TokenDispersed(
address indexed sender,
address[] recipients,
uint256[] values,
address token
);
constructor(address initialOwner) Ownable(initialOwner) {}
function disperseEther(
address[] memory _recipients,
uint256[] memory _amounts
) external payable {
require(
_recipients.length == _amounts.length,
'Number of recipients must be equal to the number of corresponding values'
);
require(_recipients.length > 0, 'Recipients array cannot be empty');
uint256 totalValue = 0;
for (uint256 i = 0; i < _recipients.length; i++) {
totalValue += _amounts[i];
}
require(address(this).balance >= totalValue, 'Insufficient balance');
for (uint256 i = 0; i < _recipients.length; i++) {
Address.sendValue(payable(_recipients[i]), _amounts[i]);
}
emit TokenDispersed(msg.sender, _recipients, _amounts, address(0));
}
}
Code Analysis and Test Scenario
Before detailing the test functions I created, I want to mention the tools I used. These tools helped me verify different aspects of the contract and ensure that the scenarios I designed worked correctly.
During this test, I used the following Foundry functions:
-
assertEq(value1, value2): Verifies that
value1
andvalue2
are equal. If not, the test fails. -
assertGt(value1, value2): Verifies that
value1
is greater thanvalue2
. If not, the test fails. - vm.expectRevert(): Sets an expectation that an operation will revert (fail) during the test, allowing verification of the expected error.
- vm.expectEmit(): Sets an expectation that a specific event will be emitted during the test, allowing verification that the correct event was emitted.
- vm.deal(address, amount): Assigns a specific amount of ether to an address, useful for simulating balance conditions in tests.
- console.log(message): Prints messages to the console during tests, useful for debugging and inspecting data.
With the environment configured and the context of the BaseSpray
contract and Foundry functions established, let's review how I conducted the unit test:
Creating the Test Contract
Contract version
I used the same contract version I was going to test, in this case 0.8.20.Import
I imported the libraries:
import {Test, console} from “forge-std/Test.sol”;
imports the Test
library for test functions and console
for printing debugging messages to the console.
import {BaseSpray} from “../src/BaseSpray.sol”;
imports the BaseSpray
contract. I recommend importing only the contract to be tested to avoid having the EVM compile unnecessary code from other contracts in the same file.
-
Creating the Test Contract
I created the contract and named it
BaseSprayTest
usingis Test
to inherit the test library.
contract BaseSprayTest is Test {}
indicates that BaseSprayTest
inherits from Test, providing access to Foundry's testing functions.
- Declaration of variables and events I declared the necessary variables and events for the test.
Variables:
BaseSpray baseSpray;
declares a variable named baseSpray
of type BaseSpray
, which will be used to create and manage an instance of the BaseSpray
contract in the tests. This variable is initialized later to interact with the contract.
address owner = address(0x55555);
creates a test address for the contract owner. In this case, 0x55555
is the address assigned for testing.
Event:
event TokenDispersed(
address indexed sender,
address[] recipients,
uint256[] values,
address token
);
is emitted to log the token distribution, including the sender, recipients, distributed values, and token type. I created it in the test contract to verify that the contract emits this event correctly during tests.
-
Function setUp
I created the
setUp
function, which runs before each test to initialize the contract.
function setUp() public {
baseSpray = new BaseSpray(owner);
}
runs before each test to ensure a clean state. In this case, it deploys a new BaseSpray
contract with the specified owner.
Test Functions
After understanding the contract's functionality, especially the disperseEther
function, I started designing test scenarios by formulating key questions. I verified whether the contract's logic executed correctly, such as whether the owner
was properly assigned (even though the onlyOwner
modifier is not used in this contract, it is good practice to confirm the assignment), whether the require
statements validated conditions correctly, and whether the function's logic worked as expected, among other issues.
- First function
test_initialOwner
:
With this function, I verified that the owner is assigned correctly:
By using assertEq(baseSpray.owner(), owner, "Owner does not match");
I am stating that the owner of the baseSpray
contract must be equal to owner, and if it is not, it will throw the message "Owner does not match".
- Second function
test_disperseEtherWorks
:
With this function, I verify that the disperseEther function works correctly when there is enough balance in the contract and the parameters are valid:
- With
vm.deal(address(this), 3 ether);
I am giving 3 ether to the contract. - With
baseSpray.disperseEther{value: 3 ether}(recipients, amounts);
I am calling thedisperseEther
function of thebaseSpray
contract and telling it to send 3 ether to the addresses inrecipients
and usingamounts
to specify how much ether to send to each address inrecipients
. - With
assertEq(recipients[0].balance, 1 ether, "Should have 1 ether");
I am verifying that after calling thedisperseEther
function, the correctamount
is given to the first address I created. If this is not correct, it will throw the message "Should have 1 ether". - With
assertEq(recipients[1].balance, 2 ether, "Should have 2 ether");
I am verifying that the second address I created receives the 2 ether after calling thedisperseEther
function. If this is not correct, it will throw the message "Should have 2 ether". - With
assertEq(address(baseSpray).balance, 0, "Contract should have 0 ether");
I am verifying that after the contract makes the transfers, it should not have any ether left.
- Third function
functiontest_requireUnequalLengthsRecipientsAmounts
:
With this function, I verify that the disperseEther function reverts (fails) when the contract does not have enough balance to make the transfers.
- With
vm.expectRevert("Insufficient balance");
I expect the function to revert (fail) due to insufficient balance with the message "Insufficient balance". - With
baseSpray.disperseEther(recipients, amounts);
I am calling thedisperseEther
function of thebaseSpray
contract. - With
assertEq(address(baseSpray).balance, 0, "Contract should have 0 ether");
I am verifying that the contract does not have any ether.
- Fourth function
test_requireRecipientsLengthGreaterThanZero
:
With this function, I verify that the disperseEther function reverts (fails) when the _recipients array is empty:
- With
vm.expectRevert();
I expect the function to revert (fail) without specifying the error message. - With
baseSpray.disperseEther(new address , amounts);
I am calling thedisperseEther
function of thebaseSpray
contract but passing an emptyrecipients
array. - With
assertEq(address(baseSpray).balance, 0, "Contract should have 0 ether");
I am verifying that the contract does not have any ether.
- Fifth function
test_requireRecipientsLengthEqualToZero()
:
With this function, I verify that the disperseEther function emits the TokenDispersed event when called correctly:
- With
vm.expectEmit(true, true, true, true);
I expect the event to be emitted. The four true`` parameters indicate that all indexed fields and non-indexed fields should be verified. - With
emit TokenDispersed(address(this), recipients, amounts, address(0));
I emit the event with the parameters I created. - With
baseSpray.disperseEther{value: 3 ether}(recipients, amounts);
I call thedisperseEther
function with the previously specifiedrecipients
andamounts
.
- Sixth function
test_forLoopTotalValue
:
In this function, I test the for loop that accumulates the sum of all values in _amounts
to determine the total to be distributed.
- What I did here was create a variable of type uint256, called it totalValue, and assigned it the amount of the first address and the second address. Then, with assertEq, I verified that the contract's balance was indeed equal to the totalValue.
- Seventh function
test_revertWhenBalanceLessThanTotalValue
:
Verifies that recipients has a length of zero and expects a revert.
- Here, I gave the amounts a total of 3 ether and gave the contract 2 ether. Then I used vm.expectRevert, called the function disperseEther, and tried to send 3 ether to the recipients addresses.
- Eighth function
test_contractBalanceAfterTransaction
:
Verifies that the contract balance is zero after executing the transaction.
- Here, I performed the transaction normally, assigning ether to the contract equal to the amount in amounts. After calling the
disperseEther
function, I verified that the contract balance was zero withassertEq
.
- Ninth function
test_TokenDispersedEvent
:
Verifies that the TokenDispersed event is emitted correctly.
- I executed the disperseEther function normally, assigning the parameters correctly. Before calling the function, I used vm.expectEmit and added a console.log with the message "Expected emit set". Then I called the function, and added another console.log after calling the disperseEther function with the message "disperseEther called". Here, I was mostly testing how the console.log function works.
Running the Tests
To run the tests, simply execute the command:
forge test
Test Result
After running the tests, it is confirmed that the BaseSpray contract functions correctly in the specified scenarios and appropriately handles error cases. This experience has allowed me to deepen my understanding of unit testing and ensure the robustness of smart contracts.
Conclusion
Conducting this unit test allowed me to verify some scenarios of the ModeSpray smart contract. Although it is a basic test, I am satisfied with taking another step forward in my development as a Security Researcher. My next steps will include attempting to attack the contract with the most common attack vectors and performing fuzz testing.
I would like to thank RareSkills for helping me strengthen my knowledge in Foundry, which enabled me to perform this test on ModeSpray.
Thank you for reading this article. If you enjoyed it and wish to follow my progress as a Security Researcher in web3, as well as stay updated on my upcoming articles, you can follow me on Twitter, now known as X.
ModeSpray Twitter: https://x.com/ModeSpray
RareSkills Twitter: https://x.com/RareSkills_io
RareSkills Article: http://rareskills.io/post/foundry-testing-solidity
Wispy Twitter: https://x.com/wispyiwnl