发布于 2026-01-05 0 阅读
0

使用 Hardhat 和 Chai 对 ERC20 代币预售智能合约进行全面测试

使用 Hardhat 和 Chai 对 ERC20 代币预售智能合约进行全面测试

介绍

本文将介绍如何使用 Hardhat 和 Chai 在以太坊 Sepolia 测试网上全面测试SPX ERC20 代币预售智能合约。我们将涵盖所有关键功能的测试,包括使用多种货币购买代币、认领、退款和提现功能。

先决条件

  • Hardhat 开发环境
  • Chai断言库
  • 以太坊华夫饼用于区块链测试
  • 对 TypeScript 测试有基本的了解

逐步测试预售智能合约

测试结构

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

重要关键测试用例

预售准备

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

代币购买功能测试

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

此测试套件“使用 USDT 购买 SPX”包含四个测试用例,用于验证不同的场景:

测试资金不足:

  • 测试购买 20,000,000 个 SPX 代币(价值 1600 USDT),投资者拥有 1000 USDT 的额度。
  • 预计交易将因“授权不足”错误而失败。
  • 核实津贴检查是否正常工作

测试代币购买是否成功:

  • 测试购买 1,500,000 个 SPX 代币(价值 120 USDT)
  • 交易前的记录状态:
    • 对两位投资者而言,USDT投资都是必要的。
    • 筹款总额
    • 代币余额
    • 可用代币
  • 为两位投资者执行购买操作
  • 交易后进行验证:
    • 代币余额已正确增加。
    • 可用代币数量已相应减少
    • 准确记录的投资金额
    • 筹款金额已正确更新

测试预售时间限制(开始前):

  • 在预售开始时间之前尝试购买
  • 预期会返回“时间无效”消息
  • 验证时序限制是否有效

测试预售时间限制(结束后):

  • 预售结束后尝试购买
  • 预期会返回“时间无效”消息
  • 核实预售结束时间执行情况

此测试套件确保 USDT 购买功能在所有预期情况下都能正常工作。
同样,我们也可以编写 USDC、DAI 和 ETH 购买功能的测试套件。

声明功能测试

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

此认领测试套件通过五个关键测试用例验证代币认领过程:

初始设置:

  • 在运行测试之前设置索赔时间
  • 使用所有者帐户设置索赔时间

早期索赔预防:

  • 测试声称在适当时间之前进行
  • 首先用 USDT 购买代币
  • 验证索赔尝试因计时错误而失败

早期投资者奖励核实:

  • 检查两位投资者的早期投资者身份
  • 确认两者均被标记为早期投资者
  • 确保奖金发放资格

代币领取流程:

  • 记录初始代币余额
  • 获得预期代币数量和奖励
  • 执行索赔交易
  • 核实:
    • 最终余额与预期金额相符
    • 代币余额重置为零
    • 第二次索赔尝试失败

代币领取流程:

  • 测试未经授权的索赔时间设置
  • 只有所有者才能设置索赔时间
  • 检查自定义错误处理

该测试套件确保索赔机制在各种条件下都能正确、安全地运行。

撤回功能测试

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

此提现测试套件通过五个关键测试用例验证代币领取流程:

初始设置:

  • 测试前设置提现钱包地址
  • 使用所有者帐户配置钱包

所有者撤回测试:

  • 记录钱包中 USDT、USDC 和 DAI 的初始余额
  • 获取所有代币的合约余额
  • 执行提款
  • 核实最终余额与预期金额是否一致:
    • USDT余额已正确增加
    • USDC余额已正确增加
    • DAI余额已正确增加

防止未经授权的提款:

  • 使用非账户进行测试提款
  • 验证是否抛出自定义错误“NotOwner”。

钱包设置访问控制:

  • 测试未经授权的钱包地址设置
  • 验证只有所有者才能设置钱包地址
  • 检查自定义错误处理

提前戒断预防:

  • 测试在预售结束前撤回
  • 验证时序限制消息
  • 确保预售期间资金被锁定

该测试套件确保提款机制在各种条件下都能正确、安全地运行。

退款功能测试

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

此退款测试套件通过三个关键测试用例验证代币退款流程:

业主退款测试:

  • 记录所有投资者的 USDT、USDC 和 DAI 初始余额
  • 获取每位投资者和代币的投资金额。
  • 设置所有代币和投资者的审批流程
  • 执行退款交易
  • 核实最终余额:
    • USDT余额已正确返还。
    • USDC余额已正确退回
    • DAI余额已正确返回。
  • 确保每位投资者都能收回全部投资款项。

防止未经授权的退款:

  • 使用非所有者账户进行测试可获得退款。
  • 验证是否抛出自定义错误“NotOwner”。
  • 确保只有所有者才能发起退款。

提前退款预防:

  • 预售结束前可退款
  • 验证时序限制消息
  • 确保在预售期间不会发生退款。

这确保了在各种情况下,所有投资者和代币的退款机制都能正确、安全地运行。

最佳实践

  • 在测试设置之前使用钩子
  • 测试成功和失败两种情况
  • 验证事件和状态变化
  • 测试访问控制
  • 使用辅助函数进行常见操作
  • 保持测试隔离

结论

对于涉及金额巨大的预售合同而言,全面的测试至关重要。这种测试方法能够确保合同在任何情况下都能正确运行,同时保障所有参与者的安全性和公平性。

文章来源:https://dev.to/imcrazysteven/thorough-testing-for-erc20-token-presale-smart-contract-using-hardhat-and-chai-3i4g