ERC-20:用 Solidity 和 Foundry 编写第一个 Token

1. 概述

ERC-20 是以太坊上最流行的 Token 标准。USDC、DAI、LINK 和数千个其他 Token 都遵循它。在本文中,你将自己编写一个最小化的 ERC-20 Token 来理解标准的工作原理,然后使用 Foundry 编写测试。接下来,你将转向 OpenZeppelin 的实现以用于生产环境,并将 Token 部署到本地区块链。

2. 什么是 ERC-20?

ERC-20 是一个 接口,即每个 Token 都必须具有的一组函数和事件。由于这个通用接口,钱包、交易所和 DeFi 合约可以以相同的方式与任何 Token 交互,而不需要了解 Token 的内部实现。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IERC20 {
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
}

这个接口可以分成两个组:

  • 直接转账: transfer 将 Token 从调用者(msg.sender)转账到 to。
  • 授权: approve 允许一个 spender 最多支出你的 value 个 Token。之后 spender 调用 transferFrom 来转出。这是 DEX 交易所在你交换时从你的钱包“提取” Token 的机制。

name()、symbol() 和 decimals() 三个函数属于 metadata 扩展。理论上它们不是强制的,但几乎每个 Token 都有。

3. 使用 Foundry 准备环境

本文使用 Foundry:你可以用 Solidity 编写合约和测试,并且测试运行非常快。安装 Foundry 后,创建项目:

forge init cafe-token
cd cafe-token
forge install OpenZeppelin/openzeppelin-contracts

forge install 命令将 OpenZeppelin 库下载到 lib/ 目录。你需要声明 remapping 来使导入更简洁。将以下行添加到项目根目录的 remappings.txt 文件:

@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/

项目结构包括 src/ 用于合约、test/ 用于测试、script/ 用于部署脚本。你可以删除 forge init 创建的示例 Counter 文件。

4. 自己编写一个最小的 ERC-20

本部分的目的是 理解 ERC-20 标准,而不是用于实际应用。创建文件 src/SimpleToken.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract SimpleToken {
    string public name = "Cafe Token";
    string public symbol = "CAFE";
    uint8 public constant decimals = 18;
    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(uint256 initialSupply) {
        totalSupply = initialSupply;
        balanceOf[msg.sender] = initialSupply;
        emit Transfer(address(0), msg.sender, initialSupply);
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        _transfer(msg.sender, to, amount);
        return true;
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        uint256 allowed = allowance[from][msg.sender];
        require(allowed >= amount, "授权额度不足");
        allowance[from][msg.sender] = allowed - amount;
        _transfer(from, to, amount);
        return true;
    }

    function _transfer(address from, address to, uint256 amount) internal {
        require(to != address(0), "零地址");
        require(balanceOf[from] >= amount, "余额不足");
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        emit Transfer(from, to, amount);
    }
}

值得注意的几点:

  • public 状态变量自动生成 getter。 声明 mapping(...) public balanceOf 会生成 balanceOf(address) 函数,其签名与接口中的完全匹配。allowance 也是如此。
  • decimals = 18。 区块链没有小数点数据类型,所以 Token 将所有余额以整数形式存储。当钱包显示“1.5 CAFE”时,合约中的实际值是 1.5 * 10^18。
  • Constructor 从 address(0) 发出 Transfer 事件。 这是表示“Token 被创建”(mint)的约定。各个区块浏览器根据这个事件来显示交易历史。
  • transferFrom 在转账前减少授权额度。 如果不这样做,spender 可以无限次地提取。
  • 不需要 SafeMath。 从 Solidity 0.8 开始,-= 操作在发生下溢时会自动回滚。不过,带有清晰错误消息的 require 仍然更有助于调试。

5. 使用 Foundry 编写测试

Foundry 中的测试是继承 Test 的合约。每个以 test 开头的函数都是一个测试用例。创建文件 test/SimpleToken.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {SimpleToken} from "../src/SimpleToken.sol";

