DEV Community

Cover image for Unit Test ModeSpray
wispy for Mode

Posted on

Unit Test ModeSpray

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.

image description

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:

  1. Clone the repository git clone “REPOSITORY_URL”
  2. Access the repository cd modespray
  3. Open the repository in VSCode code .
  4. Initialize a Foundry project Open the terminal in VSCode and initialize a Foundry project. forge init
  5. Install OpenZeppelin forge install OpenZeppelin/openzeppelin-contracts

Smart Contract Description

The BaseSpraycontract, which inherits from OpenZeppelin's Ownable, allows the distribution of ether among multiple recipients. Below, I provide a brief description of its functionality:

  1. Constructor: Initializes the Ownablecontract with an initialOwneraddress as the owner.
  2. disperseEther function:
    1. Parameters: Accepts two arrays in memory: _recipients (recipient addresses) and _amounts (amounts to distribute).
    2. Require:
    3. The _recipients and _amounts arrays must have the same length. If not, the function reverts.
    4. The _recipients array cannot be empty. If it is empty, the function reverts.
  3. Calculation of totalValue: Accumulates the sum of all _amounts values to determine the total amount to be distributed.
  4. Balance verification: Ensures that the contract's balance is sufficient to cover totalValue. If not, the function reverts with the message “Insufficient balance”.
  5. Ether distribution: Iterates over the recipients and makes the corresponding transfers.
  6. 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));
    }

}
Enter fullscreen mode Exit fullscreen mode

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 value1and value2are equal. If not, the test fails.
  • assertGt(value1, value2): Verifies that value1is greater than value2. 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 BaseSpraycontract 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 Testlibrary for test functions and consolefor 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 using is 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 setUpfunction, 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:

First function

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:

Second function

  1. With vm.deal(address(this), 3 ether); I am giving 3 ether to the contract.
  2. With baseSpray.disperseEther{value: 3 ether}(recipients, amounts); I am calling the disperseEther function of the baseSpray contract and telling it to send 3 ether to the addresses in recipients and using amounts to specify how much ether to send to each address in recipients.
  3. With assertEq(recipients[0].balance, 1 ether, "Should have 1 ether"); I am verifying that after calling the disperseEther function, the correct amount is given to the first address I created. If this is not correct, it will throw the message "Should have 1 ether".
  4. 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 the disperseEther function. If this is not correct, it will throw the message "Should have 2 ether".
  5. 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.

Third function

  1. With vm.expectRevert("Insufficient balance"); I expect the function to revert (fail) due to insufficient balance with the message "Insufficient balance".
  2. With baseSpray.disperseEther(recipients, amounts); I am calling the disperseEther function of the baseSpray contract.
  3. 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:

Fourth function

  1. With vm.expectRevert(); I expect the function to revert (fail) without specifying the error message.
  2. With baseSpray.disperseEther(new address , amounts); I am calling the disperseEther function of the baseSpray contract but passing an empty recipients array.
  3. 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:

Fifth function

  1. 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.
  2. With emit TokenDispersed(address(this), recipients, amounts, address(0)); I emit the event with the parameters I created.
  3. With baseSpray.disperseEther{value: 3 ether}(recipients, amounts); I call the disperseEther function with the previously specified recipients and amounts.
  • 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.

Sixth function

  1. 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.

Seventh function

  1. 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.

Eighth function

  1. 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 with assertEq.
  • Ninth function test_TokenDispersedEvent:

Verifies that the TokenDispersed event is emitted correctly.

Ninth function

  1. 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

Top comments (0)