Thorough Testing for ERC20 Token Presale Smart Contract using Hardhat and Chai
Steven

Steven @steven228312

About: A critical and forward thinking fullstack developer && blockchain engineer

Location:
Mesquite, Texas
Joined:
Nov 8, 2024

Thorough Testing for ERC20 Token Presale Smart Contract using Hardhat and Chai

Publish Date: Nov 9 '24
143 29

Introduction

This article shows how to thoroughly test a SPX ERC20 token presale smart contract using Hardhat and Chai on Ethereum Sepolia testnet. We'll cover testing all key functionalities including token purchases with multiple currencies, claiming, refunds and withdraw functions.

Prerequisites

  • Hardhat development environment
  • Chai assertion library
  • Ethereum-waffle for blockchain testing
  • Basic understanding of TypeScript testing

Testing Presale smart contract step by step

Test Structure

describe('Presale Contract', async function () {
    // Before testing, deploy SPX ERC20 token smart contract and Presale smart contract 
    before(async function () {
        // Deploy SPX, Presale, USDT, USDC, DAI token contract to Sepolia testnet and gets contract instance.
        // Approve presale contract to spend USDT, USDC, and DAI from investors
    })

    describe("Presale setup", function () {
        //Checks if sufficient tokens are available in presale contract
    })

    // Test token purchase functions
    describe("Buying SPX with USDT", function () {
        // It should not allow investors spending USDT more thatn allowance
        // It should allow investors buying SPX tokens with USDT
        // It should not allow investors buying SPX tokens before presale starts
        // It should now allow investors buying SPX tokens after presale ends 
    })
    describe("Buying SPX with USDC", function () {
        // It should not allow investors spending USDC more thatn allowance
        // It should allow investors buying SPX tokens with USDC
        // It should not allow investors buying SPX tokens before presale starts
        // It should now allow investors buying SPX tokens after presale ends 
    })
    describe("Buying SPX with DAI", function () {
        // It should not allow investors spending DAI more thatn allowance
        // It should allow investors buying SPX tokens with DAI
        // It should not allow investors buying SPX tokens before presale starts
        // It should now allow investors buying SPX tokens after presale ends 
    })
    describe("Buying SPX with ETH", function () {
        // It should allow investors buying SPX tokens with ETH
        // It should not allow investors buying SPX tokens before presale starts
        // It should now allow investors buying SPX tokens after presale ends 
    })

    // Test admin functions
    describe("Claim functionality", function () {
        before(async function () {
            // Set the claim time before each test
        })
        // It should revert if trying to claim tokens before claim time is set
        // It should correctly distribute bonus tokens among multiple early investors
        // It should allow investors to claim their tokens
        // It should revert if a non-owner tries to set the claim time
    })

    describe("Withdraw functionality", function () {
        before(async function () {
            // Set the predefined multisig wallet before each test       
        })
        // It should allow the owner to withdraw balance of contract to wallet after presale ends
        // It should revert if non-owner tries to withdraw
        // It should revert if a non-owner tries to set the wallet
        // It should revert if trying to withdraw before the presale ends
    })

    describe("Refund Functionality", function () {
        // It should allow the owner to refund to investors if softcap is not reached
        // It should revert if non-owner tries to refund
        // It should revert if trying to refund before the presale ends
    })
})
Enter fullscreen mode Exit fullscreen mode

Important Key Test Cases

Presale setup

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);
    });
});
Enter fullscreen mode Exit fullscreen mode

Token purchase function test