contract SimpleTokenTest is Test {
    event Transfer(address indexed from, address indexed to, uint256 value);

    SimpleToken token;
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    function setUp() public {
        token = new SimpleToken(1_000 ether);
    }

    function test_transfer_movesBalance() public {
        token.transfer(alice, 100 ether);

        assertEq(token.balanceOf(alice), 100 ether);
        assertEq(token.balanceOf(address(this)), 900 ether);
    }

    function test_transfer_emitsEvent() public {
        vm.expectEmit(true, true, false, true);
        emit Transfer(address(this), alice, 1 ether);

        token.transfer(alice, 1 ether);
    }

    function test_transferFrom_requiresAllowance() public {
        token.transfer(alice, 100 ether);

        vm.prank(alice);
        token.approve(bob, 30 ether);

        vm.prank(bob);
        token.transferFrom(alice, bob, 30 ether);

        assertEq(token.balanceOf(bob), 30 ether);
        assertEq(token.allowance(alice, bob), 0);

        vm.prank(bob);
        vm.expectRevert(bytes("授权额度不足"));
        token.transferFrom(alice, bob, 1);
    }
}

各部分解释:

  • setUp() 在 每个 测试前重新运行。测试合约是 Token 的部署者,所以 address(this) 拥有全部 1000 个 Token。
  • 1_000 ether 只是 1000 * 10^18 的简写。这里的 ether 关键字只是乘以 1e18,与真实的 ETH 无关。
  • makeAddr("alice") 创建一个带标签的地址,这样测试失败时日志更易读。
  • vm.prank(alice) 使 下一个 调用的 msg.sender 为 alice。
  • vm.expectEmit(true, true, false, true) 检查随后发出的事件中的 topic 1(from)、topic 2(to)和数据部分(value)。
  • vm.expectRevert 断言下一个调用必须以指定的错误消息回滚。

运行测试:

forge test -vv
Ran 3 tests for test/SimpleToken.t.sol:SimpleTokenTest
[PASS] test_transferFrom_requiresAllowance() (gas: 79120)
[PASS] test_transfer_emitsEvent() (gas: 41871)
[PASS] test_transfer_movesBalance() (gas: 43552)
Suite result: ok. 3 passed; 0 failed; 0 skipped

根据编译器版本,你机器上的 gas 值可能略有不同。

6. 使用 OpenZeppelin 实现真实 Token

上面自己写的版本虽然能运行,但未经审计。对于生产环境的 Token,你应该继承 OpenZeppelin 的 ERC20。这个库已在数十亿美元的流通价值上得到验证。创建文件 src/CafeToken.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract CafeToken is ERC20, Ownable {
    constructor(address initialOwner)
        ERC20("Cafe Token", "CAFE")
        Ownable(initialOwner)
    {
        _mint(initialOwner, 1_000_000 * 10 ** decimals());
    }

    function mint(address to, uint256 amount) external onlyOwner {
        _mint(to, amount);
    }
}

仅用大约十行代码,你就拥有了一个完整的 Token:

  • ERC20("Cafe Token", "CAFE") 设置 name 和 symbol。默认情况下 decimals() 返回 18。
  • _mint 增加 totalSupply,将余额添加到接收者,并从 address(0) 发出 Transfer 事件。
  • Ownable(initialOwner)(OpenZeppelin v5)要求显式传递 owner。onlyOwner 修饰符阻止其他人调用 mint。

OpenZeppelin v5 使用 自定义错误 代替 revert 字符串。因此测试也必须按错误选择器进行检查。创建文件 test/CafeToken.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";
import {CafeToken} from "../src/CafeToken.sol";
import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract CafeTokenTest is Test {
    CafeToken token;
    address owner = makeAddr("owner");
    address alice = makeAddr("alice");

    function setUp() public {
        token = new CafeToken(owner);
    }

    function test_initialSupply_goesToOwner() public view {
        assertEq(token.totalSupply(), 1_000_000e18);
        assertEq(token.balanceOf(owner), 1_000_000e18);
    }

    function test_transfer_revertsWhenBalanceTooLow() public {
        vm.prank(alice);
        vm.expectRevert(
            abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 0, 1)
        );
        token.transfer(owner, 1);
    }

    function test_mint_onlyOwner() public {
        vm.prank(alice);
        vm.expectRevert(
            abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)
        );
        token.mint(alice, 1e18);
    }
}

