上链ON-CHAIN BLOCK 14 / 26
卷三 · 合约工程BLOCK 14

从零写一个 ERC-20


ERC-20 是整个 Web3 最重要的标准——USDT、USDC、几乎所有代币都是它。它的本质朴素得惊人:一个 mapping(address => uint256) 余额表,加几个约定好的函数名。这一章我们逐行拆开它,你会发现"代币"根本不是什么装在钱包里的东西,而只是某个合约账本里记着"你有多少"的一行数字。

标准到底规定了什么

ERC-20 就是一份接口约定:只要你的合约实现了这几个函数和事件,所有钱包、交易所、DeFi 协议就能自动识别并与你的代币交互。这就是标准的力量——可组合性靠的是大家说同一种话

Solidity · ERC-20 接口
function totalSupply() external view returns (uint256);
function balanceOf(address owner) external view returns (uint256);
function transfer(address to, uint256 amt) external returns (bool);
function approve(address spender, uint256 amt) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function transferFrom(address from, address to, uint256 amt) external returns (bool);

event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);

最小实现,逐行读

Solidity · MiniToken.sol
contract MiniToken {
    string  public name = "MiniToken";
    string  public symbol = "MINI";
    uint8   public decimals = 18;              // 小数位,几乎都是 18
    uint256 public totalSupply;

    // 核心:谁有多少
    mapping(address => uint256) public balanceOf;
    // 授权:owner 允许 spender 花多少
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed f, address indexed t, uint256 v);
    event Approval(address indexed o, address indexed s, uint256 v);

    constructor(uint256 supply) {
        totalSupply = supply;
        balanceOf[msg.sender] = supply;         // 初始全给部署者
        emit Transfer(address(0), msg.sender, supply); // 从零地址铸造
    }

    function transfer(address to, uint256 amt) public returns (bool) {
        balanceOf[msg.sender] -= amt;           // 0.8 会自动检查不足则 revert
        balanceOf[to] += amt;
        emit Transfer(msg.sender, to, amt);
        return true;
    }

    function approve(address spender, uint256 amt) public returns (bool) {
        allowance[msg.sender][spender] = amt;   // 我授权 spender 花我 amt
        emit Approval(msg.sender, spender, amt);
        return true;
    }

    function transferFrom(address from, address to, uint256 amt) public returns (bool) {
        allowance[from][msg.sender] -= amt;     // 扣减授权额度,不足则 revert
        balanceOf[from] -= amt;
        balanceOf[to] += amt;
        emit Transfer(from, to, amt);
        return true;
    }
}

看清楚了吗?"转账"就是改两行 mapping——发送方减、接收方加,再 emit 一个事件让前端知道。没有任何"移动"发生,代币从来没离开过这个合约,变的只是账本上的数字。这和银行改数据库是一回事,区别是这本账全网可验证、没人能私自改。

Android 类比

把整个 ERC-20 合约想成一张 Room 表 Balance(address PRIMARY KEY, amount) 加几个封装好的更新方法。transfer = 一个事务里两条 UPDATE。所谓"我钱包里有 100 MINI",数据库层面就是这张表里你那行写着 100。钱包 App 只是替你查询并展示它,币本身一直在合约里。

approve / transferFrom:代币的"授权支付"

这对函数是 ERC-20 最精妙也最容易被攻击的设计。为什么需要它?因为合约不能主动来拿你的币(第 3 章:只有你能动你的资产)。当你想让一个 DEX 合约帮你卖币,流程是:

  1. 你调 approve(DEX, 100):授权 DEX 最多从你账户划走 100 个币。
  2. 你调 DEX 的 swap(),DEX 内部调 transferFrom(你, DEX, 100) 把币拉走。
Android 类比

approve 就像给某个 App 授予一个"预授权额度",很像你在支付里给商户开的免密代扣额度,或 OAuth 里授予第三方的 scope。它本身不转钱,只是登记"允许对方在额度内代你操作"。真正扣款发生在对方后续调用 transferFrom 时。

风险记账

无限授权(approve 一个天文数字)是资金被盗的头号入口。很多 dApp 为省事让你授权 type(uint256).max,之后若那个合约有漏洞或被升级成恶意版本,它能随时把你钱包里所有该代币划走。安全习惯:①只授权本次需要的额度;②定期用 revoke 工具检查并撤销不再用的授权。这是 Web3 用户最该养成的自我保护动作。

decimals:为什么 1 个币是 10¹⁸

链上没有小数(第 9 章)。为了表示"0.5 个币",ERC-20 约定一个 decimals(通常 18),真实存储的是最小单位。所以合约里 1000000000000000000(即 10¹⁸)在钱包里显示为 1.0。前端展示时要除以 10^decimals,写入时要乘回去——搞错这个是新手最常见的显示/转账金额 bug。注意 USDC/USDT 用的是 6 位小数,不是 18,别想当然。

生产环境:直接继承 OpenZeppelin

上面的最小实现是为了让你看透原理。真实项目永远别自己手写——用经过千百次审计的 OpenZeppelin,几行搞定,还自带各种安全边界:

Solidity · 生产级
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract MyToken is ERC20 {
    constructor() ERC20("MyToken", "MTK") {
        _mint(msg.sender, 1_000_000 * 10**decimals());
    }
}
要点入账

代币的祛魅:ERC-20 = 一张余额 mapping + 一套约定的函数名 + 两个事件。转账只是改账本数字,币从不离开合约。approve/transferFrom 是"授权代扣"模型,无限授权是头号盗币入口;decimals 决定人看到的数字与链上存储的换算。原理要看透,生产要用 OpenZeppelin。下一章我们把这套思路推广到"每一个都独一无二"的资产——NFT。

本章小结

◆ 返回目录