describe("Buying SPX with USDT", function () {
    it("should not allow investors spending usdt more than allowance", 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 buying SPX tokens with USDT.", async function () {
        const tokenAmount = ethers.parseUnits("1500000", 18); 
        const usdtAmount = await presale.estimatedCoinAmountForTokenAmount(tokenAmount, usdtMockInterface);

        const investmentsforUserBeforeTx1 = await presale.getInvestments(investor1.address, SEPOLIA_USDT);
        const investmentsforUserBeforeTx2 = await presale.getInvestments(investor2.address, SEPOLIA_USDT);
        const fundsRaisedBeforeTx = await presale.getFundsRaised();
        const investorTokenBalanceBeforeTx1 = await presale.getTokenAmountForInvestor(investor1.address);
        const investorTokenBalanceBeforeTx2 = await presale.getTokenAmountForInvestor(investor2.address);
        const tokensAvailableBeforeTx = await presale.tokensAvailable();

        const tx1 = await presale.connect(investor1).buyWithUSDT(tokenAmount);
        await tx1.wait();
        const tx2 = await presale.connect(investor2).buyWithUSDT(tokenAmount);
        await tx2.wait();

        const investmentsforUserAfterTx1 = await presale.getInvestments(investor1.address, SEPOLIA_USDT);
        const investmentsforUserAfterTx2 = await presale.getInvestments(investor2.address, SEPOLIA_USDT);
        const fundsRaisedAfterTx = await presale.getFundsRaised();
        const investorTokenBalanceAfterTx1 = await presale.getTokenAmountForInvestor(investor1.address);
        const investorTokenBalanceAfterTx2 = await presale.getTokenAmountForInvestor(investor2.address);
        const tokensAvailableAfterTx = await presale.tokensAvailable();

        expect(investorTokenBalanceAfterTx1).to.equal(investorTokenBalanceBeforeTx1 + tokenAmount);
        expect(investorTokenBalanceAfterTx2).to.equal(investorTokenBalanceBeforeTx2 + tokenAmount);
        expect(tokensAvailableAfterTx).to.equal(tokensAvailableBeforeTx - BigInt(2) * tokenAmount);
        expect(investmentsforUserAfterTx1).to.equal(investmentsforUserBeforeTx1 + usdtAmount);
        expect(investmentsforUserAfterTx2).to.equal(investmentsforUserBeforeTx2 + usdtAmount);
        expect(fundsRaisedAfterTx).to.equal(fundsRaisedBeforeTx + usdtAmount * BigInt(2) / BigInt(1000000));
    });

    //Before presale starts
    it("should not allow investors buying SPX tokens 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.");
    });

    //After presale ends
    it("should not allow investors buying SPX tokens 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.");
    });
});
Enter fullscreen mode Exit fullscreen mode

This test suite "Buying SPX with USDT" contains four test cases that verify different scenarios:

Testing insufficient allowance:

  • Tests buying 20,000,000 SPX tokens (worth 1600 USDT) Investors have 1000 USDT allowance
  • Expects transaction to fail with "Insufficient allowance" error
  • Verifies allowance checks are working properly

Testing successful token purchase:

  • Tests buying 1,500,000 SPX tokens (worth 120 USDT)
  • Records state before transactions:
    • USDT investments for both investors
    • Total funds raised
    • Token balances
    • Available tokens
  • Executes purchases for two investors
  • Verifies after transaction:
    • Token balances increased correctly
    • Available tokens decreased properly
    • Investment amounts recorded accurately
    • Funds raised updated correctly

Testing presale timing restrictions (before start):

  • Attempts purchase before presale start time
  • Expects revert with "Invalid time" message
  • Verifies timing restrictions work

Testing presale timing restrictions (after end):

  • Attempts purchase after presale end time
  • Expects revert with "Invalid time" message
  • Verifies presale end time enforcement

This test suite ensures the USDT purchase functionality works correctly under all expected conditions.
Similarly, we can write test suite for USDC, DAI, ETH purchase functionality.

Claim function test

describe("Claim functionality", function () {
    before(async function () {
        // Set the claim time before each test
        const setClaimTimeTx = await presale.connect(owner).setClaimTime(claimTime);
        await setClaimTimeTx.wait();
    });

    //Before presale ends
    it("should revert if trying to claim tokens before claim time is set", async function () {
        await presale.connect(investor1).buyWithUSDT(ethers.parseUnits("1500000", 18));
        await expect(presale.connect(investor1).claim(investor1.address)).to.be.revertedWith("It's not claiming time yet.");
    });

    it("should correctly distribute bonus tokens among multiple early investors", async function () {
        expect(await presale.isEarlyInvestors(investor1.address)).to.be.true;
        expect(await presale.isEarlyInvestors(investor2.address)).to.be.true;
    });

    it("should allow investors to claim their tokens", async function () {
        const initialBalance = await spx.balanceOf(investor2.address);
        const tokenAmount = await presale.getTokenAmountForInvestor(investor2.address);
        const bonusTokenAmount = await presale.getBonusTokenAmount();

        const claimTx = await presale.connect(investor2).claim(investor2.address);
        await claimTx.wait();
        const finalBalance = await spx.balanceOf(investor2.address);

        expect(finalBalance - initialBalance).to.equal(tokenAmount + bonusTokenAmount);
        expect(await presale.getTokenAmountForInvestor(investor2.address)).to.equal(0);
        //Second claim
        await expect(presale.connect(investor2).claim(investor2.address))
            .to.be.revertedWith("No tokens claim.");
    });

    it("should revert if a non-owner tries to set the claim time", async function () {
        await expect(presale.connect(investor1).setClaimTime(claimTime)).to.be.revertedWithCustomError(presale, "NotOwner");
    });
});
Enter fullscreen mode Exit fullscreen mode

