返回 SC 笔记
SC Day 8

ERC20 标准详解 + 手写完整实现

深入理解 EIP-20 规范每一个函数、approve+transferFrom 模式的设计原因

2026-04-17
第一阶段:基础构建(Week 2 开始)
solidityerc20tokenstandarddefi-foundation

日期: 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 账户转 amountto,由 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 ERC20https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/token/ERC20/ERC20.sol参考实现
ERC20 by Examplehttps://solidity-by-example.org/app/erc20/代码示例
Token Approval Checkerhttps://revoke.cash/检查和撤销授权
ERC-2612 Permithttps://eips.ethereum.org/EIPS/eip-2612链下签名授权
USDC 合约https://etherscan.io/token/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48#code真实 ERC20 参考