Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ERC-20:可互换代币接口与安全交互

ERC-20 是一组应用层接口约定,不是代币合约的完整实现。只要合约按照约定提供余额、转账、授权和事件,钱包、区块浏览器和 DeFi 协议就能用统一方式与它交互。标准本身不规定发行、销毁、权限、暂停或升级策略,这些功能都属于具体实现。

1. 核心状态与接口

最小实现通常维护余额、授权额度和总供应量三个状态:

mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
uint256 private _totalSupply;

必需函数和事件如下:

接口作用关键语义
totalSupply()查询总供应量应与所有账户余额之和一致
balanceOf(account)查询余额不改变状态
transfer(to, amount)发送者转账成功时必须触发 Transfer
approve(spender, amount)设置授权额度覆盖旧额度,不是累加
allowance(owner, spender)查询授权额度transferFrom 的上限
transferFrom(owner, to, amount)代扣转账消耗有限额度并触发 Transfer;部分实现对最大值额度不递减
Transfer(from, to, amount)记录转账及实现采用的发行变化铸造通常使用零地址作为 from,销毁常用零地址作为 to;标准不规定销毁接口
Approval(owner, spender, amount)approve 成功时记录设置的额度transferFrom 是否另发授权变化事件取决于实现,不能只靠此事件重建剩余额度

name、symbol 和 decimals 是常见元数据接口。它们有助于展示,但不是所有 ERC-20 实现都必须提供。decimals 只影响展示换算,不改变链上整数余额;例如 decimals = 6 的 1 USDC 在链上表示为 1_000_000。

2. 转账与授权的安全边界

approve 使用的是“覆盖”语义。用户把额度从旧值改成新值时,恶意 spender 可能在两笔交易之间抢先使用旧额度,形成经典的 allowance race。前端通常采用“先设置 0,再设置新额度”,但清零交易确认前,旧额度仍可被使用。增减授权接口不是 ERC-20 的必需接口;具体风险与兼容性见授权竞争。

授权本质上是把资产支配权交给另一个地址。用户应核对 spender 地址、额度及其业务权限;标准 ERC-20 allowance 不自带有效期,无限授权会扩大钓鱼网站或协议漏洞的损失范围。协议实现也不应假设所有代币都严格遵守返回 bool 的习惯:历史上存在不返回值、返回非标准值或转账收取手续费的代币。

安全的代币交互通常使用 OpenZeppelin 的 SafeERC20,它会兼容“不返回值但成功”的旧代币,并在失败时回滚。使用原始低级调用时,必须同时检查调用是否成功和返回数据是否表示成功。

3. 一个可运行的最小实现

简化实现包含余额、授权、铸造和销毁。实际代币还需要明确铸造权限、暂停机制和升级策略;接口兼容性不等于这些业务约束已经得到验证。

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

contract ExampleToken {
    string public name = "Example Token";
    string public symbol = "EXT";
    uint8 public immutable decimals = 18;
    uint256 public totalSupply;
    address public immutable owner;

    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);

    modifier onlyOwner() {
        require(msg.sender == owner, "not owner");
        _;
    }

    constructor(uint256 initialSupply) {
        owner = msg.sender;
        _mint(msg.sender, initialSupply);
    }

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

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

    function transferFrom(address from, address to, uint256 value) external returns (bool) {
        uint256 current = allowance[from][msg.sender];
        require(current >= value, "allowance exceeded");
        unchecked {
            allowance[from][msg.sender] = current - value;
        }
        emit Approval(from, msg.sender, allowance[from][msg.sender]);
        _transfer(from, to, value);
        return true;
    }

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

    function burn(uint256 value) external {
        require(balanceOf[msg.sender] >= value, "balance exceeded");
        unchecked {
            balanceOf[msg.sender] -= value;
        }
        totalSupply -= value;
        emit Transfer(msg.sender, address(0), value);
    }

    function _mint(address to, uint256 value) internal {
        require(to != address(0), "mint to zero");
        totalSupply += value;
        balanceOf[to] += value;
        emit Transfer(address(0), to, value);
    }

    function _transfer(address from, address to, uint256 value) internal {
        require(from != address(0) && to != address(0), "zero address");
        require(balanceOf[from] >= value, "balance exceeded");
        unchecked {
            balanceOf[from] -= value;
        }
        balanceOf[to] += value;
        emit Transfer(from, to, value);
    }
}

