ERC20代币标准详解
ERC20,说白了就是以太坊上代币的"通用语言"。我刚开始接触智能合约时,觉得这玩意儿不就是个接口规范吗?后来踩过坑才明白——没有标准,DeFi生态根本玩不转。
ERC20接口规范
ERC20标准定义了一套接口,所有代币合约都得遵守。就像USB接口一样,不管里面是什么电路,插上去就能用。
核心接口就这几个:
- totalSupply() — 返回总供应量
- balanceOf(address) — 查询某个地址的余额
- transfer(address, uint256) — 转账
- approve(address, uint256) — 授权
- allowance(address, address) — 查询授权额度
- transferFrom(address, address, uint256) — 代转账
嗯,这里要注意:接口里没有构造函数,没有mapping,只有函数签名。你想想看,这其实给了开发者很大的自由度——只要实现了这些函数,内部怎么存数据都行。
代币合约实现
我个人习惯用OpenZeppelin的模板,但为了讲清楚原理,咱们手写一个精简版。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract MyToken {
string public name = "MyToken";
string public symbol = "MTK";
uint8 public decimals = 18;
uint256 private _totalSupply;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
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 * 10 ** decimals;
_balances[msg.sender] = _totalSupply;
emit Transfer(address(0), msg.sender, _totalSupply);
}
function totalSupply() public view returns (uint256) {
return _totalSupply;
}
function balanceOf(address account) public view returns (uint256) {
return _balances[account];
}
function transfer(address to, uint256 amount) public returns (bool) {
require(to != address(0), "ERC20: transfer to zero address");
require(_balances[msg.sender] >= amount, "ERC20: insufficient balance");
_balances[msg.sender] -= amount;
_balances[to] += amount;
emit Transfer(msg.sender, to, amount);
return true;
}
function approve(address spender, uint256 amount) public returns (bool) {
_allowances[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function allowance(address owner, address spender) public view returns (uint256) {
return _allowances[owner][spender];
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(from != address(0), "ERC20: transfer from zero address");
require(to != address(0), "ERC20: transfer to zero address");
require(_balances[from] >= amount, "ERC20: insufficient balance");
require(_allowances[from][msg.sender] >= amount, "ERC20: insufficient allowance");
_balances[from] -= amount;
_balances[to] += amount;
_allowances[from][msg.sender] -= amount;
emit Transfer(from, to, amount);
return true;
}
}
关键点:构造函数里铸造了所有代币给部署者。实际项目中,你可能需要更复杂的铸造逻辑,比如时间锁、多签控制。
总供应量与余额管理
总供应量是个固定值,但余额是动态变化的。我在项目中遇到过一个问题:有人试图通过反复调用transfer来制造假交易量。怎么防?其实ERC20标准本身不关心这个,但你可以加个交易限额或者冷却时间。
余额管理说白了就是加减法:
- 转账时,发送方余额减少,接收方余额增加
- 铸造时,总供应量增加,接收方余额增加
- 销毁时,总供应量减少,发送方余额减少
你想想看,如果余额管理出bug,比如溢出,那整个代币就废了。所以Solidity 0.8+默认带溢出检查,但老版本你得自己用SafeMath。
转账与授权机制
转账分两种:
- 直接转账 — 你直接给别人打钱
- 授权转账 — 你授权别人帮你打钱
授权机制是DeFi的基石。比如你在Uniswap上交易,其实是先授权Uniswap合约花你的代币,然后它调用transferFrom帮你转出去。
我曾经踩过一个坑:授权额度没有清零。如果你授权了100个代币给某个合约,它只花了50个,那剩下的50个它随时可以花。所以很多DeFi协议会先授权0,再授权新额度,防止重入攻击。
授权流程是这样的:
- 用户A调用approve(合约地址, 100)
- 合约调用transferFrom(A, B, 50)
- A的授权额度从100变成50
- 合约可以继续花剩下的50
事件(Event)的使用
事件是合约和前端之间的桥梁。没有事件,前端根本不知道链上发生了什么。
ERC20标准定义了两个事件:
| 事件名 | 参数 | 说明 |
|---|---|---|
| Transfer | from, to, value | 任何转账都会触发 |
| Approval | owner, spender, value | 授权额度变化时触发 |
我个人习惯把indexed关键字加在from和to上,这样前端可以快速过滤。比如查询某个地址的所有转账记录:
// 前端代码示例(ethers.js)
const filter = contract.filters.Transfer(myAddress);
contract.on(filter, (from, to, value) => {
console.log(`${from} 转了 ${value} 给 ${to}`);
});
小技巧:事件参数里的indexed最多三个。如果你需要更多索引字段,可以用多个事件,或者把额外数据塞到data里。
嗯,这里还要注意:事件不是必须的,但强烈建议加上。没有事件的代币合约,就像没有仪表盘的汽车——能开,但你不知道发生了什么。
我记得有一次调试一个跨链桥,发现代币转过去了但前端没显示。查了半天,原来是事件没emit。从那以后,我写合约第一件事就是检查事件有没有漏掉。
总结一下:ERC20标准看似简单,但每个细节都关乎安全。授权机制要防重入,余额管理要防溢出,事件要完整记录。把这些搞明白了,DeFi开发就入门了。