区块链从小白到精通(八):从零实现一个标准 ERC-20 代币(完整可运行代码)

📚 本文是「区块链从小白到精通」系列第 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 没换算对。

实战部署与转账验证

完整流程走一遍(前六篇的肌肉记忆应该已经建立了):

  1. 部署 :Remix 打开 MyToken,编译,连接 MetaMask(Sepolia),Deploy 时构造参数填初始供应量------注意填最小单位:想发 100 万个,填 1000000 后面跟 18 个 0(或直接填 1000000 * 10**18 的计算结果 1e24 对应的完整数字);
  2. 自查 :展开合约面板,name/symbol/totalSupply 逐个查询确认;balanceOf 填自己地址,应等于 totalSupply;
  3. 转账 :新建一个 MetaMask 账户(或找朋友的地址)当接收方,调用 transfer,参数填接收地址和金额(记得最小单位);确认交易后用 balanceOf 查询双方余额,一减一增、总量不变;
  4. 授权链验证 :用 A 账户 approve 授权 B 账户额度,切到 B 账户调 transferFrom 把 A 的币划给自己,再用 allowance 查剩余额度------把两步转账的时序亲手跑一遍;
  5. 钱包里看到它 :MetaMask → 资产 → 导入代币 → 粘贴合约地址,你的代币出现在钱包里,带名字带符号------因为 name/symbol/decimals 标准接口被钱包调用了。

第 5 步是"标准"二字最直观的兑现:没有任何对接,你的代币天然兼容整个生态。

常见坑清单

坑 现象 根因与规避
decimals 误解 转 1 个到账 10⁻¹⁸ 个 合约只认最小单位,前端负责换算
没等 approve 确认就 transferFrom 报 InsufficientAllowance 授权交易还在 pending,额度未生效
无限授权后忘撤销 授权过的合约随时可划款 用完 approve(spender, 0) 撤销,或用带过期的授权方案
直接 transfer 给合约 代币"消失"在合约里 对方合约若没处理 ERC-20 的逻辑,代币永久卡死
忘记 emit 事件 链下看不到转账记录 事件是对账与索引的命脉,一个都不能少

⚠️ 提醒 :技术上"发币"只是十分钟的事,但发币本身不产生任何价值------价值来自项目、社区与合规经营。擅自以知名项目名义发币、承诺收益,涉嫌欺诈与非法集资,请守住法律底线。

写在最后

本篇收获盘点:

  1. ERC-20 = 六函数 + 二事件的接口约定,实现接口即接入生态;
  2. approve/transferFrom 两步授权是 DeFi 的地基,allowance 记录额度;
  3. 手写版看懂了账本守恒、铸造仪式(from = 0x0)与无限授权惯例;
  4. 生产用 OpenZeppelin:继承即完成,只写业务逻辑;
  5. decimals 是"最小单位"概念,链上无小数,前端负责换算;
  6. 实战跑通了部署 → 转账 → 授权 → 钱包展示全链路。

会发同质化代币了,下一篇解决"独一无二":《区块链从小白到精通(九):手把手写一个 NFT 合约------ERC-721 与元数据全解析》。从 ERC-20 到 ERC-721,数据模型会来一次漂亮的反转,你会看到 tokenId 如何成为链上世界的新主角。

实验室见。

相关推荐
Severus_black1 小时前
【C++初阶】Stack、Queue算法题大练兵!
c语言·c++·算法·stl
2601_967979123 小时前
机器学习必修课:经典算法与Python实战
python·算法·机器学习·实战·课程
程序猿打工仔4 小时前
计算机基础(C语言)考编、专技岗
c语言·开发语言·算法
通信小呆呆4 小时前
电子战干扰技术:从“蛮力压制”到“精准欺骗”,再到“认知博弈”
算法·通信·isac
老师我太想进步了20264 小时前
【数据结构-6】算法美学之单链表(1)
数据结构·算法
老谭说理财5 小时前
股票市场全景评测与实战分析
区块链
希望我万事顺遂5 小时前
力扣hot100-滑动窗口最大值
数据结构·算法·leetcode
꧁细听勿语情꧂6 小时前
二叉树余题,排序、直接插入排序、希尔排序
c语言·开发语言·数据结构·算法
OxYGC6 小时前
[Web3] 智能合约 02: Solidity 基础语法
web3·区块链·智能合约