跳到文章正文
03 / 合约

标准协议与组合

理解 ERC-20 的余额与授权系统,练习代币合约和 Remix 交互。

约 29 分钟阅读
第 3 课:标准协议与组合
本文为作者已发布教程的站内版本。在 X 查看原文与讨论

学习目标: 理解 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”。

执行流程是:

  1. 检查并扣减授权额度
  2. 执行转账

这就是 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

不是和那个前端全栈框架,两个同名,不要弄错了。

打开网页后,一个网页版的编辑器

标准协议与组合 · 配图 1

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

标准协议与组合 · 配图 2

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

标准协议与组合 · 配图 3

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

标准协议与组合 · 配图 4

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

标准协议与组合 · 配图 5

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

标准协议与组合 · 配图 6

Deployed Contracts里就多了一个我们刚刚部署的Token

右边也答应除了一个部署的交易信息

展开我们的Token后,能看到可以调用的方法,可以读取属性。

标准协议与组合 · 配图 7

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

标准协议与组合 · 配图 8

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

标准协议与组合 · 配图 9

3. (TBD) 实战作业:发行你的第一个 Meme Coin

原始作业页面为空白,内容待补充。

关于作者

,现居湖北武汉,拥有 10 多年经验的全栈开发工程师,分享 AI 编程、Web3 与独立开发实践。

X 原文