前言
前面已经大概理解了 ERC20 是什么,也知道了 ERC20 的核心其实就是余额表、授权表、转账、授权转账、事件、metadata 这些东西。
下面继续往生产项目的方向走一下,学习下真实项目里通常怎么实现 ERC20。
先说结论:生产项目一般不会自己从零手写 ERC20,而是优先使用 OpenZeppelin Contracts,能复用成熟库就复用成熟库。
OpenZeppelin 是什么
OpenZeppelin 可以理解成 Solidity 生态里非常常用的一套基础库,很多常见合约模式封装好了,比如:
- ERC20
- ERC721
- ERC1155
- Ownable
- AccessControl
- Pausable
- ReentrancyGuard
- Timelock
- Governor
- Proxy
所以实际开发 ERC20 时,我们通不用手写如下代码:
1 | mapping(address => uint256) private _balances; |
而是直接继承 OpenZeppelin 的 ERC20 实现,然后组合自己需要的业务能力。
为什么生产项目不用纯手写 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:可以增发 tokenPAUSER_ROLE:可以暂停和恢复合约
合约代码
这里学习使用的是 OpenZeppelin Contracts v5 风格。
1 | // SPDX-License-Identifier: MIT |
代码拆解
先看 import。
1 | import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; |
这个是 ERC20 基础实现,里面已经实现了:
totalSupplybalanceOftransferapproveallowancetransferFrom
ERC20Burnable
1 | import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; |
这个扩展提供了下面两个方法:
1 | burn(uint256 amount) // 销毁自己的 token。 |
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 | bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); |
这里要特别注意:DEFAULT_ADMIN_ROLE 是管理员角色,它可以授予和撤销其他角色。真实生产项目里,这个地址通常不应该是普通个人钱包,而应该是:
multisig 多签
timelock
DAO 治理合约
构造函数参数
部署时需要传这些参数:
1 | constructor( |
比如我要创建一个测试代币:
1 | name: OpenZeppelin Demo Token |
因为默认 decimals = 18,所以 Remix 里参数应该填:
1 | initialSupply = 1000000000000000000000 |
为什么需要 _update
这个是 OpenZeppelin v5 里非常关键的点。
在 v5 里,ERC20 的核心状态变化统一收敛到了 _update。
1 | _update(from, to, value) // 普通转账 |
我们这个合约同时继承了:
1 | ERC20Capped |
这两个扩展都需要参与 _update。
所以最终合约必须表明有多个父类:
1 | function _update(address from, address to, uint256 value) |
Remix 部署
在 Remix 中新建 MyOZToken.sol,粘贴上面的代码。
1 | 版本选择:0.8.24 |
测试 mint
1 | mint(某个地址, 1000000000000000000) // 如果调用者有MINTER_ROLE,就会成功 mint 1 个 token。 |
测试 pause
1 | pause() // 如果调用者有MINTER_ROLE,就会成功暂停转账等操作,紧急暂停机制。 |
测试 burn
如果你自己有 token,可以调用:
1 | burn(1000000000000000000) // 销毁你自己的 1 个 token。你的余额减少,总供应也减少 |
小结
这篇主要学习了如何用 OpenZeppelin 实现 ERC20。
主要内容就是:
- 不要重复造标准轮子
- 用 ERC20 做基础能力
- 用 Capped 控制供应量
- 用 Burnable 支持销毁
- 用 Pausable 做紧急暂停
- 用 Permit 支持签名授权
- 用 AccessControl 拆分权限