[Tutorial 2/4] Thorough Testing for ERC20 Token Presale Smart Contract (DeFi Central)

SAMI
December 27, 2024 18 mins to read
Share

This article shows how to thoroughly test the DeFi Central ERC20 token presale smart contract using Hardhat and Chai on the Ethereum Sepolia testnet. We’ll cover testing all key functionalities, including token purchases with multiple currencies, claiming, refunds, and withdraw functions. Testing smart contracts on a testnet before mainnet deployment is crucial for ensuring functionality and security. The Sepolia testnet provides an ideal environment to test the smart contract with real network conditions without risking real assets.

Testing on a testnet gives developers a real-world feel for how their contracts will behave. It simulates the actual blockchain environment, so you can see how your code handles real transactions and network conditions. Plus, it lets you test how your contracts interact with other contracts and dApps, uncovering potential problems that might not show up in simpler unit tests.

Table of Contents

Prerequisites

Setting Up Hardhat Development Environment on Ubuntu Linux

Hardhat is a powerful Ethereum development framework that simplifies the creation, testing, and deployment of smart contracts. This guide will walk you through setting up Hardhat on Ubuntu Linux.


1. Install Node.js and npm

Hardhat requires Node.js and npm (Node Package Manager). Follow these steps to install the latest LTS version of Node.js:

Step 1: Update the package index

sudo apt update
sudo apt upgrade

Step 2: Install Node.js via NodeSource

Add the Node.js repository and install:

curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt install -y nodejs

Step 3: Verify installation

Check the versions of Node.js and npm

node -v
npm -v

You should see the installed versions. For example:

  • Node.js: v18.x
  • npm: v8.x

2. Install Hardhat

Once Node.js and npm are set up, install Hardhat in a new project directory.

Step 1: Create a project folder

mkdir defi-central-presale
cd defi-central-presale

Step 2: Initialize a new Node.js project

Run the following command and follow the prompts:

npm init -y

Step 3: Install Hardhat

Use npm to install Hardhat:

npm install --save-dev hardhat

Step 4: Verify Hardhat installation

Run the Hardhat CLI to confirm installation:

npx hardhat

You should see the following prompt:

3. Create a Hardhat Project

After verifying Hardhat, create a project structure.

Step 1: Create a basic project

Run the following command and choose the appropriate options:

npx hardhat

Select:
  • “Create a basic sample project”
  • Confirm the installation of the required dependencies when prompted.

This will create a basic Hardhat project with the following structure:

4. Install Additional Dependencies

To test and deploy smart contracts, install these essential dependencies:

Step 1: Install Ethereum.js libraries

npm install --save-dev @nomicfoundation/hardhat-toolbox

Step 2: Install dotenv for environment variables

npm install dotenv

Step 3: Install Hardhat dependencies for Sepolia

npm install --save-dev @nomiclabs/hardhat-ethers ethers

5. Configure Hardhat

Edit the hardhat.config.js file to specify network settings, paths, and compiler versions.

Example Configuration for Sepolia:

require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();

module.exports = {
  solidity: "0.8.18",
  networks: {
    sepolia: {
      url: process.env.SEPOLIA_RPC_URL, // RPC URL for Sepolia
      accounts: [process.env.PRIVATE_KEY], // Private key for deployment
    },
  },
};

Step 1: Add environment variables

Create a .env file in the root of your project:

touch .env

Add the following keys to .env:

SEPOLIA_RPC_URL=https://your-sepolia-rpc-url
PRIVATE_KEY=your-private-key

6. Test Hardhat Setup

Run the following command to test the Hardhat setup:

npx hardhat test

You should see the sample test for Lock.sol executed successfully.

7. Ready for Development

You’re all set! Use the following commands for common Hardhat tasks:

  • Compile : npx hardhat compile
  • Run Tests: npx hardhat test
  • Deploy Contracts: npx hardhat run scripts/deploy.js --network sepolia

Install Chai Assertion Library

Chai is a popular assertion library for writing robust tests. It’s included in the Hardhat toolbox, but you can explicitly install and configure it as follows:

Step 1: Install Chai

npm install chai

Step 2: Add Chai plugins for Ethereum-specific assertions