这里的 unchecked 只出现在已经完成边界检查的减法上;加法仍保留 Solidity 0.8 的溢出检查。真实项目还需要测试零值转账、授权变化、铸造销毁事件、权限转移和总供应量不变量。

4. 使用 ethers.js 读取余额

ethers v6 使用 Contract 实例直接调用 ABI。读取操作不需要发送交易,也不需要钱包签名:

import { Contract, JsonRpcProvider, Wallet, formatUnits, parseUnits } from "ethers";

const provider = new JsonRpcProvider(process.env.RPC_URL);
const token = new Contract(
  process.env.TOKEN_ADDRESS,
  ["function balanceOf(address) view returns (uint256)",
   "function decimals() view returns (uint8)",
   "function approve(address,uint256) returns (bool)"],
  provider,
);

const raw = await token.balanceOf(process.env.USER_ADDRESS);
const decimals = await token.decimals();
console.log(formatUnits(raw, decimals));

发送 approve 则需要连接签名者,并等待交易确认:

const signer = new Wallet(process.env.PRIVATE_KEY, provider);
const writable = token.connect(signer);
const amount = parseUnits("1", Number(decimals)); // 1 个代币,按真实 decimals 换算
const tx = await writable.approve(process.env.SPENDER_ADDRESS, amount);
const receipt = await tx.wait();
if (!receipt || receipt.status !== 1) throw new Error("交易未成功确认");

该示例需要 ethers v6,并设置 RPC_URL、TOKEN_ADDRESS、USER_ADDRESS、SPENDER_ADDRESS 和 PRIVATE_KEY。Signer 的私钥仅在本地读取;不要输出私钥。普通公共 RPC 不管理用户私钥,不能直接依靠 provider.getSigner() 获得签名账户。

金额应始终使用整数和 parseUnits/formatUnits 转换,避免用 JavaScript 浮点数表示代币数量。

5. 用 Foundry 验证转账、授权和销毁

完整工程在 examples/ethereum-signatures,固定 Solidity 0.8.24、OpenZeppelin 5.2.0。上面的自写 ExampleToken 也存放在该工程中,正文直接引用同一文件,避免测试与展示代码不一致。

npm ci --ignore-scripts
forge test --match-test 'testExampleToken|testERC20|testApprovalRace' -vv

自写实现验证发行、代扣、销毁、铸造权限与失败转账时额度回滚;OpenZeppelin 教学代币还验证 18 位 decimals 的整数单位及旧额度加新额度的竞争序列。10 ether 在这里表示整数 10 × 10^18 个最小代币单位,不是往合约发送 ETH。

测试只覆盖指定场景。上面的 ethers 代码需要独立安装 ethers v6 并配置 RPC 与地址;本轮没有使用真实 RPC 执行该交互。

6. 阅读一个 ERC-20 合约时的检查清单

  • Transfer 是否覆盖转账、铸造和销毁,且事件参数正确。
  • totalSupply 是否始终等于所有余额之和。
  • approve 的覆盖语义、授权 race 和无限授权风险是否被说明。
  • 铸造、销毁、暂停和升级权限是否明确,是否由多签或治理控制。
  • 是否考虑非标准代币:无返回值、手续费代币、强制转账和回调代币。
  • 前端是否显示真实 decimals,并避免浮点运算。

ERC-20 的难点不在函数数量,而在这些接口被大量协议组合使用后形成的权限、会计和兼容性边界。

参考资料