This Claim test suite verifies the token claiming process with five key test cases:

Initial setup:

  • Sets up claim time before running tests
  • Uses owner account to set claim time

Early Claim Prevention:

  • Tests claiming before proper time
  • Buys tokens with USDT first
  • Verifies claim attempt fails with timing error

Early Investor Bonus Verification:

  • Checks early investor status for two investors
  • Verifies both are marked as early investors
  • Ensures bonus distribution eligibility

Token Claiming Process:

  • Records initial token balance
  • Gets expected token amount and bonus
  • Executes claim transaction
  • Verifies:
    • Final balance matches expected amount
    • Token balance reset to zero
    • Second claim attempt fails

Token Claiming Process:

  • Tests unauthorized claim time setting
  • Verifies only owner can set claim time
  • Checks custom error handling

This test suite ensures the claiming mechanism works correctly and securely under various conditions.

Withdraw function test

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 balance of contract to wallet after presale ends", async function () {
        const initialUSDTBalance = await usdtMockInterface.balanceOf(wallet.address);
        const initialUSDCBalance = await usdcMockInterface.balanceOf(wallet.address);
        const initialDAIBalance = await daiMockInterface.balanceOf(wallet.address);

        const usdtAmount = await usdtMockInterface.balanceOf(presaleAddress);
        const usdcAmount = await usdcMockInterface.balanceOf(presaleAddress);
        const daiAmount = await daiMockInterface.balanceOf(presaleAddress);

        const withdrawTx = await presale.connect(owner).withdraw();
        await withdrawTx.wait();

        const finalUSDTBalance = await usdtMockInterface.balanceOf(wallet.address);
        const finalUSDCBalance = await usdcMockInterface.balanceOf(wallet.address);
        const finalDAIBalance = await daiMockInterface.balanceOf(wallet.address);

        expect(finalUSDTBalance).to.equal(initialUSDTBalance + usdtAmount);
        expect(finalUSDCBalance).to.equal(initialUSDCBalance + usdcAmount);
        expect(finalDAIBalance).to.equal(initialDAIBalance + daiAmount);
    });

    it("should revert if non-owner tries to withdraw", async function () {
        await expect(presale.connect(investor1).withdraw()).to.be.revertedWithCustomError(presale, "NotOwner");
    });

    it("should revert if a non-owner tries to set the wallet", async function () {
        await expect(presale.connect(investor1).setWallet(wallet)).to.be.revertedWithCustomError(presale, "NotOwner");
    });

    //Before presale ends
    it("should revert if trying to withdraw before the presale ends", async function () {
        await expect(presale.connect(owner).withdraw())
            .to.be.revertedWith("Cannot withdraw because presale is still in progress.");
    })
})
Enter fullscreen mode Exit fullscreen mode

This Withdraw test suite verifies the token claiming process with five key test cases:

Initial setup:

  • Sets withdraw wallet address before tests
  • Uses owner account to configure wallet

Owner Withdraw Testing:

  • Records initial balances of USDT, USDC, DAI in wallet
  • Gets contract balances for all tokens
  • Executes withdraw
  • Verifies final balances match expected amounts:
    • USDT balance increased correctly
    • USDC balance increased correctly
    • DAI balance increased correctly

Unauthorized Withdraw Prevention:

  • Tests withdraw with non-owner account
  • Verifies custom error "NotOwner" is thrown

Wallet Setting Access Control:

  • Tests unauthorized wallet address setting
  • Verifies only owner can set wallet address
  • Checks custom error handling

Early Withdraw Prevention:

  • Tests withdraw before presale end time
  • Verifies timing restriction message
  • Ensures funds are locked during presale

