ERC20 标准详解 + 手写完整实现
深入理解 EIP-20 规范每一个函数、approve+transferFrom 模式的设计原因
日期: 2026-04-17 方向: Solidity 阶段: 第一阶段:基础构建(Week 2 开始) 标签: #solidity #erc20 #token #standard #defi-foundation
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | 深入理解 EIP-20 规范每一个函数、approve+transferFrom 模式的设计原因 |
| 实操 | 从零手写完整的 ERC20 合约(不使用 OpenZeppelin),完全注释每一行 |
| 产出 | 可部署的 ERC20 合约 + ERC20 机制理解笔记 |
一、什么是 ERC20
1.1 背景
ERC20(Ethereum Request for Comment 20)是以太坊上同质化代币(Fungible Token)的标准接口。由 Fabian Vogelsteller 和 Vitalik Buterin 于 2015 年提出。
为什么需要标准?
- 在 ERC20 之前,每个代币合约的接口不同,钱包和交易所需要为每个代币写专门的适配代码
- 标准化接口让所有 ERC20 代币可以被任何兼容钱包、DEX、DApp 通用支持
- 这就是以太坊生态的**可组合性(composability)**基础
1.2 ERC20 定义了什么
EIP-20 Standard Token Interface:
├── 6 个函数 (必须实现)
│ ├── totalSupply() → 代币总量
│ ├── balanceOf(addr) → 查询余额
│ ├── transfer(to, amt) → 直接转账
│ ├── approve(spender, amt) → 授权额度
│ ├── transferFrom(from, to, amt) → 代理转账
│ └── allowance(owner, spender) → 查询授权额度
│
├── 2 个事件 (必须触发)
│ ├── Transfer(from, to, value)
│ └── Approval(owner, spender, value)
│
└── 3 个可选函数
├── name() → 代币名称
├── symbol() → 代币符号
└── decimals() → 小数位数
二、每个函数深度解析
2.1 totalSupply
function totalSupply() external view returns (uint256);
返回当前代币总供应量。注意:
- 如果代币可以 mint/burn,这个值会变化
- 通常在构造函数中初始化
- 单位是最小单位(如果 decimals=18,1 Token = 10^18)
2.2 balanceOf
function balanceOf(address account) external view returns (uint256);
查询某地址的代币余额。底层就是一个 mapping(address => uint256)。
2.3 transfer — 直接转账
function transfer(address to, uint256 amount) external returns (bool);
调用者(msg.sender)直接转账给 to。
- 必须检查余额充足
- 必须触发
Transfer事件 - 返回
true表示成功
2.4 approve — 授权额度
function approve(address spender, uint256 amount) external returns (bool);
授权 spender 最多可以从 msg.sender 账户转走 amount 个代币。
- 设置 allowance(授权额度)
- 必须触发
Approval事件 - 可以多次调用覆盖之前的授权
2.5 transferFrom — 代理转账
function transferFrom(address from, address to, uint256 amount) external returns (bool);
从 from 账户转 amount 给 to,由 msg.sender 执行。
- msg.sender 必须有足够的 allowance
- 转账后 allowance 减少
amount - 必须触发
Transfer事件
2.6 allowance — 查询授权
function allowance(address owner, address spender) external view returns (uint256);
查询 owner 授权给 spender 的剩余额度。
三、approve + transferFrom 模式详解
这是 ERC20 最重要也最容易混淆的设计。
3.1 为什么需要两步(approve + transferFrom)?
问题场景:Alice 想在 Uniswap 上卖出 100 USDC 换 ETH。
如果只有 transfer:
Alice 调用 USDC.transfer(Uniswap, 100)
→ USDC 直接到了 Uniswap 合约
→ 但 Uniswap 合约不知道这是 Alice 发的(没有回调机制)
→ 无法自动完成交易的另一半
用 approve + transferFrom:
Step 1: Alice 调用 USDC.approve(Uniswap, 100)
→ "我允许 Uniswap 合约从我账户取最多 100 USDC"
Step 2: Alice 调用 Uniswap.swap(USDC, ETH, 100)
→ Uniswap 合约内部调用 USDC.transferFrom(Alice, Uniswap, 100)
→ Uniswap 收到 USDC,然后发 ETH 给 Alice
→ 一切在一笔交易中原子完成!
3.2 交互流程图
用户钱包 ERC20 合约 DApp 合约
│ │ │
│── approve(DApp, 100) ──→│ │
│ │ 记录 allowance │
│←── true ─────────│ │
│ │ │
│── swap(params) ──────────────────────→ │
│ │ │
│ │←── transferFrom(user, DApp, 100) ──│
│ │ 检查 allowance ≥ 100 │
│ │ 扣减 user 余额 │
│ │ 增加 DApp 余额 │
│ │ 减少 allowance │
│ │──→ true │
│ │ │
│←── 完成 swap ────────────────────────── │
3.3 授权的安全问题
// ⚠️ 经典的 approve 前端攻击(Front-running Attack)
// 场景:Alice 之前授权 Bob 100,现在想改为 50
// Step 1: Alice 发交易 approve(Bob, 50)
// Step 2: Bob 看到 pending 交易,抢先执行 transferFrom(Alice, Bob, 100)
// Step 3: Alice 的 approve(Bob, 50) 执行
// Step 4: Bob 再次执行 transferFrom(Alice, Bob, 50)
// 结果:Bob 总共获得 150!
// 解决方案 1:先设为 0 再设新值
// approve(Bob, 0) → approve(Bob, 50)
// 解决方案 2:使用 increaseAllowance/decreaseAllowance(OpenZeppelin)
function increaseAllowance(address spender, uint256 addedValue) public returns (bool) {
_approve(msg.sender, spender, allowance(msg.sender, spender) + addedValue);
return true;
}
// 解决方案 3:使用 permit(ERC2612,链下签名授权)
四、完整 ERC20 手写实现
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title MomoToken - 从零手写的 ERC20 代币
/// @author SC Day8 Learning
/// @notice 不使用 OpenZeppelin,完整实现 ERC20 标准
/// @dev 遵循 EIP-20: https://eips.ethereum.org/EIPS/eip-20
interface IERC20 {
/// @notice 返回代币总供应量
function totalSupply() external view returns (uint256);
/// @notice 返回某地址的余额
function balanceOf(address account) external view returns (uint256);
/// @notice 转账代币给目标地址
/// @param to 接收地址
/// @param amount 转账金额
/// @return 是否成功
function transfer(address to, uint256 amount) external returns (bool);
/// @notice 查询授权额度
function allowance(address owner, address spender) external view returns (uint256);
/// @notice 授权 spender 可以从调用者账户转走最多 amount 个代币
function approve(address spender, uint256 amount) external returns (bool);
/// @notice 从 from 转账到 to(需要授权)
function transferFrom(address from, address to, uint256 amount) external returns (bool);
/// @notice 转账事件
event Transfer(address indexed from, address indexed to, uint256 value);
/// @notice 授权事件
event Approval(address indexed owner, address indexed spender, uint256 value);
}
/// @dev 自定义错误(比 require string 更省 gas)
error ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed);
error ERC20InvalidSender(address sender);
error ERC20InvalidReceiver(address receiver);
error ERC20InsufficientAllowance(address spender, uint256 allowance, uint256 needed);
error ERC20InvalidApprover(address approver);
error ERC20InvalidSpender(address spender);
contract MomoToken is IERC20 {
// ============ 存储 ============
/// @dev 代币名称
string private _name;
/// @dev 代币符号
string private _symbol;
/// @dev 小数位数(通常为 18,与 ETH 保持一致)
uint8 private constant _decimals = 18;
/// @dev 总供应量
uint256 private _totalSupply;
/// @dev 余额映射:address => balance
mapping(address => uint256) private _balances;
/// @dev 授权映射:owner => spender => amount
mapping(address => mapping(address => uint256)) private _allowances;
// ============ 构造函数 ============
/// @notice 部署时设置代币名称和符号,并给部署者铸造初始供应量
/// @param name_ 代币名称(如 "Momo Token")
/// @param symbol_ 代币符号(如 "MOMO")
/// @param initialSupply 初始供应量(以整数 Token 计,内部会乘以 10^decimals)
constructor(string memory name_, string memory symbol_, uint256 initialSupply) {
_name = name_;
_symbol = symbol_;
// 铸造初始供应量给部署者
// initialSupply 是"人类可读"数量,需要乘以 10^decimals 转换为最小单位
// 例如:initialSupply = 1000000 → _totalSupply = 1000000 * 10^18
_mint(msg.sender, initialSupply * 10 ** _decimals);
}
// ============ 元数据函数(可选但推荐)============
/// @notice 返回代币名称
function name() public view returns (string memory) {
return _name;
}
/// @notice 返回代币符号
function symbol() public view returns (string memory) {
return _symbol;
}
/// @notice 返回小数位数
/// @dev 绝大多数代币使用 18(与 ETH 一致)
/// USDC/USDT 使用 6,WBTC 使用 8
function decimals() public pure returns (uint8) {
return _decimals;
}
// ============ ERC20 标准函数 ============
/// @inheritdoc IERC20
function totalSupply() public view override returns (uint256) {
return _totalSupply;
}
/// @inheritdoc IERC20
function balanceOf(address account) public view override returns (uint256) {
return _balances[account];
}
/// @inheritdoc IERC20
/// @dev 调用者转账给 to
function transfer(address to, uint256 amount) public override returns (bool) {
address owner = msg.sender;
_transfer(owner, to, amount);
return true;
}
/// @inheritdoc IERC20
function allowance(address owner, address spender) public view override returns (uint256) {
return _allowances[owner][spender];
}
/// @inheritdoc IERC20
/// @dev 设置 spender 的授权额度(覆盖旧值)
function approve(address spender, uint256 amount) public override returns (bool) {
address owner = msg.sender;
_approve(owner, spender, amount);
return true;
}
/// @inheritdoc IERC20
/// @dev msg.sender 从 from 转账到 to,需要足够的 allowance
function transferFrom(
address from,
address to,
uint256 amount
) public override returns (bool) {
address spender = msg.sender;
_spendAllowance(from, spender, amount); // 先扣减 allowance
_transfer(from, to, amount); // 再执行转账
return true;
}
// ============ 内部实现函数 ============
/// @dev 核心转账逻辑
/// @param from 发送方
/// @param to 接收方
/// @param amount 金额
function _transfer(address from, address to, uint256 amount) internal {
// 检查地址有效性
if (from == address(0)) {
revert ERC20InvalidSender(address(0));
}
if (to == address(0)) {
revert ERC20InvalidReceiver(address(0));
}
// 检查余额
uint256 fromBalance = _balances[from];
if (fromBalance < amount) {
revert ERC20InsufficientBalance(from, fromBalance, amount);
}
// 执行转账
// 使用 unchecked 是安全的,因为上面已经检查了 fromBalance >= amount
unchecked {
_balances[from] = fromBalance - amount;
}
_balances[to] += amount;
// 触发事件(标准要求)
emit Transfer(from, to, amount);
}
/// @dev 铸造新代币
/// @param account 接收地址
/// @param amount 铸造数量
function _mint(address account, uint256 amount) internal {
if (account == address(0)) {
revert ERC20InvalidReceiver(address(0));
}
_totalSupply += amount;
_balances[account] += amount;
// ERC20 标准规定:mint 必须触发 from=address(0) 的 Transfer 事件
emit Transfer(address(0), account, amount);
}
/// @dev 销毁代币
/// @param account 销毁来源地址
/// @param amount 销毁数量
function _burn(address account, uint256 amount) internal {
if (account == address(0)) {
revert ERC20InvalidSender(address(0));
}
uint256 accountBalance = _balances[account];
if (accountBalance < amount) {
revert ERC20InsufficientBalance(account, accountBalance, amount);
}
unchecked {
_balances[account] = accountBalance - amount;
}
_totalSupply -= amount;
// burn 触发 to=address(0) 的 Transfer 事件
emit Transfer(account, address(0), amount);
}
/// @dev 设置授权额度
function _approve(address owner, address spender, uint256 amount) internal {
if (owner == address(0)) {
revert ERC20InvalidApprover(address(0));
}
if (spender == address(0)) {
revert ERC20InvalidSpender(address(0));
}
_allowances[owner][spender] = amount;
emit Approval(owner, spender, amount);
}
/// @dev 消费 allowance(在 transferFrom 中调用)
function _spendAllowance(address owner, address spender, uint256 amount) internal {
uint256 currentAllowance = allowance(owner, spender);
// type(uint256).max 表示"无限授权",不需要扣减
if (currentAllowance != type(uint256).max) {
if (currentAllowance < amount) {
revert ERC20InsufficientAllowance(spender, currentAllowance, amount);
}
unchecked {
_approve(owner, spender, currentAllowance - amount);
}
}
}
}
五、使用场景与交互示例
5.1 部署与基本操作
在 Remix 中部署:
name_: "Momo Token"symbol_: "MOMO"initialSupply: 1000000(100万 Token)
5.2 典型 DeFi 交互流程
场景:在 DEX 上交换代币
1. 用户查看余额
→ MomoToken.balanceOf(用户地址) → 1,000,000 * 10^18
2. 用户授权 DEX 合约
→ MomoToken.approve(DEX地址, 1000 * 10^18)
→ 触发 Approval 事件
3. 用户调用 DEX 的 swap 函数
→ DEX 内部调用 MomoToken.transferFrom(用户, DEX, 1000 * 10^18)
→ 触发 Transfer 事件
→ DEX 发送另一种代币给用户
4. 用户查看剩余授权
→ MomoToken.allowance(用户, DEX) → 0(已全部使用)
六、decimals 深度理解
重要:Solidity 没有浮点数,所有代币都是整数!
人类看到:1.5 MOMO
实际存储:1,500,000,000,000,000,000 (1.5 * 10^18)
常见 decimals 值:
├── 18: ETH, 大多数 ERC20 (LINK, UNI, AAVE)
├── 8: WBTC (与比特币 satoshi 对齐)
├── 6: USDC, USDT (与美元 cents 相近)
└── 0: 某些 NFT-like 代币
代码中的换算:
uint256 oneToken = 1 * 10**18; // 1 MOMO
uint256 halfToken = 5 * 10**17; // 0.5 MOMO
uint256 oneMillionTokens = 1_000_000 * 10**18; // 1M MOMO
// ⚠️ 不同 decimals 的代币交互时必须统一精度!
// 1 USDC (6 decimals) = 1,000,000
// 1 DAI (18 decimals) = 1,000,000,000,000,000,000
// 两者都代表 $1,但数字差 10^12 倍!
七、关键要点总结
| 要点 | 说明 |
|---|---|
| ERC20 = 接口标准 | 6 函数 + 2 事件,保证可组合性 |
| approve+transferFrom 两步 | DApp 合约需要代理转账权限 |
| decimals 不是小数 | 是放大倍数,通常 18 |
| 无限授权 type(uint256).max | 方便但有安全风险 |
| Transfer from 0x0 = mint | 标准规定的 mint 事件格式 |
| Transfer to 0x0 = burn | 标准规定的 burn 事件格式 |
| allowance 前端攻击 | 修改授权前先设为 0 |
| unchecked 优化 | 已验证安全时跳过溢出检查省 gas |
八、常见误区
误区 1:以为 transfer 和 transferFrom 可以互换
// transfer: msg.sender 直接转给 to(用户自己操作)
// transferFrom: msg.sender 从 from 转给 to(合约代理操作)
// DEX/借贷合约必须用 transferFrom,因为是合约在帮用户转账
误区 2:忘记 decimals 换算
// ❌ 错误:转 1 个代币
token.transfer(to, 1); // 实际只转了 0.000000000000000001 个代币!
// ✅ 正确:转 1 个代币
token.transfer(to, 1 * 10**18); // 转了完整的 1 个代币
误区 3:以为 approve 是累加的
// approve 是覆盖,不是累加!
approve(spender, 100); // allowance = 100
approve(spender, 50); // allowance = 50(不是 150!)
误区 4:忽略 approve 返回值
// 虽然标准要求返回 bool,但几乎所有实现都返回 true
// 有些老合约(如 USDT)不返回值!
// 使用 SafeERC20 库可以处理这种不一致性
九、面试关联
Q: 解释 ERC20 的 approve + transferFrom 模式。为什么不能只用 transfer?
A: transfer 只能由代币持有者直接调用,无法支持第三方合约代理转账。在 DeFi 中,用户需要和 DEX/借贷合约交互,这些合约需要从用户账户中取出代币。approve 让用户授权合约可以转走多少代币,transferFrom 让合约实际执行转账。这两步保证了:(1) 用户主动授权,控制权在用户手中;(2) 合约可以在一笔交易中完成复杂操作(如 swap 的两个代币转账)。
Q: ERC20 的 approve 有什么安全风险?
A: 主要风险:(1) 无限授权风险——很多 DApp 请求 type(uint256).max 授权,如果 DApp 合约有漏洞,攻击者可以转走所有代币。(2) 授权前端攻击——从旧额度改到新额度时,恶意 spender 可以在交易挤入时同时使用旧额度和新额度。缓解方案:限制授权额度、使用 permit(ERC-2612)进行链下签名授权、定期检查和撤销旧授权。
Q: 什么是 decimals?为什么 USDC 用 6 而 DAI 用 18?
A: decimals 定义了代币最小单位和 1 Token 之间的换算关系。18 是以太坊生态的约定(1 ETH = 10^18 wei),大多数代币沿用。USDC 用 6 是因为 Circle(发行方)认为和美分(2位小数)接近的 6 位更实用,且降低了大额转账时整数溢出的风险。开发者在处理不同 decimals 的代币交互时必须做精度转换,这是 DeFi 开发中常见的 bug 来源。
十、参考资源
| 资源 | 链接 | 说明 |
|---|---|---|
| EIP-20 规范 | https://eips.ethereum.org/EIPS/eip-20 | 标准原文 |
| OpenZeppelin ERC20 | https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol | 参考实现 |
| ERC20 by Example | https://solidity-by-example.org/app/erc20/ | 代码示例 |
| Token Approval Checker | https://revoke.cash/ | 检查和撤销授权 |
| ERC-2612 Permit | https://eips.ethereum.org/EIPS/eip-2612 | 链下签名授权 |
| USDC 合约 | https://etherscan.io/token/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48#code | 真实 ERC20 参考 |