Install chai-as-promised and chai-ethers for Ethereum smart contract testing:

npm install --save-dev chai-as-promised chai-ethers

Step 3: Configure Chai in your tests

Add the following imports to your test files:

const { expect } = require("chai");
const chaiAsPromised = require("chai-as-promised");
chai.use(chaiAsPromised);

Write Tests with Chai

Use Chai to write clean and expressive tests. Here’s an example for a simple ERC-20 token:

Example Test (test/TokenTest.js):

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Token", function () {
  it("Should deploy and mint initial supply", async function () {
    const [owner] = await ethers.getSigners();
    const Token = await ethers.getContractFactory("Token");
    const token = await Token.deploy();

    await token.deployed();
    expect(await token.balanceOf(owner.address)).to.equal(ethers.utils.parseEther("100000000"));
  });
});

Run the test

npx hardhat test

Expected Output:

  Token
    ✓ Should deploy and mint initial supply (500ms)




Setup Ethereum-Waffle for Blockchain Testing

Ethereum-Waffle is a framework specifically designed for testing smart contracts with Ethereum. It integrates seamlessly with Hardhat and provides advanced features such as snapshot testing, mocking, and built-in matchers for contract testing.

Here’s how to set it up:


1. Install Ethereum-Waffle

Run the following command to install Ethereum-Waffle as a development dependency:

npm install --save-dev ethereum-waffle


2. Integrate Waffle with Hardhat

Waffle is already compatible with Hardhat when using the @nomicfoundation/hardhat-toolbox package. Ensure this package is installed:

npm install --save-dev @nomicfoundation/hardhat-toolbox

The toolbox includes Ethereum-Waffle, so you don’t need additional configuration for integration.


3. Write Tests Using Waffle

Ethereum-Waffle provides a range of matchers to simplify testing smart contracts. You can use expect assertions from Chai along with Waffle’s matchers.

Example Test (test/WaffleTest.js):

const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Token", function () {
  let token;
  let owner, addr1, addr2;

  beforeEach(async function () {
    [owner, addr1, addr2] = await ethers.getSigners();

    const Token = await ethers.getContractFactory("Token");
    token = await Token.deploy();
    await token.deployed();
  });

  it("Should assign the total supply to the owner", async function () {
    const ownerBalance = await token.balanceOf(owner.address);
    expect(await token.totalSupply()).to.equal(ownerBalance);
  });

  it("Should transfer tokens between accounts", async function () {
    // Transfer 50 tokens from owner to addr1
    await token.transfer(addr1.address, 50);
    const addr1Balance = await token.balanceOf(addr1.address);
    expect(addr1Balance).to.equal(50);

    // Transfer 50 tokens from addr1 to addr2
    await token.connect(addr1).transfer(addr2.address, 50);
    const addr2Balance = await token.balanceOf(addr2.address);
    expect(addr2Balance).to.equal(50);
  });
});

4. Waffle Matchers

Ethereum-Waffle extends Chai with matchers specifically designed for smart contract testing. Here are some examples:

Check Events:

    await expect(token.transfer(addr1.address, 50)) .to.emit(token, "Transfer") .withArgs(owner.address, addr1.address, 50);

    Revert Testing:

      await expect(token.transfer(addr1.address, 1000)).to.be.revertedWith("ERC20: transfer amount exceeds balance");

      Balance Assertions:

      await expect(() => token.transfer(addr1.address, 50)) .to.changeTokenBalances(token, [owner, addr1], [-50, 50]);

        5. Run Tests

        Run the tests using Hardhat’s testing command:

        npx hardhat test

        Example output:

          Token
            ✓ Should assign the total supply to the owner
            ✓ Should transfer tokens between accounts
        
          2 passing (400ms)

        By setting up Ethereum-Waffle, you gain access to powerful testing utilities tailored for blockchain development. Combined with Hardhat and Chai, Waffle ensures your tests are concise, readable, and robust.

        Testing the DeFi Central Presale Smart Contract: Step-by-Step Guide

        This guide explains the testing process for the DeFi Central Presale contract. Each section focuses on critical functionalities, ensuring the contract behaves as expected under various conditions. Solidity code snippets and explanations are provided for clarity.


        Test Structure

        The test suite is structured into distinct sections for systematic testing of the presale contract:

        describe('Presale Contract', async function () {
            // Deploy the DeFi Central token and presale smart contract before running tests
            before(async function () {
                // Deploy the token, presale, and mock USDT, USDC, and DAI contracts to Sepolia testnet
                // Approve the presale contract to spend stablecoins on behalf of investors
            });
        
            describe("Presale setup", function () {
                // Ensure the presale contract has sufficient tokens for distribution
            });
        
            // Test purchasing tokens with various payment methods
            describe("Buying DeFi Central with USDT", function () {
                // Validate scenarios for spending beyond allowance, successful purchases, and timing restrictions
            });
            describe("Buying DeFi Central with USDC", function () {
                // Similar tests as for USDT, adapted for USDC
            });
            describe("Buying DeFi Central with DAI", function () {
                // Similar tests as for USDT, adapted for DAI
            });
            describe("Buying DeFi Central with ETH", function () {
                // Test ETH purchases, timing restrictions, and other edge cases
            });
        
            // Test administrative functionalities
            describe("Claim functionality", function () {
                before(async function () {
                    // Configure claim time before running tests
                });
                // Validate token claiming, early investor bonuses, and access control
            });
        
            describe("Withdraw functionality", function () {
                before(async function () {
                    // Set the multisig wallet address before testing withdrawals
                });
                // Test owner withdrawals, unauthorized actions, and timing restrictions
            });
        
            describe("Refund functionality", function () {
                // Validate the refund process for unmet softcap, unauthorized refunds, and timing restrictions
            });
        });
        

        Presale Setup Testing

        The setup phase ensures the contract is initialized correctly with the required funds and settings:

        describe("Presale setup", function () {
            it("should set up presale correctly", async function () {
                expect(await presale.getFundsRaised()).to.equal(0);
                expect(await presale.tokensAvailable()).to.equal(presaleSupply);
            });
        });

        This test verifies:

        • Initial funds raised is zero.
        • Tokens allocated for the presale match the expected supply.

        Token Purchase Functionality

        Each payment method (USDT, USDC, DAI, ETH) has its own test suite. Here’s an example for USDT purchases:

        describe("Buying DeFi Central with USDT", function () {
            it("should not allow investors to spend more USDT than allowed", async function () {
                const tokenAmount = ethers.parseUnits("20000000", 18); 
                await expect(presale.connect(investor1).buyWithUSDT(tokenAmount))
                    .to.be.revertedWith("Insufficient allowance set for the contract.");
            });
        
            it("should allow investors to buy tokens with USDT", async function () {
                const tokenAmount = ethers.parseUnits("1500000", 18); 
                const usdtAmount = await presale.estimatedCoinAmountForTokenAmount(tokenAmount, usdtMockInterface);
        
                // Capture state before purchase
                const investmentsBefore = await presale.getInvestments(investor1.address, SEPOLIA_USDT);
                const fundsRaisedBefore = await presale.getFundsRaised();
                const tokensAvailableBefore = await presale.tokensAvailable();
        
                // Perform purchase
                const tx = await presale.connect(investor1).buyWithUSDT(tokenAmount);
                await tx.wait();
        
                // Validate state after purchase
                const investmentsAfter = await presale.getInvestments(investor1.address, SEPOLIA_USDT);
                const fundsRaisedAfter = await presale.getFundsRaised();
                const tokensAvailableAfter = await presale.tokensAvailable();
        
                expect(investmentsAfter).to.equal(investmentsBefore + usdtAmount);
                expect(fundsRaisedAfter).to.equal(fundsRaisedBefore + usdtAmount);
                expect(tokensAvailableAfter).to.equal(tokensAvailableBefore - tokenAmount);
            });
        
            it("should not allow token purchases before presale starts", async function () {
                const tokenAmount = ethers.parseUnits("1500000", 18);
                await expect(presale.connect(investor1).buyWithUSDT(tokenAmount))
                    .to.be.revertedWith("Invalid time for buying the token.");
            });
        
            it("should not allow token purchases after presale ends", async function () {
                const tokenAmount = ethers.parseUnits("1500000", 18);
                await expect(presale.connect(investor1).buyWithUSDT(tokenAmount))
                    .to.be.revertedWith("Invalid time for buying the token.");
            });
        });

        This test suite for “Buying DeFi Central with USDT” validates four critical scenarios, ensuring the contract’s functionality and security:


        1. Insufficient Allowance Check

        • Scenario: An investor attempts to buy 20,000,000 DeFi Central tokens (worth 1600 USDT) but only has a 1000 USDT allowance set.
        • Expected Behavior: Transaction fails with the error message: "Insufficient allowance set for the contract."
        • Purpose: Confirms the contract enforces allowance limits to prevent overspending.

        2. Successful Token Purchase

        • Scenario: Investors purchase 1,500,000 DeFi Central tokens each (worth 120 USDT per purchase).
        • Steps:
          1. Record the following states before the transaction:
            • USDT investments for both investors.
            • Total funds raised.
            • Token balances for the investors.
            • Tokens available for sale.
          2. Execute token purchases for two investors.
          3. Validate the following states after the transaction:
            • Token balances for both investors have increased accurately.
            • The total tokens available have decreased as expected.
            • The investment amounts for both investors are correctly recorded.
            • Total funds raised reflect the added investments.
        • Purpose: Ensures correct processing of successful purchases, accurate state updates, and reliable token allocations.

        3. Presale Timing Restriction (Before Start)

        • Scenario: An investor tries to purchase tokens before the presale start time.
        • Expected Behavior: Transaction reverts with the error message: "Invalid time for buying the token."
        • Purpose: Confirms that token purchases are restricted to the presale period, preventing early transactions.

        4. Presale Timing Restriction (After End)

        • Scenario: An investor attempts to purchase tokens after the presale end time.
        • Expected Behavior: Transaction reverts with the error message: "Invalid time for buying the token."
        • Purpose: Ensures purchases cannot occur once the presale period has concluded, enforcing strict timing controls.

        Summary

        This test suite thoroughly validates the USDT purchase functionality, ensuring:

        • Transactions respect allowance limits.
        • Successful purchases update contract states accurately.
        • Timing restrictions prevent unauthorized transactions before and after the presale period.

        Next Steps

        Similar test suites can be written for purchases using USDC, DAI, and ETH to ensure consistent functionality across all payment methods.

        Claim Functionality

        Testing token claims ensures proper distribution and bonus allocation:

        describe("Claim functionality", function () {
            before(async function () {
                const setClaimTimeTx = await presale.connect(owner).setClaimTime(claimTime);
                await setClaimTimeTx.wait();
            });
        
            it("should revert if tokens are claimed before the claim time", async function () {
                await expect(presale.connect(investor1).claim()).to.be.revertedWith("It's not claiming time yet.");
            });
        
            it("should distribute tokens and bonuses correctly", async function () {
                const initialBalance = await spx.balanceOf(investor1.address);
                const tokenAmount = await presale.getTokenAmountForInvestor(investor1.address);
                const bonusAmount = await presale.getBonusTokenAmount();
        
                const claimTx = await presale.connect(investor1).claim();
                await claimTx.wait();
        
                const finalBalance = await spx.balanceOf(investor1.address);
                expect(finalBalance - initialBalance).to.equal(tokenAmount + bonusAmount);
            });
        });

        This Claim Test Suite thoroughly validates the token claiming process for the DeFi Central Presale, covering five essential test cases:


        1. Initial Setup

        • Scenario: Configure the claim time before running tests.
        • Steps:
          1. Use the owner account to set the claim time using the setClaimTime function.
          2. Confirm the claim time is updated correctly in the contract.
        • Purpose: Ensures proper initialization of the claim period, allowing investors to claim tokens only after the presale has concluded.

        2. Early Claim Prevention

        • Scenario: An investor attempts to claim tokens before the claim time is reached.
        • Steps:
          1. Simulate a token purchase using USDT.
          2. Attempt to claim tokens before the configured claim time.
          3. Expect the transaction to revert with an error: "It's not claiming time yet."
        • Purpose: Validates that claims are restricted until the designated claim period begins, maintaining presale integrity.

        3. Early Investor Bonus Verification

        • Scenario: Verify that early investors are eligible for bonus tokens.
        • Steps:
          1. Check the early investor status for two test investors.
          2. Confirm that both investors are flagged as early participants.
          3. Validate that these investors are eligible for bonus token distribution.
        • Purpose: Ensures early contributors are appropriately identified and rewarded with bonus tokens as promised.

        4. Token Claiming Process

        • Scenario: Investors claim tokens, including any eligible bonuses.
        • Steps:
          1. Record the investor’s initial token balance.
          2. Fetch the expected token amount and any bonus tokens from the contract.
          3. Execute the claim transaction.
          4. Verify the following:
            • The final token balance reflects the expected token amount and bonus.
            • The claimed tokens are reset to zero in the contract.
          5. Attempt a second claim and expect it to fail with an error: "No tokens claim."
        • Purpose: Confirms that the claiming mechanism distributes tokens accurately, resets the claimed balance, and prevents duplicate claims.

        5. Unauthorized Claim Time Setting

        • Scenario: A non-owner attempts to set the claim time.
        • Steps:
          1. Use an investor account to call setClaimTime.
          2. Expect the transaction to revert with a custom error: "NotOwner."
        • Purpose: Enforces strict access control, ensuring only the contract owner can modify the claim time.

        Summary

        This test suite ensures that the token claiming process is reliable and secure:

        • Tokens can only be claimed after the presale ends and the claim time is set.
        • Early investors are rewarded as per the contract’s bonus policy.
        • Access controls and error handling are robust, preventing unauthorized actions.

        By covering a range of conditions, this suite guarantees the contract’s behavior aligns with the presale rules and maintains fairness for all participants.

        Withdraw Functionality

        Testing withdrawals ensures only authorized users can perform these actions:

        describe("Withdraw functionality", function () {
            before(async function () {
                const setWalletTx = await presale.connect(owner).setWallet(wallet.address);
                await setWalletTx.wait();
            });
        
            it("should allow the owner to withdraw funds after the presale ends", async function () {
                const initialBalance = await usdtMockInterface.balanceOf(wallet.address);
                const presaleBalance = await usdtMockInterface.balanceOf(presale.address);
        
                const withdrawTx = await presale.connect(owner).withdraw();
                await withdrawTx.wait();
        
                const finalBalance = await usdtMockInterface.balanceOf(wallet.address);
                expect(finalBalance).to.equal(initialBalance + presaleBalance);
            });
        
            it("should revert if a non-owner attempts to withdraw", async function () {
                await expect(presale.connect(investor1).withdraw()).to.be.revertedWithCustomError(presale, "NotOwner");
            });
        });

        This Withdraw Test Suite rigorously evaluates the withdrawal process for the DeFi Central Presale, focusing on five essential scenarios to ensure security and correctness:


        1. Initial Setup

        • Scenario: Configure the wallet address for withdrawals before running tests.
        • Steps:
          1. Use the owner account to set the withdrawal wallet address via the setWallet function.
          2. Confirm that the wallet address is updated correctly in the contract.
        • Purpose: Ensures the contract is initialized with the correct wallet address, enabling secure fund transfers post-presale.

        2. Owner Withdraw Testing

        • Scenario: The owner withdraws the contract’s funds after the presale ends.
        • Steps:
          1. Record the initial balances of USDT, USDC, and DAI in the designated wallet.
          2. Check the presale contract’s balances for these tokens.
          3. Execute the withdrawal transaction using the owner account.
          4. Validate the following post-withdrawal:
            • Wallet balances for USDT, USDC, and DAI have increased by the respective amounts withdrawn.
            • Contract balances for all tokens are reduced to zero.
        • Purpose: Confirms that funds are transferred accurately to the designated wallet and contract balances are cleared.

        3. Unauthorized Withdraw Prevention

        • Scenario: A non-owner attempts to withdraw funds from the contract.
        • Steps:
          1. Use an investor or other non-owner account to call the withdraw function.
          2. Expect the transaction to revert with a custom error: "NotOwner."
        • Purpose: Ensures strict access control, allowing only the owner to perform withdrawals.

        4. Wallet Setting Access Control

        • Scenario: A non-owner attempts to change the withdrawal wallet address.
        • Steps:
          1. Use an investor or other non-owner account to call the setWallet function.
          2. Expect the transaction to revert with a custom error: "NotOwner."
        • Purpose: Verifies that only the owner can configure the wallet address, maintaining control over withdrawal destinations.

        5. Early Withdraw Prevention

        • Scenario: The owner tries to withdraw funds before the presale has ended.
        • Steps:
          1. Attempt to execute the withdraw function while the presale is still active.
          2. Expect the transaction to revert with an error: "Cannot withdraw because presale is still in progress."
        • Purpose: Ensures funds remain locked during the presale period, preventing premature withdrawals.

        Summary

        This test suite ensures the withdrawal mechanism operates securely and as intended:

        • Funds are transferred only after the presale concludes.
        • Withdrawal actions are strictly limited to the contract owner.
        • The designated wallet address is protected against unauthorized modifications.
        • Timing restrictions safeguard funds during the presale.

        By validating these scenarios, the suite guarantees the withdrawal process is reliable, secure, and compliant with the presale rules.

        Refund Functionality

        Testing refunds ensures compliance with softcap conditions:

        describe("Refund functionality", function () {
            it("should allow the owner to refund if softcap is not met", async function () {
                const initialBalance = await usdtMockInterface.balanceOf(investor1.address);
                const refundAmount = await presale.getInvestments(investor1.address, usdtMockInterface);
        
                const refundTx = await presale.connect(owner).refund();
                await refundTx.wait();
        
                const finalBalance = await usdtMockInterface.balanceOf(investor1.address);
                expect(finalBalance).to.equal(initialBalance + refundAmount);
            });
        
            it("should revert if a non-owner attempts a refund", async function () {
                await expect(presale.connect(investor1).refund()).to.be.revertedWithCustomError(presale, "NotOwner");
            });
        });

        This Refund Test Suite comprehensively validates the refund process for the DeFi Central Presale, covering three critical scenarios to ensure functionality and security:


        1. Owner Refund Testing

        • Scenario: The owner initiates refunds after the presale ends and the softcap is not met.
        • Steps:
          1. Record the initial balances of all investors in USDT, USDC, and DAI.
          2. Retrieve the investment amounts for each investor across all tokens.
          3. Ensure all investors set up token approvals for the presale contract.
          4. Execute the refund transaction using the owner account.
          5. Validate post-transaction states:
            • USDT, USDC, and DAI balances for all investors reflect the refunded amounts.
            • Each investor receives the exact amount they initially invested.
        • Purpose: Confirms that the refund mechanism accurately returns investments to investors and handles multiple tokens securely.

        2. Unauthorized Refund Prevention

        • Scenario: A non-owner attempts to initiate refunds.
        • Steps:
          1. Use an investor or other non-owner account to call the refund function.
          2. Expect the transaction to revert with a custom error: "NotOwner."
        • Purpose: Ensures that only the contract owner has the authority to initiate refunds, protecting against unauthorized actions.

        3. Early Refund Prevention

        • Scenario: The owner tries to issue refunds before the presale period has ended.
        • Steps:
          1. Attempt to execute the refund function while the presale is still active.
          2. Expect the transaction to revert with an error: "Cannot refund because presale is still in progress."
        • Purpose: Ensures that refunds can only occur after the presale ends, maintaining the integrity of the presale process.

        Summary

        This test suite ensures the refund mechanism operates as expected under all conditions:

        • Investors are refunded accurately in proportion to their investments across USDT, USDC, and DAI.
        • Access control prevents unauthorized refund attempts.
        • Timing restrictions ensure refunds cannot be issued prematurely.

        By covering these scenarios, this suite validates that the refund process is secure, reliable, and fair to all participants.

        Best Practices

        1. Use before hooks for setup tasks.
        2. Test both successful and failing scenarios.
        3. Verify events and state changes.
        4. Test access control mechanisms.
        5. Maintain test isolation to avoid interference.

        This comprehensive test suite ensures the DeFi Central Presale contract is secure, functional, and fair. Each test validates key functionalities, enforcing timing restrictions, access control, and accurate state transitions for a seamless presale experience.



        Leave a comment

        Your email address will not be published. Required fields are marked *