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
})
})
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);
});
});
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.");
});
});
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");
});
});
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.");
})
})
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.");
})
});
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 (18)
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.
Thank you so much for the helpful information!
Highly recommended.
Thanks again
Really impressive.
Thank you for sharing.
Looking forward to your next article.
Thanks again
Highly recommend this article.
It contains comprehensive test cases for ERC20 token presale smart contract.
Thanks for your effort
Can we test the smart contract after deploying the sepolia testnet and test on Etherscan?
Yes, of course,
I will show you in more detail in my next article.
Okay, thanks.
Will wait for your next article.
Thanks for your article.
Thanks Steven.
Nice article
Thanks for your article.
Looks nice like always.
Looks amazing
Thank you Steven