1. ERC777简介
这是是官方文档的说明:链接。我记录的是我读文档和代码的自我理解。
ERC777与ERC20兼容(兼容的意思就是ERC777的功能包括了ERC20的所有功能,实现兼容的方式就是,让ERC777直接继承IERC20接口。),同时引入了operator操作员的概念,操作员可以代表另一个地址(合约或者普通账户)发送代币,这个操作员的身份类似始于ERC20中被 某地址执行 approve操作后的身份,可以托管授权这的资产。同时还引进了sender和receiver的钩子函数(hooks)让代币持有者和代币接收者能有更多的处理。而且ERC777还采用了ERC1820标准的优点,可以判断某合约是否实现ERC777协议的相关接口,更重要的是还可以将sender/receiver的钩子函数放到 地址的 implement去处理,这样一来,使得整个代币体系更丰富,拓展性也大大增强。
2. ERC777代码解读
源码来自 openzeppelin:链接。阅读该代码必须要有ERC1820的前置知识,ERC777的源码可以分为core和hooks部分。
2.1 Core部分
解读 ERC777.sol
1 | constructor(string memory name,string memory symbol,address[] memory defaultOperators) |
构造函数:在部署ERC777
合约的时候,就需要传入默认的 operator
,而且默认的 operator
不能进行增加和删除操作。而且还会将自身添加到 ERC1820注册表中_erc1820.setInterfaceImplementer(address(this), keccak256("ERC777Token"), address(this));
这行代码的意思就是,记录address(this)
地址实现ERC777Token
接口的合约地址是address(this)
,换句话就是 “我”自己实现了 ERC777
接口,并将其记录在注册表中。
2.1.1 View Funciotns
**name() symbol() decimals() totalSupply() balanceOf()*这几个函数和ERC20的用法一样。需要注意的是:granularity()
函数,granularity*必须在创建的时设置,且不能修改这个值。同时还要保证这个值必须 >=1
,执行 铸币,销币,发送操作的资产数量必须是 granularity
的整数倍,否则就会被revert
(如何理解呢,举个例子,比如granularity
的值是2
,执行mint操作的时候,mint(to,3)就会报错,因为 3%2!=0
。)但是在 oppenzepelin源码中,granularity的值被设置为了1
,所以可以不要太多考虑这个因素。
2.1.2 Operators
操作员是ERC777引入的一个新概念,有普通操作员和默认操作员之分。
- 普通操作员:即是代币的 holder亲自给 operator授权的,该 operator只能操作 该holder的代币。
- 默认操作员:即合约初始化设置的操作员,ta的权限是最大的,默认情况下它可以操作所有人的代币,类似于管理员。但是用户可以自行将其移除权限,这样一来ta就不能操作自己的tokens了。
先理解三个mapping:
1 | // Immutable, but accounts may revoke them (tracked in __revokedDefaultOperators). |
_defaultOperators
:保存某地址是不是默认操作员,在函数初始化的时候赋值。_operators
:保存 某地址是不是某代币holder的操作员,传参的方式为:_operators[tokenHolder][operator]
。_revokedDefaultOperators
:保存某地址是否移除了默认操作员,传参的方式为:_revokedDefaultOperators[tokenHolder][operator])
。
函数理解:
1 | isOperatorFor(address operator,address tokenHolder) |
查看该 operetor是不是tokenHolder的操作员,如果 operator和tokenHolder相等则返回true(因为每个tokenHolder是自己的operator)。如果不等,则需要判断operator是不是默认操作员,同时该默认操作员不能被该tokenHolder取消授权过,如果是且没有取消授权则返回true。第三个代码段_operators[tokenHolder][operator]
则是查看tokenHolder是否对operator授权了,yes true,no false。
1 | authorizeOperator(address operator) |
msg.sender
(tokenHolder)为 指定 operator
执行授权操作,需要注意的是 msg.sender
不能等于operator
,否则revert
,因为msg.sendedr
本身就是自己的操作员,所以不能再次授权。还需要判断该 operator
是不是默认操作员,如果是则执行delete _revokedDefaultOperators[msg.sender][operator]
操作(即将该值变成false),如果不是默认操作员,则将 operators
映射的值更新为true(授权成功)。
1 | revokeOperator(address operator) |
msg.sender
(tokenHolder)撤销 指定 operator
操作员身份,需要注意的是 msg.sender
不能等于operator
,否则revert
,因为msg.sendedr
本身就是自己的操作员,所以不能撤销自己。如果 operator
是默认操作员,则_revokedDefaultOperators[msg.sender][operator] = true
这样一来默认操作员便不能操作msg.sender
的tokens了。如果不是默认操作员,则将operator
的值更新为false
。
1 | defaultOperators() |
返回默认操作员列表。
2.1.3 Send Tokens
1 | function _callTokensToSend( |
_callTokensToSend
函数,先通过注册表getInterfaceImplementer(from, TOKENS_SENDER_INTERFACE_HASH)
查看 tokenHolder 用来实现IERC777TokensSender
接口的合约地址ADDRESS,如果有则调用ADDRESS中的tokensToSend
函数。
1 | function _callTokensReceived( |
_callTokensReceived
函数,先通过注册表getInterfaceImplementer(from, TOKENS_RECIPIENT_INTERFACE_HASH)
查看 tokenHolder 用来实现IERC777Recipient
接口的合约地址ADDRESS,如果有则调用ADDRESS中的tokensReceived
函数,如果没有还需要判断传入的参数requireReceptionAck
,如果参数为 true
,那么则需要检测接收者to
是否为合约地址,如果是合约地址则revert
(其目的即使为了保证contract receiver必须实现 ERC777TokensRecipient
)。
1 | function _move( |
_move
函数则是负责更新余额,修改Holder和receiver的代币余额。
1 | function _send( |
ERC77 Token转移代币的逻辑,基本上都离不开这个函数。该函数要求 tokenHolder和receiver不能为零地址。先调用 tokenHolder的钩子函数,再更新账户的余额,最后调用receiver的钩子函数。
1 | operatorSend( |
调用该函数需要判断 msg.sender是不是 sender的operator。随后调用 _send
函数。
1 | send(address recipient, uint256 amount, bytes calldata data) |
这是提供给tokenHolder的函数,因为调用_send
函数的方式为:_send(msg.sender, msg.sender, recipient, amount, data, "", true);
自己是自己的操作员,同时还要求接收者必须实现ERC777TokensRecipient
接口(如果接收者是合约地址)。
1 | transfer(address recipient, uint256 amount) |
功能类似于send
函数,也是提供给 tokenHolder 的函数。不过不同于send函数是,transfer
不能传userdata
,而且,如果recipient
是合约地址的话,不需要满足合约地址一定要实现ERC777TokensRecipient
接口的要求(前提是接收者不不能为零地址)。
1 | transferFrom(address holder, address recipient, uint256 amount) |
功能类似ERC20中的transferFrom
函数,需要 tokenHolder
为 msg.sender
授权,并且检查授权额度的操作在_approve(holder, spender, _allowances[holder][spender].sub(amount))
,函数体没有显示的判断授权额度,而是通过了直接减的方法来验证,如果amount
大于授权额度这个减法操作肯定会报错,也算一种另类的隐式检验了。
2.1.4 Mint & Burn Tokens
1 | _mint( |
铸币功能,铸多少币 _totalSupply 就要加多少。 铸币会调用_callTokensReceived
函数,tokenHolder为零地址,而且还需要检测 recipient,如果recipient
是合约地址的话,不需要满足合约地址一定要实现ERC777TokensRecipient
接口的要求(前提是接收者不不能为零地址)。
注:在调用_mint()函数的函数中,要严格添加访问控制,因为 _mint()函数本身没有访问控制,避免出现人人可铸币的现象。
1 | _burn( |
销币功能,私有函数,销毁多少币,_totalSupply 就要减多少。销币回调用_callTokensToSend
函数,recipient为零地址。
1 | operatorBurn(address account, uint256 amount, bytes calldata data, bytes calldata operatorData) |
外部销币函数,必须要满足isOperatorFor(msg.sender, account)
才能成功调用。
2.2 Hooks部分
2.2.1 ERC777TokensSender :: tokensToSend Hook
这是一个执行转账或销币的前置钩子函数,即在执行转账之前需要从 ERC1820注册表中获取 实现接口的合约implementer
,并执行 implementer
合约中的tokensToSend ()
函数,执行该钩子函数的逻辑。至于是什么逻辑,具体取决于合约的编写者,如果是执行恶意操作则可能会造成资金损失(重入攻击)。
涉及到的函数有:transferFrom() transfer() send() operatorSend() operatorBurn()。
2.2.2 ERC777TokensRecipient :: tokensReceived Hook
这是一个执行转账或铸币的后置钩子函数,即在执行转账之后(更新完账户余额,即执行完 _move()
函数)需要从 ERC1820注册表中获取 实现接口的合约implementer
,并执行 implementer
合约中的tokensReceived()
函数,执行该钩子函数的逻辑。逻辑取决于合约的编写者,如果是执行恶意操作则可能会造成资金损失(重入攻击)。
涉及到的函数有:transferFrom() transfer() send() operatorSend() _mint()。
2.2.3 Hook的工作原理复现
复现原理为:address(this)通过 operatorSend(address(this), address(this), 0, "", "")
自己给自己转账,再执行转账之前,将address(this)的实现 IERC777Sender
接口的合约为 sender,并写入注册表中;同理将address(this)的实现 IERC777Recipient
接口的合约为 recipient,并写入注册表中。复现旨在说明,执行转账操作时,余额更新前后可以做一系列操作,需要注意防范。在 sender::tokensToSend输出事件emit BeforeMove(operator, from, to, amount, "====== BeforeMove() ======")
,在recipient::tokensReceived也输出事件emit AfterMove(operator, from, to, amount, "====== AfterMove() ======")
。
TestHooks.sol
1 | // SPDX-License-Identifier: UNLICENSED |
3. ERC777的安全隐患
ERC777协议存在的安全隐患便是:重入漏洞(基本上就是在
tokensToSend()
和tokensReceived()
)。该漏洞存在于_callTokensToSend()
和_callTokensReceived()
。具体的攻击事件和攻击步骤,后面再补充。