4、ERC20核心接口详解:totalSupply、balanceOf、transfer、transferFrom、approve、allowance
好,咱们今天来啃硬骨头。ERC20 标准里最核心的六个接口,说白了就是代币合约的「命根子」。你写合约、调合约、审计合约,天天跟它们打交道。我当年刚入行时,就是被这几个接口虐得死去活来——尤其是 approve 和 transferFrom 的配合,踩坑踩到怀疑人生。
别急,咱们一个一个拆开讲。保证你听完之后,闭着眼都能写对。
4.1 totalSupply —— 总供应量
这个最简单。它返回代币的总发行量。注意,是「当前」的总量,不是一成不变的。如果你合约里有增发或销毁逻辑,这个值会变。
function totalSupply() external view returns (uint256);
嗯,这里要注意:view 关键字意味着它不消耗 gas(只读)。你调用它时,钱包不会弹出确认窗口。我见过不少新手在合约里写了个 public 变量叫 _totalSupply,然后又在外面写了个 totalSupply() 函数返回它——其实 Solidity 会自动给 public 变量生成 getter 函数,你根本不用手动写。
4.2 balanceOf —— 查询余额
这个也简单。给定一个地址,返回它的代币余额。
function balanceOf(address account) external view returns (uint256);
我个人习惯在合约里用 mapping(address => uint256) private _balances 来存储余额。为什么用 private?因为我不想让外部合约直接读写这个 mapping,而是通过 balanceOf 这个接口暴露出去。这样以后想改存储逻辑(比如改成 ERC20 的变种),只需要改内部实现,对外接口不变。
我在项目中遇到过一个问题:有人直接读取了 _balances 这个 mapping,结果合约升级后 mapping 的 slot 变了,前端直接崩了。所以记住——永远通过接口访问状态,别走捷径。
4.3 transfer —— 转账
这是最常用的接口。从调用者自己账户转给接收者。
function transfer(address to, uint256 amount) external returns (bool);
注意几个关键点:
to不能是零地址(0x0),否则代币就永久锁死了amount不能大于调用者的余额- 必须触发
Transfer事件
你想想看,如果转账不触发事件,链下服务(比如区块浏览器)就不知道这笔转账发生了。ERC20 标准强制要求触发事件,就是为了让链下能追踪。
require(to != address(0), "ERC20: transfer to zero address");。
4.4 approve —— 授权
这个接口有点绕。它允许代币持有者授权另一个地址(通常是合约)代扣一定数量的代币。
function approve(address spender, uint256 amount) external returns (bool);
说白了,就是你说:「嘿,spender,你可以从我账户里扣最多 amount 个代币。」
这里有个经典坑:重放攻击。假设你给某个 DEX 授权了 100 个 USDT,然后你用了 50 个,还剩 50 个授权额度。这时候你想把授权改成 30 个。如果你直接调用 approve(spender, 30),攻击者可以在你交易确认前,抢先提交一笔 transferFrom 把剩下的 50 个转走。然后你的授权变成 30,他又能再转 30 个。总共损失 80 个。
怎么解决?我建议先授权为 0,再授权为目标值:
require(allowance[msg.sender][spender] == 0, "ERC20: approve from non-zero to non-zero");
allowance[msg.sender][spender] = amount;
或者用 OpenZeppelin 的 increaseAllowance / decreaseAllowance 函数,它们更安全。
4.5 transferFrom —— 代扣转账
这个接口是 approve 的搭档。它允许被授权的地址(spender)从授权者账户转出代币。
function transferFrom(address from, address to, uint256 amount) external returns (bool);
逻辑流程:
- 检查
from的余额是否足够 - 检查
msg.sender(即 spender)的授权额度是否足够 - 扣减授权额度
- 转账
- 触发 Transfer 事件
我在项目中遇到过一个问题:某个 DeFi 协议在调用 transferFrom 时,没有先检查授权额度,直接调用了。结果用户授权了 1000 个代币,协议一次性转走了 1000 个,但用户本意只想转 100 个。嗯,这就是为什么前端一定要让用户看清楚授权额度。
4.6 allowance —— 查询授权额度
这个接口返回 owner 给 spender 的剩余授权额度。
function allowance(address owner, address spender) external view returns (uint256);
它也是只读的,不消耗 gas。前端经常用它来显示「你已授权 XX 代币给 YY 合约」。
我个人习惯在合约里用嵌套 mapping 来存储:
mapping(address => mapping(address => uint256)) private _allowances;
这样 _allowances[owner][spender] 就是授权额度。注意,这个 mapping 是私有的,外部只能通过 allowance 函数查询。
4.7 六个接口的协作关系
咱们用一张表总结一下:
| 接口 | 作用 | 是否消耗 Gas | 常见坑 |
|---|---|---|---|
| totalSupply | 返回总供应量 | 否(view) | 忘记考虑增发/销毁 |
| balanceOf | 查询余额 | 否(view) | 直接读取内部 mapping |
| transfer | 自己转账 | 是 | 零地址、余额不足 |
| approve | 授权代扣 | 是 | 重放攻击(非零到非零) |
| transferFrom | 代扣转账 | 是 | 授权额度不足、余额不足 |
| allowance | 查询授权额度 | 否(view) | 授权额度被意外修改 |
你想想看,这六个接口构成了 ERC20 代币的完整生命周期:发行、持有、转账、授权、代扣。任何一个接口写错了,轻则代币不能用,重则资金被盗。
好,这一章就到这里。下一章咱们聊聊 ERC20 的事件机制——Transfer 和 Approval 事件,以及为什么它们比你想的更重要。