从零写一个 ERC-20
ERC-20 是整个 Web3 最重要的标准——USDT、USDC、几乎所有代币都是它。它的本质朴素得惊人:一个 mapping(address => uint256) 余额表,加几个约定好的函数名。这一章我们逐行拆开它,你会发现"代币"根本不是什么装在钱包里的东西,而只是某个合约账本里记着"你有多少"的一行数字。
标准到底规定了什么
ERC-20 就是一份接口约定:只要你的合约实现了这几个函数和事件,所有钱包、交易所、DeFi 协议就能自动识别并与你的代币交互。这就是标准的力量——可组合性靠的是大家说同一种话。
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);
最小实现,逐行读
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 一个事件让前端知道。没有任何"移动"发生,代币从来没离开过这个合约,变的只是账本上的数字。这和银行改数据库是一回事,区别是这本账全网可验证、没人能私自改。
把整个 ERC-20 合约想成一张 Room 表 Balance(address PRIMARY KEY, amount) 加几个封装好的更新方法。transfer = 一个事务里两条 UPDATE。所谓"我钱包里有 100 MINI",数据库层面就是这张表里你那行写着 100。钱包 App 只是替你查询并展示它,币本身一直在合约里。
approve / transferFrom:代币的"授权支付"
这对函数是 ERC-20 最精妙也最容易被攻击的设计。为什么需要它?因为合约不能主动来拿你的币(第 3 章:只有你能动你的资产)。当你想让一个 DEX 合约帮你卖币,流程是:
- 你调
approve(DEX, 100):授权 DEX 最多从你账户划走 100 个币。 - 你调 DEX 的
swap(),DEX 内部调transferFrom(你, DEX, 100)把币拉走。
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,几行搞定,还自带各种安全边界:
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。
本章小结
- ERC-20 是接口标准;实现核心是余额 mapping,转账即改两行数字并 emit Transfer。
- approve + transferFrom 实现授权代扣,让合约能在你许可的额度内动你的币。
- 无限授权是最大盗币风险,应最小化授权并定期撤销。
- decimals(常 18,稳定币多为 6)决定显示与存储的换算;生产直接继承 OpenZeppelin。