当转账回滚时,ERC20InsufficientBalance(sender, balance, needed) 告诉你确切地谁缺少余额以及缺少多少。这些信息比模糊的错误字符串有用得多。

7. 部署到本地网络并交互

Foundry 内置 anvil,一个运行本地 Ethereum 节点的工具,具有 10 个预加载 ETH 的账户。打开另一个终端并运行:

anvil

在另一个终端中,使用 anvil 的第一个账户部署 Token。这个私钥是公开的,仅用于本地网络:

export PK=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
export OWNER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

forge create src/CafeToken.sol:CafeToken \
  --rpc-url http://127.0.0.1:8545 \
  --private-key $PK \
  --broadcast \
  --constructor-args $OWNER
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Transaction hash: 0x...

使用 cast 来读取余额和转账 Token:

export TOKEN=0x5FbDB2315678afecb367f032d93F642f64180aa3

cast call $TOKEN "balanceOf(address)(uint256)" $OWNER --rpc-url http://127.0.0.1:8545

cast send $TOKEN "transfer(address,uint256)" \
  0x70997970C51812dc3A010C7d01b50e0d17dc79C8 50ether \
  --private-key $PK --rpc-url http://127.0.0.1:8545
1000000000000000000000000 [1e24]

cast call 只读取数据,不消耗 gas。cast send 在 anvil 上创建真实交易。参数 50ether 被 cast 自动转换为 50 * 10^18。

8. 常见错误和注意事项

注意: 不要自己为生产环境编写 ERC-20。第 4 部分的版本仅用于学习。对于真实 Token,继承 OpenZeppelin 并仅添加你的自定义逻辑。

  • 混淆 decimals。 调用 transfer(to, 100) 只转账 100 * 10^-18 个 Token,几乎为零。始终乘以 10 ** decimals()。相反,USDC 使用 6 decimals 而不是 18,所以在与他人的 Token 交互时不要硬编码 1e18。
  • approve 的竞态条件。 假设你将授权从 100 降低到 50。恶意的 spender 可以监视内存池,在新命令被确认前提取 100,然后再提取 50。安全的做法是先将授权设为 0,或使用支持 permit 的 Token(ERC-2612)。
  • 不符合标准的 Token。 一些旧 Token 如 USDT 不从 transfer 返回 bool。当你的合约调用外部 Token 时,使用 SafeERC20 库(using SafeERC20 for IERC20; 然后调用 safeTransfer)而不是直接调用 transfer。
  • 转账到合约的 Token 被卡住。 ERC-20 没有通知接收方的机制。如果有人向一个没有提取函数的合约发送 Token,这些 Token 将永久锁定。
  • Fee-on-transfer 和 rebasing Token。 有些 Token 接收的数量少于发送的数量,或者余额随时间自动变化。如果你的合约接收任意 Token,请在转账前后测量余额,而不是相信 amount 参数。
  • 无限制的 mint。 带有 onlyOwner 的 mint 函数意味着 owner 可以无限地创建 Token。如果你想要固定总供应量,删除此函数或设置上限,例如使用 ERC20Capped。

9. 结论

在本文中,你了解了 ERC-20 接口,并自己实现了一个最小化的 Token,以清晰地看到 balanceOf、allowance、transfer 和 transferFrom 如何协同工作。你还使用 Foundry 编写了测试,包括 vm.prank、vm.expectEmit 和 vm.expectRevert。随后,你转向基于 OpenZeppelin 的 CafeToken,部署到 anvil,并通过 cast 进行交互。

合理的下一步是尝试扩展功能,如 ERC20Permit 用于签名授权、ERC20Burnable 用于销毁 Token,或 ERC20Capped 用于限制总供应。你还应该练习使用 SafeERC20 安全地编写接收 Token 的合约。

solidityerc-20ethereumfoundrysmart-contract