Cover photo

ERC20 Test

https://hardhat.org/tutorial/writing-and-compiling-contracts

用solidity写一个简单的合约,用hardhat完成测试的过程,以及一些小的知识点的说明

  1. ERC-20合约介绍

其核心作用,确保代币在钱包,交易所,dapp之间的胡操作性

必须实现六个核心的函数

  • totalSupply() 返回代币总供应量

  • balanceOf(address) 查询制定地址余额

  • transfer(address, uint256) 从调用者地址转账到目标地址

  • allowance(address, address) 查询授权额度

  • approve(address, uint256) 授权第三方地址使用一定数量的代币

  • transferFrom(address, address, uint256) 被授权者从所有者地址转账代币

一个简单的合约代码

//SPDX-License-Identifier: UNLICENSED

// Solidity files have to start with this pragma.
// It will be used by the Solidity compiler to validate its version.
pragma solidity ^0.8.0;

import "hardhat/console.sol";

// This is the main building block for smart contracts.
contract Token {
    // Some string type variables to identify the token.
    string public name = "My Hardhat Token";
    string public symbol = "MHT";
    uint8 public decimals = 18;

    // The fixed amount of tokens, stored in an unsigned integer type variable.
    uint256 public totalSupply = 1000000 * 10**18; // 1,000,000 tokens with 18 decimals

    // An address type variable is used to store ethereum accounts.
    address public owner;

    // A mapping is a key/value map. Here we store each account's balance.
    mapping(address => uint256) balances;

    // A mapping to store allowances for the approve/transferFrom pattern
    mapping(address => mapping(address => uint256)) allowances;

    // The Transfer event helps off-chain applications understand
    // what happens within your contract.
    event Transfer(address indexed _from, address indexed _to, uint256 _value);
    
    // The Approval event is emitted when approve is called
    event Approval(address indexed _owner, address indexed _spender, uint256 _value);

    /**
     * Contract initialization.
     */
    constructor() {
        // The totalSupply is assigned to the transaction sender, which is the
        // account that is deploying the contract.
        balances[msg.sender] = totalSupply;
        owner = msg.sender;
        
        // Emit transfer event for the initial allocation
        emit Transfer(address(0), msg.sender, totalSupply);
    }

    /**
     * A function to transfer tokens.
     *
     * The `external` modifier makes a function *only* callable from *outside*
     * the contract.
     */
    function transfer(address to, uint256 amount) external returns (bool) {
        // Check if the transaction sender has enough tokens.
        // If `require`'s first argument evaluates to `false`, the
        // transaction will revert.
        require(balances[msg.sender] >= amount, "Not enough tokens");
        require(to != address(0), "Cannot transfer to zero address");

        console.log("Transferring %s tokens from %s to %s", amount, msg.sender, to);

        // Transfer the amount.
        balances[msg.sender] -= amount;
        balances[to] += amount;

        // Notify off-chain applications of the transfer.
        emit Transfer(msg.sender, to, amount);
        
        return true;
    }

    /**
     * Read only function to retrieve the token balance of a given account.
     *
     * The `view` modifier indicates that it doesn't modify the contract's
     * state, which allows us to call it without executing a transaction.
     */
    function balanceOf(address account) external view returns (uint256) {
        return balances[account];
    }

    /**
     * Approve function to allow spender to spend tokens on behalf of owner
     */
    function approve(address spender, uint256 amount) external returns (bool) {
        require(spender != address(0), "Cannot approve zero address");
        
        allowances[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        
        return true;
    }

    /**
     * TransferFrom function to transfer tokens from one address to another
     * using the allowance mechanism
     */
    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        require(balances[from] >= amount, "Not enough tokens");
        require(allowances[from][msg.sender] >= amount, "Insufficient allowance");
        require(to != address(0), "Cannot transfer to zero address");

        balances[from] -= amount;
        balances[to] += amount;
        allowances[from][msg.sender] -= amount;

        emit Transfer(from, to, amount);
        
        return true;
    }

    /**
     * Get the allowance for a spender
     */
    function allowance(address tokenOwner, address spender) external view returns (uint256) {
        return allowances[tokenOwner][spender];
    }
}

关于event的说明

  • 链下应用集成

钱包,交易所,dapp等监听转账事件,更新钱包余额

  • 审计和追踪

    交易记录授权记录

  1. Hardhat实现测试合约

测试点

  1. 合约部署测试

    ✅ 验证总供应量正确分配给部署者

  2. 基础转账功能测试

    ✅ 成功转账代币

    ✅ 多账户间转账

    ✅ Transfer 事件触发

    ✅ 余额不足时失败

    ✅ 向零地址转账失败

  3. 授权和委托转账功能测试

    ✅ 成功授权代币

    ✅ Approval 事件触发

    ✅ 向零地址授权失败

    ✅ 使用 transferFrom 委托转账

    ✅ 授权额度不足时失败

    ✅ 余额不足时 transferFrom 失败

    ✅ transferFrom 向零地址失败

    ✅ transferFrom 触发 Transfer 事件

  4. 授权额度管理测试

    ✅ 正确返回授权额度

    ✅ transferFrom 后授权额度减少

    ✅ 多次授权覆盖之前额度

  5. 边界条件测试(新增)

    ✅ 转账 0 个代币

    ✅ 授权 0 个代币

    ✅ 转账全部余额

