学习目标: 理解 ERC-20,实现代币合约,并使用 Remix 部署和交互。
上一课
本课目录
- 1. ERC-20
- 2. Remix
- 3. (TBD) 实战作业:发行你的第一个 Meme Coin
1. ERC-20
ERC-20,是用来发代币 (Token) 的。是一个协议
当时以太坊上项目越来越多,大家都能发代币,但每个合约的余额查询、转账、授权写法都不一样,钱包、交易所和协议很难统一接入。
于是社区提出 ERC-20,规定同质化代币的标准接口,让不同代币能被生态一致识别和使用。
Interface
接口定义
ERC-20的接口定义,定义了实现ERC-20的时候,最少所需要实现的属性和方法。
非常简单,对吧?
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC20/IERC20.sol)
// 定义solidity的版本
pragma solidity >=0.4.16;
/**
* @dev ERC 中定义的 ERC-20 标准接口。
*/
interface IERC20 {
/**
* @dev 当 `value` 数量的代币从一个账户(`from`)转移到
* 另一个账户(`to`)时触发。
*
* 注意 `value` 可以为 0。
*/
event Transfer(address indexed from, address indexed to, uint256 value);
/**
* @dev 当通过调用 {approve} 为 `owner` 设置 `spender` 的授权额度时触发。
* `value` 是新的授权额度。
*/
event Approval(address indexed owner, address indexed spender, uint256 value);
/**
* @dev 返回代币总供应量。
*/
function totalSupply() external view returns (uint256);
/**
* @dev 返回 `account` 持有的代币数量。
*/
function balanceOf(address account) external view returns (uint256);
/**
* @dev 将调用者账户中的 `value` 数量代币转移给 `to`。
*
* 返回一个布尔值,表示操作是否成功。
*
* 触发 {Transfer} 事件。
*/
function transfer(address to, uint256 value) external returns (bool);
/**
* @dev 返回 `spender` 通过 {transferFrom} 代表 `owner` 可花费的剩余代币数量。
* 默认值为 0。
*
* 该值会在调用 {approve} 或 {transferFrom} 时变化。
*/
function allowance(address owner, address spender) external view returns (uint256);
/**
* @dev 将调用者代币中 `value` 数量设置为 `spender` 的可支配授权额度。
*
* 返回一个布尔值,表示操作是否成功。
*
* 重要提示:使用此方法修改授权额度时存在风险,
* 由于交易顺序问题,可能会导致旧额度和新额度都被使用。
* 缓解该竞态条件的一种方式是先将 `spender` 的授权降为 0,
* 然后再设置期望值:
* https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729
*
* 触发 {Approval} 事件。
*/
function approve(address spender, uint256 value) external returns (bool);
/**
* @dev 通过授权机制将 `value` 数量的代币从 `from` 转移到 `to`。
* 随后会从调用者的授权额度中扣除 `value`。
*
* 返回一个布尔值,表示操作是否成功。
*
* 触发 {Transfer} 事件。
*/
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
创建一个自己的Token
openzeppelin-contracts中有一个现成的abstract contract. 只需要继承它,就能得到一个包含完整默认功能的Token
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
/**
* @dev 简单的 ERC20 代币示例,继承自 abstract contract ERC20
*
* 这个示例展示了如何通过继承 abstract contract 快速创建自己的代币
*/
contract MyToken is ERC20 {
/**
* @dev 构造函数:初始化代币名称、符号,并铸造初始供应量
*
* @param name_ 代币名称,例如 "My Token"
* @param symbol_ 代币符号,例如 "MTK"
* @param initialSupply 初始供应量(会自动加上 18 位小数)
*/
constructor(string memory name_, string memory symbol_, uint256 initialSupply) ERC20(name_, symbol_) {
// 使用内部函数 _mint 铸造代币给部署者
// msg.sender 是合约部署者地址
_mint(msg.sender, initialSupply * 10**decimals());
}
}
从零写一个完整的ERC-20
练习一下Solidity
下面我们来一步一步手写一个 ERC-20 代币合约。
你可以把 ERC-20 先理解成三部分:
- 余额系统:谁有多少代币
- 授权系统:谁允许谁花多少代币
- 标准接口:别人怎么和这个代币交互
一、先写合约头
先把合约最外层搭起来:
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract SimpleERC20 is IERC20 {
}
这一部分的作用
- pragma solidity ^0.8.0;
- 指定 Solidity 编译器版本
- import "../token/ERC20/IERC20.sol";
- 引入 ERC-20 接口
- contract SimpleERC20 is IERC20
- 表示这个合约要实现 ERC-20 标准要求的函数
也就是说,既然说自己是 IERC20,那就要把接口里规定的方法都实现出来。
二、先定义状态变量
ERC-20 代币最核心的是“存数据”。先把需要保存的数据写出来。
uint256 private _totalSupply;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
string private _name;
string private _symbol;
uint8 private _decimals;
1. _totalSupply
表示总代币数量。
uint256 private _totalSupply;
它记录的是整个系统一共有多少代币。
2. _balances
表示每个地址的余额。
mapping(address => uint256) private _balances;
例如:
_balances[alice] = 100;
表示 alice 这个地址有 100 个代币。
3. _allowances
表示授权关系。
mapping(address => mapping(address => uint256)) private _allowances;
它的含义是:
- 第一层 address:代币拥有者 owner
- 第二层 address:被授权者 spender
- uint256:授权额度
例如:
_allowances[alice][dex] = 500;
表示 dex 最多可以从 alice 那里花 500 个代币。
4. _name
表示代币名称。
string private _name;
例如 "My Token"。
5. _symbol
表示代币符号。
string private _symbol;
例如 "MTK"。
6. _decimals
表示显示精度。
uint8 private _decimals;
通常 ERC-20 代币都会设置为 18。
三、写构造函数
构造函数在合约部署时执行,用来完成初始化。
constructor(string memory name_, string memory symbol_, uint256 initialSupply) {
_name = name_;
_symbol = symbol_;
_decimals = 18;
_totalSupply = initialSupply;
_balances[msg.sender] = initialSupply;
emit Transfer(address(0), msg.sender, initialSupply);
}
构造函数做了什么
1. 设置名称
_name = name_;
把部署时传入的名称保存下来。
2. 设置符号
_symbol = symbol_;
把部署时传入的简称保存下来。
3. 设置精度
_decimals = 18;
设定代币的显示精度为 18。
4. 设置总供应量
_totalSupply = initialSupply;
总代币数量等于初始发行量。
5. 把初始代币给部署者
_balances[msg.sender] = initialSupply;
部署合约的人一开始获得全部初始代币。
6. 发出 Transfer 事件
emit Transfer(address(0), msg.sender, initialSupply);
ERC-20 里,从零地址转到某个地址,表示铸造代币。
四、实现 totalSupply
先从最简单的只读函数开始。
function totalSupply() public view override returns (uint256) {
return _totalSupply;
}
作用
返回当前总代币数量。
五、实现 balanceOf
function balanceOf(address account) public view override returns (uint256) {
return _balances[account];
}
作用
输入一个地址,返回这个地址持有的代币数量。
六、实现 allowance
function allowance(address owner, address spender) public view override returns (uint256) {
return _allowances[owner][spender];
}
作用
查询 owner 授权给 spender 的剩余额度。
七、实现元数据函数
这些函数用于返回代币的基本信息。
1. name
function name() public view returns (string memory) {
return _name;
}
返回代币名称。
2. symbol
function symbol() public view returns (string memory) {
return _symbol;
}
返回代币简称。
3. decimals
function decimals() public view returns (uint8) {
return _decimals;
}
返回代币精度。
八、先写内部转账函数 _transfer
在写外部 transfer 之前,先把真正的转账逻辑写成内部函数。
function _transfer(address from, address to, uint256 value) internal {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
uint256 fromBalance = _balances[from];
require(fromBalance >= value, "ERC20: transfer amount exceeds balance");
_balances[from] = fromBalance - value;
_balances[to] += value;
emit Transfer(from, to, value);
}
_transfer 做了什么
1. 检查发送地址不能是零地址
require(from != address(0), "ERC20: transfer from the zero address");
2. 检查接收地址不能是零地址
require(to != address(0), "ERC20: transfer to the zero address");
3. 读取发送者余额
uint256 fromBalance = _balances[from];
4. 检查余额是否足够
require(fromBalance >= value, "ERC20: transfer amount exceeds balance");
5. 扣减发送者余额
_balances[from] = fromBalance - value;
6. 增加接收者余额
_balances[to] += value;
7. 发出转账事件
emit Transfer(from, to, value);
九、实现 transfer
有了 _transfer 之后,外部转账函数就很简单了。
function transfer(address to, uint256 value) public override returns (bool) {
_transfer(msg.sender, to, value);
return true;
}
作用
表示“调用者把自己的代币转给别人”。
这里的发送者固定就是 msg.sender。
十、先写内部授权函数 _approve
和转账一样,先把内部逻辑写好。
function _approve(address owner, address spender, uint256 value) internal {
require(owner != address(0), "ERC20: approve from the zero address");
require(spender != address(0), "ERC20: approve to the zero address");
_allowances[owner][spender] = value;
emit Approval(owner, spender, value);
}
_approve 做了什么
1. 检查 owner 不能是零地址
require(owner != address(0), "ERC20: approve from the zero address");
2. 检查 spender 不能是零地址
require(spender != address(0), "ERC20: approve to the zero address");
3. 记录授权额度
_allowances[owner][spender] = value;
4. 发出授权事件
emit Approval(owner, spender, value);
十一、实现 approve
function approve(address spender, uint256 value) public override returns (bool) {
_approve(msg.sender, spender, value);
return true;
}
作用
表示“调用者授权 spender 最多可以花自己多少代币”。
注意:
- approve 不会转账
- 它只是记录一个授权额度
十二、先写 _spendAllowance
在实现 transferFrom 之前,先把“消耗授权额度”的逻辑拆出来。
function _spendAllowance(address owner, address spender, uint256 value) internal {
uint256 currentAllowance = allowance(owner, spender);
if (currentAllowance != type(uint256).max) {
require(currentAllowance >= value, "ERC20: insufficient allowance");
_approve(owner, spender, currentAllowance - value);
}
}
_spendAllowance 做了什么
1. 读取当前授权额度
uint256 currentAllowance = allowance(owner, spender);
2. 判断是否是无限授权
if (currentAllowance != type(uint256).max) {
如果授权值是 uint256 最大值,通常表示“无限授权”,这时不扣减。
3. 检查授权是否足够
require(currentAllowance >= value, "ERC20: insufficient allowance");
4. 扣减授权额度
_approve(owner, spender, currentAllowance - value);
十三、实现 transferFrom
function transferFrom(address from, address to, uint256 value) public override returns (bool) {
_spendAllowance(from, msg.sender, value);
_transfer(from, to, value);
return true;
}
作用
表示“调用者根据授权,从 from 账户里转出代币给 to”。
执行流程是:
- 检查并扣减授权额度
- 执行转账
这就是 ERC-20 的“授权转账”机制。
十四、实现 _mint
function _mint(address account, uint256 value) internal {
require(account != address(0), "ERC20: mint to the zero address");
_totalSupply += value;
_balances[account] += value;
emit Transfer(address(0), account, value);
}
作用
内部铸造代币。
_mint 做了什么
1. 检查接收地址不能是零地址
require(account != address(0), "ERC20: mint to the zero address");
2. 增加总供应量
_totalSupply += value;
3. 增加账户余额
_balances[account] += value;
4. 发出铸造事件
emit Transfer(address(0), account, value);
从零地址转到用户地址,表示铸造。
十五、实现 _burn
function _burn(address account, uint256 value) internal {
require(account != address(0), "ERC20: burn from the zero address");
uint256 accountBalance = _balances[account];
require(accountBalance >= value, "ERC20: burn amount exceeds balance");
_balances[account] = accountBalance - value;
_totalSupply -= value;
emit Transfer(account, address(0), value);
}
作用
内部销毁代币。
_burn 做了什么
1. 检查销毁来源地址不能是零地址
require(account != address(0), "ERC20: burn from the zero address");
2. 读取账户余额
uint256 accountBalance = _balances[account];
3. 检查余额是否足够
require(accountBalance >= value, "ERC20: burn amount exceeds balance");
4. 扣减账户余额
_balances[account] = accountBalance - value;
5. 减少总供应量
_totalSupply -= value;
6. 发出销毁事件
emit Transfer(account, address(0), value);
转到零地址,表示销毁。
十六、完整代码
到这里,一个简单的 ERC-20 合约就写完了。
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/**
* @title SimpleERC20
* @dev 简单的ERC20代币合约实现
*/
contract SimpleERC20 is IERC20 {
uint256 private _totalSupply;
mapping(address => uint256) private _balances;
mapping(address => mapping(address => uint256)) private _allowances;
string private _name;
string private _symbol;
uint8 private _decimals;
constructor(string memory name_, string memory symbol_, uint256 initialSupply) {
_name = name_;
_symbol = symbol_;
_decimals = 18;
_totalSupply = initialSupply;
_balances[msg.sender] = initialSupply;
emit Transfer(address(0), msg.sender, initialSupply);
}
function totalSupply() public view override returns (uint256) {
return _totalSupply;
}
function balanceOf(address account) public view override returns (uint256) {
return _balances[account];
}
function transfer(address to, uint256 value) public override returns (bool) {
_transfer(msg.sender, to, value);
return true;
}
function allowance(address owner, address spender) public view override returns (uint256) {
return _allowances[owner][spender];
}
function approve(address spender, uint256 value) public override returns (bool) {
_approve(msg.sender, spender, value);
return true;
}
function transferFrom(address from, address to, uint256 value) public override returns (bool) {
_spendAllowance(from, msg.sender, value);
_transfer(from, to, value);
return true;
}
function name() public view returns (string memory) {
return _name;
}
function symbol() public view returns (string memory) {
return _symbol;
}
function decimals() public view returns (uint8) {
return _decimals;
}
function _transfer(address from, address to, uint256 value) internal {
require(from != address(0), "ERC20: transfer from the zero address");
require(to != address(0), "ERC20: transfer to the zero address");
uint256 fromBalance = _balances[from];
require(fromBalance >= value, "ERC20: transfer amount exceeds balance");
_balances[from] = fromBalance - value;
_balances[to] += value;
emit Transfer(from, to, value);
}
function _approve(address owner, address spender, uint256 value) internal {
require(owner != address(0), "ERC20: approve from the zero address");
require(spender != address(0), "ERC20: approve to the zero address");
_allowances[owner][spender] = value;
emit Approval(owner, spender, value);
}
function _spendAllowance(address owner, address spender, uint256 value) internal {
uint256 currentAllowance = allowance(owner, spender);
if (currentAllowance != type(uint256).max) {
require(currentAllowance >= value, "ERC20: insufficient allowance");
_approve(owner, spender, currentAllowance - value);
}
}
function _mint(address account, uint256 value) internal {
require(account != address(0), "ERC20: mint to the zero address");
_totalSupply += value;
_balances[account] += value;
emit Transfer(address(0), account, value);
}
function _burn(address account, uint256 value) internal {
require(account != address(0), "ERC20: burn from the zero address");
uint256 accountBalance = _balances[account];
require(accountBalance >= value, "ERC20: burn amount exceeds balance");
_balances[account] = accountBalance - value;
_totalSupply -= value;
emit Transfer(account, address(0), value);
}
}
十七、最后抓住这条主线
手写 ERC-20 时,最重要的是记住这条主线:
- 用 _balances 记录余额
- 用 _allowances 记录授权
- 用 transfer 处理自己转账
- 用 approve + transferFrom 处理授权转账
- 用 Transfer 和 Approval 事件告诉外部世界发生了什么
ERC-20 看起来函数不少,但核心其实就两句话:
**余额怎么记,授权怎么记。
转账怎么改账,授权怎么改账。**
这就是整个 ERC-20 的骨架。
2. Remix
Remix是一个Solidiy的IDE
不是和那个前端全栈框架,两个同名,不要弄错了。
打开网页后,一个网页版的编辑器

新建一个文件,把我们写的MyToken粘贴进去

点击右侧的这个按钮: Deploy & run transactions

在CONTRACT这里选择我们刚写的合约

按顺序填写构造函数的参数

然后点击Deploy, 部署到Remix提供的测试环境中

Deployed Contracts里就多了一个我们刚刚部署的Token
右边也答应除了一个部署的交易信息
展开我们的Token后,能看到可以调用的方法,可以读取属性。

然后就可以在这个面板上,点击这些按钮,去操作这个Token合约了。

上方可以找到一个测试Accounts列表

3. (TBD) 实战作业:发行你的第一个 Meme Coin
原始作业页面为空白,内容待补充。