This test suite ensures the withdrawl mechanism works correctly and securely under various conditions.

Refund function test

describe("Refund Functionality", function () {
    it("should allow the owner to refund to investors if softcap is not reached", async function () {
        const investor1USDTInitialBalance = await usdtMockInterface.balanceOf(investor1.address);
        const investor2USDTInitialBalance = await usdtMockInterface.balanceOf(investor2.address);
        const investor1USDCInitialBalance = await usdcMockInterface.balanceOf(investor1.address);
        const investor2USDCInitialBalance = await usdcMockInterface.balanceOf(investor2.address);
        const investor1DAIInitialBalance = await daiMockInterface.balanceOf(investor1.address);
        const investor2DAIInitialBalance = await daiMockInterface.balanceOf(investor2.address);

        const investor1USDTAmount = await presale.getInvestments(investor1.address, usdtMockInterface);
        const investor2USDTAmount = await presale.getInvestments(investor2.address, usdtMockInterface);
        const investor1USDCAmount = await presale.getInvestments(investor1.address, usdcMockInterface);
        const investor2USDCAmount = await presale.getInvestments(investor2.address, usdcMockInterface);
        const investor1DAIAmount = await presale.getInvestments(investor1.address, daiMockInterface);
        const investor2DAIAmount = await presale.getInvestments(investor2.address, daiMockInterface);

        await usdtMockInterface.connect(investor1).approve(presaleAddress, investor1USDTAmount);
        await usdtMockInterface.connect(investor2).approve(presaleAddress, investor2USDTAmount);
        await usdcMockInterface.connect(investor1).approve(presaleAddress, investor1USDCAmount);
        await usdcMockInterface.connect(investor2).approve(presaleAddress, investor2USDCAmount);
        await daiMockInterface.connect(investor1).approve(presaleAddress, investor1DAIAmount);
        await daiMockInterface.connect(investor2).approve(presaleAddress, investor2DAIAmount);

        const tx = await presale.connect(owner).refund();
        await tx.wait();

        const investor1USDTFinalBalance = await usdtMockInterface.balanceOf(investor1.address);
        const investor2USDTFinalBalance = await usdtMockInterface.balanceOf(investor2.address);
        const investor1USDCFinalBalance = await usdcMockInterface.balanceOf(investor1.address);
        const investor2USDCFinalBalance = await usdcMockInterface.balanceOf(investor2.address);
        const investor1DAIFinalBalance = await daiMockInterface.balanceOf(investor1.address);
        const investor2DAIFinalBalance = await daiMockInterface.balanceOf(investor2.address);

        expect(investor1USDTFinalBalance).to.equal(investor1USDTInitialBalance + investor1USDTAmount);
        expect(investor2USDTFinalBalance).to.equal(investor2USDTInitialBalance + investor2USDTAmount);
        expect(investor1USDCFinalBalance).to.equal(investor1USDCInitialBalance + investor1USDCAmount);
        expect(investor2USDCFinalBalance).to.equal(investor2USDCInitialBalance + investor2USDCAmount);
        expect(investor1DAIFinalBalance).to.equal(investor1DAIInitialBalance + investor1DAIAmount);
        expect(investor2DAIFinalBalance).to.equal(investor2DAIInitialBalance + investor2DAIAmount);
    });

    it("should revert if non-owner tries to refund", async function () {
        await expect(presale.connect(investor1).refund())
            .to.be.revertedWithCustomError(presale, "NotOwner");
    });

    //After presale ends
    it("should revert if trying to refund before the presale ends", async function () {
        await expect(presale.connect(owner).refund())
            .to.be.revertedWith("Cannot refund because presale is still in progress.");
    })
});
Enter fullscreen mode Exit fullscreen mode

This Refund test suite verifies the token refunding process with three key test cases:

Owner Refund Testing:

  • Records initial balances for all investors in USDT, USDC, DAI
  • Gets investment amounts for each investor and token
  • Sets up approvals for all tokens and investors
  • Executes refund transaction
  • Verifies final balances:
    • USDT balances returned correctly
    • USDC balances returned correctly
    • DAI balances returned correctly
  • Ensures each investor receives their exact investment back

Unauthorized Refund Prevention:

  • Tests refund with non-owner account
  • Verifies custom error "NotOwner" is thrown
  • Ensures only owner can initiate refunds