import { expect } from "chai";
import { ethers } from "hardhat";
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";

describe("Token 合约测试", function () {
  // 部署合约的通用函数
  async function deployTokenFixture() {
    const [owner, addr1, addr2] = await ethers.getSigners();
    const token = await ethers.deployContract("Token");
    await token.waitForDeployment();
    return { token, owner, addr1, addr2 };
  }

  // ==================== 部署测试 ====================
  describe("合约部署", function () {
    it("应该将总供应量分配给部署者", async function () {
      const { token, owner } = await loadFixture(deployTokenFixture);
      const ownerBalance = await token.balanceOf(owner.address);
      expect(await token.totalSupply()).to.equal(ownerBalance);
    });
  });

  // ==================== 基础转账功能测试 ====================
  describe("基础转账功能", function () {
    it("应该能够成功转账代币", async function () {
      const { token, owner, addr1 } = await loadFixture(deployTokenFixture);
      
      // 执行转账
      await token.transfer(addr1.address, 100);
      
      // 验证转账结果
      expect(await token.balanceOf(addr1.address)).to.equal(100n);
      expect(await token.balanceOf(owner.address)).to.equal((await token.totalSupply()) - 100n);
    });
    
    it("应该能够进行多账户间的转账", async function () {
      const { token, owner, addr1, addr2 } = await loadFixture(deployTokenFixture);
      
      // 第一步:owner 转给 addr1
      await token.transfer(addr1.address, 100);
      
      // 第二步:addr1 转给 addr2
      await token.connect(addr1).transfer(addr2.address, 50);
      
      // 验证最终余额
      expect(await token.balanceOf(addr1.address)).to.equal(50n);
      expect(await token.balanceOf(addr2.address)).to.equal(50n);
      expect(await token.balanceOf(owner.address)).to.equal((await token.totalSupply()) - 100n);
    });
    
    it("应该触发 Transfer 事件", async function () {
      const { token, owner, addr1 } = await loadFixture(deployTokenFixture);
      
      // 验证转账事件
      await expect(token.transfer(addr1.address, 100))
        .to.emit(token, "Transfer")
        .withArgs(owner.address, addr1.address, 100n);
    });
    
    it("当余额不足时应该失败", async function () {
      const { token, owner, addr1 } = await loadFixture(deployTokenFixture);
      const initialOwnerBalance = await token.balanceOf(owner.address);
      
      // 尝试转出超过余额的代币
      await expect(token.connect(addr1).transfer(owner.address, 1))
        .to.be.revertedWith("Not enough tokens");
      
      // 验证余额没有变化
      expect(await token.balanceOf(owner.address)).to.equal(initialOwnerBalance);
      expect(await token.balanceOf(addr1.address)).to.equal(0n);
    });
    
    it("向零地址转账应该失败", async function () {
      const { token, owner } = await loadFixture(deployTokenFixture);
      
      // 尝试向零地址转账
      await expect(token.transfer("0x0000000000000000000000000000000000000000", 100))
        .to.be.revertedWith("Cannot transfer to zero address");
    });
  });

  // ==================== 授权和委托转账功能测试 ====================
  describe("授权和委托转账功能", function () {
    it("应该能够成功授权代币", async function () {
      const { token, owner, addr1 } = await loadFixture(deployTokenFixture);
      
      // 执行授权
      await token.approve(addr1.address, 100);
      
      // 验证授权额度
      expect(await token.allowance(owner.address, addr1.address)).to.equal(100n);
    });

    it("应该触发 Approval 事件", async function () {
      const { token, owner, addr1 } = await loadFixture(deployTokenFixture);
      
      // 验证授权事件
      await expect(token.approve(addr1.address, 100))
        .to.emit(token, "Approval")
        .withArgs(owner.address, addr1.address, 100n);
    });

    it("向零地址授权应该失败", async function () {
      const { token, owner } = await loadFixture(deployTokenFixture);
      
      // 尝试向零地址授权
      await expect(token.approve("0x0000000000000000000000000000000000000000", 100))
        .to.be.revertedWith("Cannot approve zero address");
    });

    it("应该能够使用 transferFrom 进行委托转账", async function () {
      const { token, owner, addr1, addr2 } = await loadFixture(deployTokenFixture);
      
      // 第一步:owner 授权给 addr1
      await token.approve(addr1.address, 100);
      
      // 第二步:addr1 使用 transferFrom 转出 owner 的代币
      await token.connect(addr1).transferFrom(owner.address, addr2.address, 50);
      
      // 验证转账结果
      expect(await token.balanceOf(addr2.address)).to.equal(50n);
      expect(await token.balanceOf(owner.address)).to.equal((await token.totalSupply()) - 50n);
      
      // 验证授权额度减少
      expect(await token.allowance(owner.address, addr1.address)).to.equal(50n);
    });

    it("当授权额度不足时 transferFrom 应该失败", async function () {
      const { token, owner, addr1, addr2 } = await loadFixture(deployTokenFixture);
      
      // 授权额度不足
      await token.approve(addr1.address, 50);
      
      // 尝试转出超过授权额度的代币
      await expect(token.connect(addr1).transferFrom(owner.address, addr2.address, 100))
        .to.be.revertedWith("Insufficient allowance");
    });

    it("当余额不足时 transferFrom 应该失败", async function () {
      const { token, owner, addr1, addr2 } = await loadFixture(deployTokenFixture);
      
      // 给 addr1 一些代币
      await token.transfer(addr1.address, 50);
      
      // addr1 授权给 addr2
      await token.connect(addr1).approve(addr2.address, 100);
      
      // addr2 尝试转出超过 addr1 余额的代币
      await expect(token.connect(addr2).transferFrom(addr1.address, owner.address, 100))
        .to.be.revertedWith("Not enough tokens");
    });

    it("transferFrom 向零地址转账应该失败", async function () {
      const { token, owner, addr1 } = await loadFixture(deployTokenFixture);
      
      // 先授权
      await token.approve(addr1.address, 100);
      
      // 尝试向零地址转账
      await expect(token.connect(addr1).transferFrom(owner.address, "0x0000000000000000000000000000000000000000", 50))
        .to.be.revertedWith("Cannot transfer to zero address");
    });

    it("transferFrom 应该触发 Transfer 事件", async function () {
      const { token, owner, addr1, addr2 } = await loadFixture(deployTokenFixture);
      
      // 先授权
      await token.approve(addr1.address, 100);
      
      // 验证 transferFrom 事件
      await expect(token.connect(addr1).transferFrom(owner.address, addr2.address, 50))
        .to.emit(token, "Transfer")
        .withArgs(owner.address, addr2.address, 50n);
    });
  });

  // ==================== 授权额度管理测试 ====================
  describe("授权额度管理", function () {
    it("应该正确返回授权额度", async function () {
      const { token, owner, addr1 } = await loadFixture(deployTokenFixture);
      
      // 初始授权额度应该为 0
      expect(await token.allowance(owner.address, addr1.address)).to.equal(0n);
      
      // 授权后额度应该更新
      await token.approve(addr1.address, 100);
      expect(await token.allowance(owner.address, addr1.address)).to.equal(100n);
    });

    it("transferFrom 后授权额度应该正确减少", async function () {
      const { token, owner, addr1, addr2 } = await loadFixture(deployTokenFixture);
      
      // 初始授权
      await token.approve(addr1.address, 100);
      expect(await token.allowance(owner.address, addr1.address)).to.equal(100n);
      
      // 使用部分授权额度
      await token.connect(addr1).transferFrom(owner.address, addr2.address, 30);
      
      // 验证授权额度减少
      expect(await token.allowance(owner.address, addr1.address)).to.equal(70n);
    });

    it("多次授权应该覆盖之前的授权额度", async function () {
      const { token, owner, addr1 } = await loadFixture(deployTokenFixture);
      
      // 第一次授权
      await token.approve(addr1.address, 100);
      expect(await token.allowance(owner.address, addr1.address)).to.equal(100n);
      
      // 第二次授权(覆盖)
      await token.approve(addr1.address, 200);
      expect(await token.allowance(owner.address, addr1.address)).to.equal(200n);
    });
  });

  // ==================== 边界条件测试 ====================
  describe("边界条件测试", function () {
    it("应该能够转账 0 个代币", async function () {
      const { token, owner, addr1 } = await loadFixture(deployTokenFixture);
      const initialBalance = await token.balanceOf(addr1.address);
      
      // 转账 0 个代币
      await token.transfer(addr1.address, 0);
      
      // 余额应该不变
      expect(await token.balanceOf(addr1.address)).to.equal(initialBalance);
    });

    it("应该能够授权 0 个代币", async function () {
      const { token, owner, addr1 } = await loadFixture(deployTokenFixture);
      
      // 授权 0 个代币
      await token.approve(addr1.address, 0);
      
      // 授权额度应该为 0
      expect(await token.allowance(owner.address, addr1.address)).to.equal(0n);
    });

    it("应该能够转账全部余额", async function () {
      const { token, owner, addr1 } = await loadFixture(deployTokenFixture);
      const totalSupply = await token.totalSupply();
      
      // 转账全部余额
      await token.transfer(addr1.address, totalSupply);
      
      // 验证转账结果
      expect(await token.balanceOf(addr1.address)).to.equal(totalSupply);
      expect(await token.balanceOf(owner.address)).to.equal(0n);
    });
  });
});