ERC777与ERC20兼容(兼容的意思就是ERC777的功能包括了ERC20的所有功能,实现兼容的方式就是,让ERC777直接继承IERC20接口。),同时引入了operator操作员的概念。
ERC1820标准定义了一个通用的注册表合约,任何地址(不管是合约地址还是E0A账户地址)都可以注册它支持的接口以及哪个智能合约负责接口实现。
source code:链接。如果想看每个函数的各个参数代表什么意思,可以到这里:链接。
mapping(address => mapping(bytes32 => address)) internal interfaces:作用是保存某地址实现某接口的地址(说实话我感觉怪怪的,我感觉有点说不过去,举个例子:接口为I,A实现I的地址是B,换句话说就是,A用B地址来实现I接口,类似代理合约的逻辑,proxy基本上都是通过logic合约来执行逻辑)。
mapping(address => address) internal managers:作用是保存某地址的管理员
mapping(address => mapping(bytes4 => bool)) internal erc165Cached:作用是用作缓存表,用来记录某地址是否实现了 IERC165接口。
noThrowCall(address _contract, bytes4 _interfaceId)函数,的运作原理是_contract.staticcall(abi.encodeWithSelector(ERC165.supportsInterface.selector,_interfaceId))
,即就是为了检测_contract合约是否实现了 IERC165且是否实现了指定接口_interfaceId
。两个返回值的意思分别是,调用函数是否成功,返回值是否为true。
对于
noThrowCall()
函数,可以查缺补漏。我好奇的是,对于 bytes4类型的 _interfaceId,执行
mstore(add(x, 0x04), _interfaceId)
操作之后,再预存储的32bytes里,ta是会被放在左端还是右端,同理对mstore(x, erc165ID)
也是一样好奇,但是按照编码规则calldata应该为:bytes4(functon.selector)+paramters
,所以应该是放在左端,写过测试用例验证:pragma solidity ^0.8.0; contract TestAssembly { event msgdata(bytes); function test() public { emit msgdata(msg.data); } function noThrowCall(address _contract, bytes4 _interfaceId) public returns (uint256 success, uint256 result) { bytes4 erc165ID = 0xf8a8fd6d; assembly { let x := mload(0x40) // Find empty storage location using "free memory pointer" mstore(x, erc165ID) // Place signature at beginning of empty storage mstore(add(x, 0x04), _interfaceId) // Place first argument directly next to signature success := call( gas(), // 30k gas _contract, // To addr 0, // msg.value x, // Inputs are stored at location x 0x24, // Inputs are 36 (4 + 32) bytes long x, // Store output over input (saves space) 0x20 // Outputs are 32 bytes long ) result := mload(x) // Load the result } } }
当
_interfaceId
为uint32
类型时:pragma solidity ^0.8.0; contract TestAssembly { event msgdata(bytes); function test() public { emit msgdata(msg.data); } function noThrowCall(address _contract, uint32 _interfaceId) public returns (uint256 success, uint256 result) { bytes4 erc165ID = 0xf8a8fd6d; assembly { let x := mload(0x40) // Find empty storage location using "free memory pointer" mstore(x, erc165ID) // Place signature at beginning of empty storage mstore(add(x, 0x04), _interfaceId) // Place first argument directly next to signature success := call( gas(), // 30k gas _contract, // To addr 0, // msg.value x, // Inputs are stored at location x 0x24, // Inputs are 36 (4 + 32) bytes long x, // Store output over input (saves space) 0x20 // Outputs are 32 bytes long ) result := mload(x) // Load the result } } }
所以对于 bytes(n)类型的操作,写入方式为从高位写入(即左端写入),而对于uint(n)类型则是从低位写入(即右端写入)。
implementsERC165InterfaceNoCache(address _contract, bytes4 _interfaceId)函数,作用是在不使用或更新缓存的情况下检查合约是否实现IERC165接口,且是否实现 _interfaceId接口。检查的方式很简单,即调用noThrowCall()
函数,只有当函数调用成功,且实现了 _interfaceId 接口(即 result==true )时,该函数才返回true。
isERC165Interface(bytes32 _interfaceHash)函数用来检测,传入的接口hash值是不是IERC165接口,判断方法就是:对传入的参数进行与运算,如果后28为0,那么则判断该接口为IERC165接口。
implementsERC165Interface(address _contract, bytes4 _interfaceId)函数,作用是检查 _contract合约是否实现了 _interfaceId(多指IERC165),如果不在缓存表中存储过,则通过implementsERC165InterfaceNoCache()
函数检测,如果在缓存表中,则判断用于实现 _interfaceId的合约是不是参数 _contract本身,yes return true,no return false。
updateERC165Cache(address _contract, bytes4 _interfaceId)函数,通过implementsERC165InterfaceNoCache()
函数来判断 _contract 是否实现了 _interfaceId,如果实现了则更新 interfaces映射,同时更新缓存(这个换成始终都是被设置为true,有什么用呢?我的理解是,在implementsERC165Interface()
函数中就用的了这个映射,如果这个映射的值为 fasle则会调用implementsERC165InterfaceNoCache()
函数,那么如果值为true那么则不需要调用。那么这样一来就可以节约gas的花费了)。
getManager(address _addr)函数,查询某地址的管理员是谁,如果没有管理员,则返回自己(相对于自己的管理员是自己)。
setManager(address _addr, address _newManager)函数,设置管理员,确保只能是 _addr的管理员亲自调用,如果管理员给自己又设置一边管理员,那么managers[_addr] =address(0)
,相对于重置管理员了,管理员变成了_addr
自己。
getInterfaceImplementer(address _addr, bytes32 _interfaceHash)函数,查询地址是否实现了接口以及通过哪个合约实现的,如果 _addr是零地址则将 _addr看作是msg.sender。先判断是 IERC165接口hash吗,如果是,则通过implementsERC165Interface()
来获取实现的地址,如果不是则通过映射interfaces来查看。
setInterfaceImplementer(address _addr, bytes32 _interfaceHash, address _implementer):设置某个地址的接口由哪个合约实现,需要由管理员来设置,待设置的关联接口的地址(如果'_addr'是零地址,则假定为'msg.sender'),而且 _interfaceHash 不能为 IERC165,然后通过实现者_implementer
的canImplementInterfaceForAddress()
函数来检测,如果实现了则通过。这个函数可以在这里:链接看到实现逻辑(这里需要注意重入风险,具体情况具体分析)。
注:ERC1820ImplementerInterface(_implementer).canImplementInterfaceForAddress(_interfaceHash, addr)
这行代码很重要,有重入风险,同时要求实现者_implementer
必须按要求返回指定的值ERC1820_ACCEPT_MAGIC
。
ERC1820协议主要用于以太坊智能合约的接口查询和管理。它为智能合约之间的交互提供了标准化的方式,使得合约可以公开声明并查询它们所实现的接口。这对于智能合约的互操作性和扩展性非常重要。
以下是一些具体的应用场景:
- 合约功能发现:通过ERC1820协议,合约可以公开声明它们实现的接口,其他合约就可以查询这些接口,了解如何与该合约交互。这使得合约之间的交互更为灵活和高效。
- 合约升级:智能合约一旦部署,其代码就不能更改。但是,通过ERC1820协议,可以将接口实现的逻辑放在另一个可以升级的合约中。这样,即使主合约的代码不变,也可以通过更改实现接口的合约来升级功能。
- 合约互操作性:ERC1820协议支持任意接口的注册,这使得不同的合约可以实现和支持各种各样的接口,大大增强了合约之间的互操作性。
- 合约安全性:ERC1820协议的查询机制可以避免对未实现特定接口的合约进行错误的调用,从而增加了智能合约的安全性。
总的来说,ERC1820协议的作用就是提供了一种标准化的方式,让智能合约可以公开声明和查询接口,从而简化了合约之间的互动。
有了这些前置知识,就可以继续学习ERC777了。
这是是官方文档的说明:链接。我记录的是我读文档和代码的自我理解。
ERC777与ERC20兼容(兼容的意思就是ERC777的功能包括了ERC20的所有功能,实现兼容的方式就是,让ERC777直接继承IERC20接口。),同时引入了operator操作员的概念,操作员可以代表另一个地址(合约或者普通账户)发送代币,这个操作员的身份类似始于ERC20中被 某地址执行 approve操作后的身份,可以托管授权这的资产。同时还引进了sender和receiver的钩子函数(hooks)让代币持有者和代币接收者能有更多的处理。而且ERC777还采用了ERC1820标准的优点,可以判断某合约是否实现ERC777协议的相关接口,更重要的是还可以将sender/receiver的钩子函数放到 地址的 implement去处理,这样一来,使得整个代币体系更丰富,拓展性也大大增强。
源码来自 openzeppelin:链接。阅读该代码必须要有ERC1820的前置知识,ERC777的源码可以分为core和hooks部分。
解读 ERC777.sol
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
接口,并将其记录在注册表中。
name() symbol() decimals() totalSupply() balanceOf()这几个函数和ERC20的用法一样。需要注意的是:granularity()
函数,granularity必须在创建的时设置,且不能修改这个值。同时还要保证这个值必须 >=1
,执行 铸币,销币,发送操作的资产数量必须是 granularity
的整数倍,否则就会被revert
(如何理解呢,举个例子,比如granularity
的值是2
,执行mint操作的时候,mint(to,3)就会报错,因为3%2!=0
。)但是在 oppenzepelin源码中,granularity的值被设置为了1
,所以可以不要太多考虑这个因素。
操作员是ERC777引入的一个新概念,有普通操作员和默认操作员之分。
先理解三个mapping:
// Immutable, but accounts may revoke them (tracked in __revokedDefaultOperators).
mapping(address => bool) private _defaultOperators;
// For each account, a mapping of its operators and revoked default operators.
mapping(address => mapping(address => bool)) private _operators;
mapping(address => mapping(address => bool)) private _revokedDefaultOperators;
_defaultOperators
:保存某地址是不是默认操作员,在函数初始化的时候赋值。
_operators
:保存 某地址是不是某代币holder的操作员,传参的方式为:_operators[tokenHolder][operator]
。
_revokedDefaultOperators
:保存某地址是否移除了默认操作员,传参的方式为:_revokedDefaultOperators[tokenHolder][operator])
。
函数理解:
isOperatorFor(address operator,address tokenHolder)
查看该 operetor是不是tokenHolder的操作员,如果 operator和tokenHolder相等则返回true(因为每个tokenHolder是自己的operator)。如果不等,则需要判断operator是不是默认操作员,同时该默认操作员不能被该tokenHolder取消授权过,如果是且没有取消授权则返回true。第三个代码段_operators[tokenHolder][operator]
则是查看tokenHolder是否对operator授权了,yes true,no false。
authorizeOperator(address operator)
msg.sender
(tokenHolder)为 指定 operator
执行授权操作,需要注意的是 msg.sender
不能等于operator
,否则revert
,因为msg.sendedr
本身就是自己的操作员,所以不能再次授权。还需要判断该 operator
是不是默认操作员,如果是则执行delete _revokedDefaultOperators[msg.sender][operator]
操作(即将该值变成false),如果不是默认操作员,则将 operators
映射的值更新为true(授权成功)。
revokeOperator(address operator)
msg.sender
(tokenHolder)撤销 指定 operator
操作员身份,需要注意的是 msg.sender
不能等于operator
,否则revert
,因为msg.sendedr
本身就是自己的操作员,所以不能撤销自己。如果 operator
是默认操作员,则_revokedDefaultOperators[msg.sender][operator] = true
这样一来默认操作员便不能操作msg.sender
的tokens了。如果不是默认操作员,则将operator
的值更新为false
。
defaultOperators()
返回默认操作员列表。
function _callTokensToSend(
address operator,
address from,
address to,
uint256 amount,
bytes memory userData,
bytes memory operatorData
)
private
{
address implementer = _erc1820.getInterfaceImplementer(from, TOKENS_SENDER_INTERFACE_HASH);
if (implementer != address(0)) {
IERC777Sender(implementer).tokensToSend(operator, from, to, amount, userData, operatorData);
}
}
_callTokensToSend
函数,先通过注册表getInterfaceImplementer(from, TOKENS_SENDER_INTERFACE_HASH)
查看 tokenHolder 用来实现IERC777TokensSender
接口的合约地址ADDRESS,如果有则调用ADDRESS中的tokensToSend
函数。
function _callTokensReceived(
address operator,
address from,
address to,
uint256 amount,
bytes memory userData,
bytes memory operatorData,
bool requireReceptionAck
)
private
{
address implementer = _erc1820.getInterfaceImplementer(to, TOKENS_RECIPIENT_INTERFACE_HASH);
if (implementer != address(0)) {
IERC777Recipient(implementer).tokensReceived(operator, from, to, amount, userData, operatorData);
} else if (requireReceptionAck) {
require(!to.isContract(), "ERC777: token recipient contract has no implementer for ERC777TokensRecipient");
}
}
_callTokensReceived
函数,先通过注册表getInterfaceImplementer(from, TOKENS_RECIPIENT_INTERFACE_HASH)
查看 tokenHolder 用来实现IERC777Recipient
接口的合约地址ADDRESS,如果有则调用ADDRESS中的tokensReceived
函数,如果没有还需要判断传入的参数requireReceptionAck
,如果参数为 true
,那么则需要检测接收者to
是否为合约地址,如果是合约地址则revert
(其目的即使为了保证contract receiver必须实现 ERC777TokensRecipient
)。
function _move(
address operator,
address from,
address to,
uint256 amount,
bytes memory userData,
bytes memory operatorData
)
private
{
_balances[from] = _balances[from].sub(amount);
_balances[to] = _balances[to].add(amount);
emit Sent(operator, from, to, amount, userData, operatorData);
emit Transfer(from, to, amount);
}
_move
函数则是负责更新余额,修改Holder和receiver的代币余额。
function _send(
address operator,
address from,
address to,
uint256 amount,
bytes memory userData,
bytes memory operatorData,
bool requireReceptionAck
)
private
{
require(from != address(0), "ERC777: send from the zero address");
require(to != address(0), "ERC777: send to the zero address");
_callTokensToSend(operator, from, to, amount, userData, operatorData);
_move(operator, from, to, amount, userData, operatorData);
_callTokensReceived(operator, from, to, amount, userData, operatorData, requireReceptionAck);
}
ERC77 Token转移代币的逻辑,基本上都离不开这个函数。该函数要求 tokenHolder和receiver不能为零地址。先调用 tokenHolder的钩子函数,再更新账户的余额,最后调用receiver的钩子函数。
operatorSend(
address sender,
address recipient,
uint256 amount,
bytes calldata data,
bytes calldata operatorData
)
调用该函数需要判断 msg.sender是不是 sender的operator。随后调用 _send
函数。
send(address recipient, uint256 amount, bytes calldata data)
这是提供给tokenHolder的函数,因为调用_send
函数的方式为:_send(msg.sender, msg.sender, recipient, amount, data, "", true);
自己是自己的操作员,同时还要求接收者必须实现ERC777TokensRecipient
接口(如果接收者是合约地址)。
transfer(address recipient, uint256 amount)
功能类似于send
函数,也是提供给 tokenHolder 的函数。不过不同于send函数是,transfer
不能传userdata
,而且,如果recipient
是合约地址的话,不需要满足合约地址一定要实现ERC777TokensRecipient
接口的要求(前提是接收者不不能为零地址)。
transferFrom(address holder, address recipient, uint256 amount)
功能类似ERC20中的transferFrom
函数,需要 tokenHolder
为 msg.sender
授权,并且检查授权额度的操作在_approve(holder, spender, _allowances[holder][spender].sub(amount))
,函数体没有显示的判断授权额度,而是通过了直接减的方法来验证,如果amount
大于授权额度这个减法操作肯定会报错,也算一种另类的隐式检验了。
_mint(
address operator,
address account,
uint256 amount,
bytes memory userData,
bytes memory operatorData
)
铸币功能,铸多少币 _totalSupply 就要加多少。 铸币会调用_callTokensReceived
函数,tokenHolder为零地址,而且还需要检测 recipient,如果recipient
是合约地址的话,不需要满足合约地址一定要实现ERC777TokensRecipient
接口的要求(前提是接收者不不能为零地址)。
注:在调用_mint()函数的函数中,要严格添加访问控制,因为 _mint()函数本身没有访问控制,避免出现人人可铸币的现象。
_burn(
address operator,
address from,
uint256 amount,
bytes memory data,
bytes memory operatorData
)
销币功能,私有函数,销毁多少币,_totalSupply 就要减多少。销币回调用_callTokensToSend
函数,recipient为零地址。
operatorBurn(address account, uint256 amount, bytes calldata data, bytes calldata operatorData)
外部销币函数,必须要满足isOperatorFor(msg.sender, account)
才能成功调用。
这是一个执行转账或销币的前置钩子函数,即在执行转账之前需要从 ERC1820注册表中获取 实现接口的合约implementer
,并执行 implementer
合约中的tokensToSend ()
函数,执行该钩子函数的逻辑。至于是什么逻辑,具体取决于合约的编写者,如果是执行恶意操作则可能会造成资金损失(重入攻击)。
涉及到的函数有:transferFrom() transfer() send() operatorSend() operatorBurn()。
这是一个执行转账或铸币的后置钩子函数,即在执行转账之后(更新完账户余额,即执行完 _move()
函数)需要从 ERC1820注册表中获取 实现接口的合约implementer
,并执行 implementer
合约中的tokensReceived()
函数,执行该钩子函数的逻辑。逻辑取决于合约的编写者,如果是执行恶意操作则可能会造成资金损失(重入攻击)。
涉及到的函数有:transferFrom() transfer() send() operatorSend() _mint()。
复现原理为: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
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import {IERC777Sender} from "./IERC777Sender.sol";
import {IERC777Recipient} from "./IERC777Recipient.sol";
import {IERC1820Registry} from "../ERC1820/IERC1820Registry.sol";
import {ERC777} from "./ERC777.sol";
contract TestHooks is Test {
string constant ERC1820_PATH = "out/ERC1820.sol/ERC1820Registry.json";
string constant ERC777Sender_PATH = "out/IERC777Sender.sol/ERC777Sender.json";
string constant ERCRecipient_PATH = "out/IERC777Recipient.sol/ERC777Recipient.json";
// string constant ERC777_PATH = "../../../out/ERC777.sol/ERC777.json";
IERC777Sender sender;
IERC777Recipient recipient;
IERC1820Registry registry;
ERC777 erc777;
address[] defaultOperators;
// to deploy these contracts
function setUp() public {
sender = IERC777Sender(deployer(ERC777Sender_PATH));
recipient = IERC777Recipient(deployer(ERCRecipient_PATH));
registry = IERC1820Registry(deployer(ERC1820_PATH));
// erc777 = ERC777(deployer(ERC777_PATH));
defaultOperators.push(address(this));
erc777 = new ERC777("TOKEN", "token", address(registry), defaultOperators);
}
function pre_test() internal {
// address(this)作为 defaultOperator
// 1. address(this)用来实现 tokensToSend 函数的合约是 sender,将其写入注册表
registry.setInterfaceImplementer(address(this), keccak256(abi.encodePacked("ERC777TokensSender")), address(sender));
// 2. address(this)用来实现 tokensReceived 函数的合约是 recipient,将其写入注册表
registry.setInterfaceImplementer(address(this), keccak256(abi.encodePacked("ERC777TokensRecipient")), address(recipient));
}
function test_Hooks() public {
pre_test();
// 通过自己给自己转账,触发 sender和recipient中的hook函数
erc777.operatorSend(address(this), address(this), 0, "", "");
}
// 部署合约
function deployer(string memory path) internal returns (address addr_) {
bytes memory creationCode = abi.encodePacked(vm.getCode(path));
assembly {
addr_ := create(0, add(creationCode, 0x20), mload(creationCode))
}
}
}
ERC777协议存在的安全隐患便是:重入漏洞(基本上就是在
tokensToSend()
和tokensReceived()
)。该漏洞存在于_callTokensToSend()
和_callTokensReceived()
。具体的攻击事件和攻击步骤,后面再补充。
码字不易,请点个赞😜再走呗~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!