抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

前言

前面已经大概理解了 ERC20 是什么,也知道了 ERC20 的核心其实就是余额表、授权表、转账、授权转账、事件、metadata 这些东西。

下面继续往生产项目的方向走一下,学习下真实项目里通常怎么实现 ERC20。

先说结论:生产项目一般不会自己从零手写 ERC20,而是优先使用 OpenZeppelin Contracts,能复用成熟库就复用成熟库。

895dd70e-c120-44da-9616-ba953a0b7a54

OpenZeppelin 是什么

OpenZeppelin 可以理解成 Solidity 生态里非常常用的一套基础库,很多常见合约模式封装好了,比如:

  • ERC20
  • ERC721
  • ERC1155
  • Ownable
  • AccessControl
  • Pausable
  • ReentrancyGuard
  • Timelock
  • Governor
  • Proxy

所以实际开发 ERC20 时,我们通不用手写如下代码:

1
2
3
mapping(address => uint256) private _balances;

mapping(address => mapping(address => uint256)) private _allowances;

而是直接继承 OpenZeppelinERC20 实现,然后组合自己需要的业务能力。

为什么生产项目不用纯手写 ERC20

当我知道这个库的时候,我在想为啥需要专门一个库来实现ERC20呢?后面查了一下才知道ERC20看起来简单,但边角细节不少。

比如:

  • 转账是否允许转到零地址
  • mint 和 burn 是否正确触发 Transfer 事件
  • allowance 是否正确扣减
  • 无限授权 type(uint256).max 怎么处理
  • 某些扩展组合时,继承顺序和 override 是否正确
  • pause 时到底暂停 transfer、mint、burn 中的哪些操作
  • cap 限制是否真的覆盖所有 mint 路径
  • permit 签名授权是否防重放

这些东西自己写不是不行,但是每一处都可能踩坑。

OpenZeppelin 的价值就在这里:它把标准实现和常见扩展都封装好了,我们只需要在上面做业务组合。

因此下面来学习怎么用 OpenZeppelin 实现 ERC20,功能包括:

  • 标准 ERC20
  • 最大供应量 cap
  • 管理员 mint
  • 用户 burn
  • pause / unpause
  • 基于角色的权限控制
  • permit 签名授权
  • 权限控制AccessControl,因为生产项目里经常需要拆分权限。、
    • DEFAULT_ADMIN_ROLE:角色管理员
    • MINTER_ROLE:可以增发 token
    • PAUSER_ROLE:可以暂停和恢复合约

合约代码

这里学习使用的是 OpenZeppelin Contracts v5 风格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {ERC20Capped} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Capped.sol";
import {ERC20Pausable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol";
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";

contract MyOZToken is
ERC20,
ERC20Burnable,
ERC20Capped,
ERC20Pausable,
ERC20Permit,
AccessControl
{
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

error ZeroAddress();
error InitialSupplyExceedsCap(uint256 initialSupply, uint256 cap);

constructor(
string memory tokenName,
string memory tokenSymbol,
address admin,
address treasury,
uint256 initialSupply,
uint256 maxSupply
)
ERC20(tokenName, tokenSymbol)
ERC20Capped(maxSupply)
ERC20Permit(tokenName)
{
if (admin == address(0) || treasury == address(0)) {
revert ZeroAddress();
}

if (initialSupply > maxSupply) {
revert InitialSupplyExceedsCap(initialSupply, maxSupply);
}

_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(MINTER_ROLE, admin);
_grantRole(PAUSER_ROLE, admin);

_mint(treasury, initialSupply);
}

function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
_mint(to, amount);
}

function pause() external onlyRole(PAUSER_ROLE) {
_pause();
}

function unpause() external onlyRole(PAUSER_ROLE) {
_unpause();
}

function _update(address from, address to, uint256 value)
internal
override(ERC20, ERC20Capped, ERC20Pausable)
{
super._update(from, to, value);
}
}

代码拆解

先看 import。

1
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";

这个是 ERC20 基础实现,里面已经实现了:

  • totalSupply
  • balanceOf
  • transfer
  • approve
  • allowance
  • transferFrom

ERC20Burnable

1
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";

这个扩展提供了下面两个方法:

1
2
3
burn(uint256 amount)  // 销毁自己的 token。

burnFrom(address account, uint256 amount) // 基于 allowance 销毁别人的 token,比如 Alice 授权 Bob 可以花 100 个 token,Bob 就可以在额度内执行 `burnFrom(Alice, 50)`。

