📚 本文是「区块链从小白到精通」系列第 8 篇 | 上一篇:《区块链从小白到精通(七):Solidity 语法精讲------数据类型、函数、修饰器与事件》 | 下一篇:《区块链从小白到精通(九):手把手写一个 NFT 合约------ERC-721 与元数据全解析》
前言
学语法最好的方式是完成一个真实项目。本篇我们做智能合约界" Hello World 之后的第二步"------从零实现一个标准的 ERC-20 代币。
本篇有两个目标:一是让你理解"标准"二字的分量------为什么钱包、交易所、DEX 能即插即用地支持千千万万种代币;二是完整走一遍"接口 → 实现 → 部署 → 转账"的工程流程。本篇代码不依赖任何第三方库,每一行都自己写,把原理吃透;之后会给出 OpenZeppelin 库版本作为生产环境对照。
什么是 ERC-20:接口即协议
先厘清两个名词:EIP (Ethereum Improvement Proposal)是以太坊改进提案,ERC(Ethereum Request for Comments)是其中面向应用层的标准类提案。ERC-20 是 2015 年定稿的"同质化代币标准",直到今天仍是使用最广泛的代币标准------USDT、USDC 等稳定币都是 ERC-20(或其多链版本)。
它规定了什么?规定了六个函数 + 两个事件。就这么多。
solidity
interface IERC20 {
// ------ 查询类 ------
function totalSupply() external view returns (uint256); // 总发行量
function balanceOf(address account) external view returns (uint256); // 某地址余额
function allowance(address owner, address spender) external view returns (uint256); // 授权额度
// ------ 操作类 ------
function transfer(address to, uint256 amount) external returns (bool); // 直接转账
function approve(address spender, uint256 amount) external returns (bool); // 授权
function transferFrom(address from, address to, uint256 amount) external returns (bool); // 基于授权的转账
// ------ 事件 ------
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
这就是"标准"的精妙之处:你的代币实现了这套接口,钱包就能显示它,交易所就能上它,Uniswap 就能交易它------不需要任何人为的对接。协议像插座标准,所有电器(生态工具)按同一规格生产,插上就能用。
(补充说明:name、symbol、decimals 三个可选元数据在单独的扩展接口 IERC20Metadata 里,我们的实现会一并加上。)
approve / transferFrom:为什么转账要"两步"
ERC-20 最让新手困惑的设计:转别人的币为什么不能一步到位,要先 approve 再 transferFrom?
想象一个场景:你在 Uniswap 上用 100 USDT 换 ETH。Uniswap 是一个合约,它需要从你的地址把 USDT 划走。但 EVM 有条铁律------合约动你资产的前提是你授权过。所以流程是:
text
第一步:你 → USDT 合约 approve(Uniswap路由地址, 100 USDT)
(你的签名交易,在 USDT 合约里记下:允许 Uniswap 划走我 100 USDT)
第二步:你 → Uniswap 路由 swap(用 100 USDT 换 ETH)
(路由合约内部调用 USDT.transferFrom(你, 路由, 100 USDT) 完成划款)
对应关系一目了然:
| 函数 | 谁调用 | 干什么 |
|---|---|---|
transfer |
代币持有者本人 | 自己转自己的钱 |
approve |
代币持有者 | 给第三方合约发"划款额度" |
transferFrom |
被授权的合约/地址 | 在额度内替持有者划款 |
allowance |
任何人 | 查询授权剩余额度 |
这个"授权额度"机制是 DeFi 的地基:没有它,所有第三方协议都无法安全地替你操作资产。代价就是每一步 DeFi 操作前都要先做一次 approve------你在 DApp 里常看到的"授权"按钮,背后就是这笔交易。
从零手写 MyToken:完整可运行代码
下面是本篇的主角------不依赖任何库的完整 ERC-20 实现。代码较长,但每一行都值得读:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract MyToken {
// ===== 元数据 =====
string public name = "My Token";
string public symbol = "MTK";
uint8 public constant decimals = 18; // 精度:1 token = 10^18 最小单位
// ===== 账本 =====
uint256 public totalSupply; // 总量
mapping(address => uint256) private _balances; // 余额表
mapping(address => mapping(address => uint256)) private _allowances; // 授权表:owner => spender => 额度
// ===== 自定义 error(比 require 字符串省 Gas)=====
error InsufficientBalance(address from, uint256 requested, uint256 available);
error InsufficientAllowance(address spender, uint256 requested, uint256 available);
// ===== 标准事件 =====
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;
_balances[msg.sender] = _initialSupply;
emit Transfer(address(0), msg.sender, _initialSupply); // 铸造 = 从 0 地址转出
}
// ===== 查询 =====
function balanceOf(address _who) external view returns (uint256) {
return _balances[_who];
}
function allowance(address _owner, address _spender) external view returns (uint256) {
return _allowances[_owner][_spender];
}
// ===== 直接转账 =====
function transfer(address _to, uint256 _amount) external returns (bool) {
_transfer(msg.sender, _to, _amount);
return true;
}
// ===== 授权 =====
function approve(address _spender, uint256 _amount) external returns (bool) {
_allowances[msg.sender][_spender] = _amount;
emit Approval(msg.sender, _spender, _amount);
return true;
}
// ===== 基于授权的转账 =====
function transferFrom(address _from, address _to, uint256 _amount) external returns (bool) {
uint256 allowed = _allowances[_from][msg.sender];
if (allowed < _amount) {
revert InsufficientAllowance(msg.sender, _amount, allowed);
}
if (allowed != type(uint256).max) { // 无限授权约定:max 不扣减,省 Gas
_allowances[_from][msg.sender] = allowed - _amount;
}
_transfer(_from, _to, _amount);
return true;
}
// ===== 内部核心:转账与事件 =====
function _transfer(address _from, address _to, uint256 _amount) internal {
if (_balances[_from] < _amount) {
revert InsufficientBalance(_from, _amount, _balances[_from]);
}
_balances[_from] -= _amount;
_balances[_to] += _amount;
emit Transfer(_from, _to, _amount);
}
}
逐块讲解设计意图:
账本三件套 :totalSupply、余额表、授权表。上一篇讲过 mapping 不能遍历、查不存在的键返回零值------正好符合"没余额就是 0"的语义,天然合拍。
铸造的仪式感 :构造函数里 emit Transfer(address(0), msg.sender, ...)。生态约定 from = 0x0 的 Transfer 事件代表"铸造",to = 0x0 代表"销毁"。区块浏览器的"铸造记录"就是这么识别的。
零值转账不禁止 :注意我们的实现允许 transfer 0 个代币(标准不禁止)。惯例上零值转账也应该成功并广播事件------有些索引系统依赖事件对账,不要自作聪明拦截。
无限授权的惯例 :DApp 常approve 一个天文数字(type(uint256).max)避免反复授权。行业标准约定这个值不参与扣减,所以代码里专门判断跳过。这也是下一篇 NFT 里 setApprovalForAll 的精神源头------方便与风险并存。
鉴权链 :transferFrom 检查额度 → 内部 _transfer 检查余额 → 事件广播。三道关都在,账目永远守恒:转账前后 totalSupply 不变,任何一方的增减都有对应事件。
⚠️ 提醒:这是教学实现,刻意保持了最小化。生产环境请使用 OpenZeppelin 版本------它处理了更多边界情况(如 ERC-20 的"授权竞态"问题),并经过多年审计。自己手写是学习手段,不是生产手段。
对照 OpenZeppelin:生产版只要几行
同样的代币,用社区标准库实现:
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract ProductionToken is ERC20, Ownable {
constructor(uint256 _initialSupply)
ERC20("Production Token", "PTK")
Ownable(msg.sender)
{
_mint(msg.sender, _initialSupply); // 初始量给部署者
}
// 可选:给 owner 开放后续增发
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
}
继承 ERC20 后六个标准方法、两个事件、全部边界处理都是现成的。生产代码里"用审计过的库 + 只写业务逻辑"是铁律------自己造轮子意味着自己承担全部风险。在 Remix 里,import 语句会自动从 npm 源拉取 OpenZeppelin,直接编译即可,无需手动安装。
decimals:链上只有整数这件事
一个高频误区:链上并不能存"1.5 个代币"这种小数。decimals = 18 的含义是:账本上记的都是最小单位,1 个代币 = 10¹⁸ 个最小单位(类似人民币的"分",只是分母是 10^18)。
text
你想转 1.5 个 MTK:
前端:1.5 × 10^18 = 1500000000000000000 ← 这才是传给合约的 _amount
合约账本:_balances[你] -= 1500000000000000000
钱包显示:1500000000000000000 / 10^18 = 1.5 MTK
所以:合约里全是整数运算;前端展示时除以 10^18、输入时乘以 10^18;测试转账时写 1000000000000000000 才是"1 个币"。初学时"转了 1 结果到账 0.000000000000000001"之类的怪事,几乎都是 decimals 没换算对。
实战部署与转账验证
完整流程走一遍(前六篇的肌肉记忆应该已经建立了):
- 部署 :Remix 打开 MyToken,编译,连接 MetaMask(Sepolia),Deploy 时构造参数填初始供应量------注意填最小单位:想发 100 万个,填
1000000后面跟 18 个 0(或直接填1000000 * 10**18的计算结果1e24对应的完整数字); - 自查 :展开合约面板,
name/symbol/totalSupply逐个查询确认;balanceOf填自己地址,应等于 totalSupply; - 转账 :新建一个 MetaMask 账户(或找朋友的地址)当接收方,调用
transfer,参数填接收地址和金额(记得最小单位);确认交易后用balanceOf查询双方余额,一减一增、总量不变; - 授权链验证 :用 A 账户
approve授权 B 账户额度,切到 B 账户调transferFrom把 A 的币划给自己,再用allowance查剩余额度------把两步转账的时序亲手跑一遍; - 钱包里看到它 :MetaMask → 资产 → 导入代币 → 粘贴合约地址,你的代币出现在钱包里,带名字带符号------因为
name/symbol/decimals标准接口被钱包调用了。
第 5 步是"标准"二字最直观的兑现:没有任何对接,你的代币天然兼容整个生态。
常见坑清单
| 坑 | 现象 | 根因与规避 |
|---|---|---|
| decimals 误解 | 转 1 个到账 10⁻¹⁸ 个 | 合约只认最小单位,前端负责换算 |
| 没等 approve 确认就 transferFrom | 报 InsufficientAllowance |
授权交易还在 pending,额度未生效 |
| 无限授权后忘撤销 | 授权过的合约随时可划款 | 用完 approve(spender, 0) 撤销,或用带过期的授权方案 |
| 直接 transfer 给合约 | 代币"消失"在合约里 | 对方合约若没处理 ERC-20 的逻辑,代币永久卡死 |
| 忘记 emit 事件 | 链下看不到转账记录 | 事件是对账与索引的命脉,一个都不能少 |
⚠️ 提醒 :技术上"发币"只是十分钟的事,但发币本身不产生任何价值------价值来自项目、社区与合规经营。擅自以知名项目名义发币、承诺收益,涉嫌欺诈与非法集资,请守住法律底线。
写在最后
本篇收获盘点:
- ERC-20 = 六函数 + 二事件的接口约定,实现接口即接入生态;
- approve/transferFrom 两步授权是 DeFi 的地基,
allowance记录额度; - 手写版看懂了账本守恒、铸造仪式(from = 0x0)与无限授权惯例;
- 生产用 OpenZeppelin:继承即完成,只写业务逻辑;
- decimals 是"最小单位"概念,链上无小数,前端负责换算;
- 实战跑通了部署 → 转账 → 授权 → 钱包展示全链路。
会发同质化代币了,下一篇解决"独一无二":《区块链从小白到精通(九):手把手写一个 NFT 合约------ERC-721 与元数据全解析》。从 ERC-20 到 ERC-721,数据模型会来一次漂亮的反转,你会看到 tokenId 如何成为链上世界的新主角。
实验室见。