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