Early Refund Prevention:

  • Tests refund before presale end time
  • Verifies timing restriction message
  • Ensures refunds can't happen during active presale

This ensures the refund mechanism works correctly and securely for all investors and tokens under various conditions.

Best Practices

  • Use before hooks for test setup
  • Test both success and failure cases
  • Verify events and state changes
  • Test access control
  • Use helper functions for common operations
  • Maintain test isolation

Conclusion

Thorough testing is crucial for presale contracts handling significant value. This testing approach ensures the contract behaves correctly under all conditions while maintaining security and fairness for all participants.

Comments 29 total

  • LovelyBTC
    LovelyBTCNov 9, 2024

    Highly recommend this article.
    It contains comprehensive test cases for ERC20 token presale smart contract.
    Thanks for your effort

  • Ichikawa Hiroshi
    Ichikawa HiroshiNov 9, 2024

    This article is incredibly insightful and packed with valuable information in ERC20 token presale smart contract test on Sepolia testnet! It has provided me with practical strategies that I can apply directly to my work. Highly recommend it to anyone looking to enhance their knowledge and skills in Blockchain development.

  • Robert Angelo
    Robert AngeloNov 9, 2024

    Really impressive.
    Thank you for sharing.
    Looking forward to your next article.
    Thanks again

  • Mitsuru Kudo
    Mitsuru KudoNov 9, 2024

    Thank you so much for the helpful information!
    Highly recommended.
    Thanks again

  • LovelyBTC
    LovelyBTCNov 11, 2024

    Can we test the smart contract after deploying the sepolia testnet and test on Etherscan?

    • Steven
      StevenNov 11, 2024

      Yes, of course,
      I will show you in more detail in my next article.

      • LovelyBTC
        LovelyBTCNov 11, 2024

        Okay, thanks.
        Will wait for your next article.

  • Arlo Oscar
    Arlo OscarNov 12, 2024

    Looks amazing

  • eugene garrett
    eugene garrettNov 12, 2024

    Likewise, this article gave me comprehensive content to blockchain technology

  • Jackson Mori
    Jackson MoriNov 12, 2024

    Thanks for your article.
    Looks nice like always.

  • Jason
    JasonNov 12, 2024

    Thanks for your article.
    Deeply impressive.
    Will wait your next article.

  • Jin William
    Jin WilliamNov 12, 2024

    Thank you Steven

  • Sebastian Robinson
    Sebastian RobinsonNov 12, 2024

    Thanks Steven.
    Nice article

  • Oleksii Kasian
    Oleksii KasianNov 12, 2024

    Great. Thanks

  • Sebastian Robinson
    Sebastian RobinsonNov 12, 2024

    Comprehensive and detailed guide for testing smart contract using Chai and Solidity

  • Handy Apps
    Handy AppsNov 14, 2024

    This article gave me clear understanding of through testing method of ERC20 token presale smart contract using Hardhat and Chai

  • Elon
    ElonNov 14, 2024

    👍👍Thank you

  • Ares Colin
    Ares ColinNov 15, 2024

    Thanks for your article.

  • Brave
    BraveNov 15, 2024

    Thank you.

  • anders nielsen
    anders nielsenNov 15, 2024

    This article gave me clear understanding of thorough testing for ERC20 token presale smart contract.
    Thanks

  • Johnny Webster
    Johnny WebsterNov 15, 2024

    Great

  • Minato Shaikh
    Minato ShaikhNov 15, 2024

    This article is really impressive.
    Thanks for your effort
    🎈🎉

  • Elon
    ElonNov 16, 2024

    Thanks for sharing the article.
    I am new to blockchain but it's really easy to understand.

  • Ito Inoue
    Ito InoueNov 16, 2024

    Nice article.
    THanks

  • Minato
    MinatoNov 24, 2024

    Great post.
    Thx

  • White Dream
    White DreamNov 24, 2024

    Great post
    Thanks

  • Creative
    CreativeDec 3, 2024

    Greate help.
    Look forward your next article.
    Thank you.

  • Shiro Ennosuke
    Shiro EnnosukeDec 16, 2024

    Thanks for sharing great article

  • Pavlo Posternak
    Pavlo PosternakDec 16, 2024

    I guess its great article for not only begineers, but also professionals.

Add comment