DEV Community

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

Posted on

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

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.

Top comments (15)

Collapse
 
ichikawa0822 profile image
Ichikawa Hiroshi

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.

Collapse
 
dodger213 profile image
Mitsuru Kudo

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

Collapse
 
btc415 profile image
LovelyBTC

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

Collapse
 
robert_angelo_484 profile image
Robert Angelo

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

Collapse
 
btc415 profile image
LovelyBTC

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

Collapse
 
steven-dev profile image
Steven

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

Collapse
 
btc415 profile image
LovelyBTC

Okay, thanks.
Will wait for your next article.

Collapse
 
sebastian_robinson_884ddc profile image
Sebastian Robinson

Thanks Steven.
Nice article

Collapse
 
arlo_oscar_d8a2de736e7c73 profile image
Arlo Oscar

Looks amazing

Collapse
 
jacksonmoridev0507 profile image
Jackson Mori

Thanks for your article.
Looks nice like always.

Collapse
 
oleksii_kasian_edf7403f06 profile image
Oleksii Kasian

Great. Thanks

Collapse
 
reonel_ronaldo_207e34fcfb profile image
Reonel Ronaldo

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