ERC20Capped

1
import {ERC20Capped} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Capped.sol";

这个扩展用于限制最大供应量。

比如:maxSupply = 10000000000000000000000

因为默认 decimals = 18,所以这个表示最大供应量是:10000 token

有了 cap 之后,mint 不能无限增发。

这个在生产项目里很重要,因为如果没有 cap,用户就必须完全信任管理员不会乱 mint。

ERC20Pausable

1
import {ERC20Pausable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol";

这个扩展用于紧急暂停,暂停后,token 的转账相关操作会失败。

典型使用场景:

  • 发现合约或业务系统异常
  • 发现私钥或权限泄露
  • 发现跨链桥、金库、质押池等外部系统被攻击

ERC20Permit

1
import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";

这个是 EIP-2612 permit。

普通 approve 需要用户发一笔链上交易:

1
approve(spender, amount)

permit 的思路是:用户先在链下签名,合约再在链上校验签名并设置 allowance。

这样可以做到:不用用户先单独发 approve 交易,很多 DeFi 协议喜欢这个能力,因为它可以减少一次链上交互。

AccessControl

1
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

这个是角色权限的依赖,例如下面我们定义了两个角色,然后在构造函数里给 admin 授权:

1
2
3
4
5
6
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(MINTER_ROLE, admin);
_grantRole(PAUSER_ROLE, admin);

这里要特别注意:DEFAULT_ADMIN_ROLE 是管理员角色,它可以授予和撤销其他角色。真实生产项目里,这个地址通常不应该是普通个人钱包,而应该是:

  • multisig 多签

  • timelock

  • DAO 治理合约

构造函数参数

部署时需要传这些参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
constructor(

string memory tokenName, // 代币名称

string memory tokenSymbol, // 代币符号

address admin, // 管理员地址

address treasury, // 初始代币接受地址

uint256 initialSupply, // 初始供应量

uint256 maxSupply // 最大供应量

)

比如我要创建一个测试代币:

1
2
3
4
name: OpenZeppelin Demo Token
symbol: OZD
初始供应量: 1000
最大供应量: 10000

因为默认 decimals = 18,所以 Remix 里参数应该填:

1
2
initialSupply = 1000000000000000000000
maxSupply = 10000000000000000000000

为什么需要 _update

这个是 OpenZeppelin v5 里非常关键的点。

在 v5 里,ERC20 的核心状态变化统一收敛到了 _update

1
2
3
4
5
_update(from, to, value) // 普通转账

_update(address(0),to,value) // mint方法

_update(from,address(0),value) // burn方法

我们这个合约同时继承了:

1
2
ERC20Capped
ERC20Pausable

这两个扩展都需要参与 _update

所以最终合约必须表明有多个父类:

1
2
3
4
5
6
7
8
9
10
11
function _update(address from, address to, uint256 value)

internal

override(ERC20, ERC20Capped, ERC20Pausable)

{

super._update(from, to, value);

}

Remix 部署

在 Remix 中新建 MyOZToken.sol,粘贴上面的代码。

1
2
3
4
5
6
7
8
9
版本选择:0.8.24


tokenName: OpenZeppelin Demo Token
tokenSymbol: OZD
admin: Remix 账户地址
treasury: Remix 账户地址
initialSupply: 1000000000000000000000
maxSupply: 10000000000000000000000

测试 mint

1
mint(某个地址, 1000000000000000000) //  如果调用者有MINTER_ROLE,就会成功 mint 1 个 token。

测试 pause

1
2
3
pause() // 如果调用者有MINTER_ROLE,就会成功暂停转账等操作,紧急暂停机制。
transfer(某个地址, 1000000000000000000) // 预期是转账失败
unpause() // 恢复转账操作

测试 burn

如果你自己有 token,可以调用:

1
burn(1000000000000000000) // 销毁你自己的 1 个 token。你的余额减少,总供应也减少

小结

这篇主要学习了如何用 OpenZeppelin 实现 ERC20。

主要内容就是:

  1. 不要重复造标准轮子
  2. 用 ERC20 做基础能力
  3. 用 Capped 控制供应量
  4. 用 Burnable 支持销毁
  5. 用 Pausable 做紧急暂停
  6. 用 Permit 支持签名授权
  7. 用 AccessControl 拆分权限