深入剖析 ERC777

  • BY_DLIFE
  • 更新于 2024-04-24 21:09
  • 阅读 148

ERC777与ERC20兼容(兼容的意思就是ERC777的功能包括了ERC20的所有功能,实现兼容的方式就是,让ERC777直接继承IERC20接口。),同时引入了operator操作员的概念。

前置知识

ERC1820简介

ERC1820标准定义了一个通用的注册表合约,任何地址(不管是合约地址还是E0A账户地址)都可以注册它支持的接口以及哪个智能合约负责接口实现。

ERC1820代码解读

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
        }
    }
}

image.png

_interfaceIduint32类型时:

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
        }
    }
}

image.png

所以对于 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,然后通过实现者_implementercanImplementInterfaceForAddress()函数来检测,如果实现了则通过。这个函数可以在这里:链接看到实现逻辑(这里需要注意重入风险,具体情况具体分析)。

注:ERC1820ImplementerInterface(_implementer).canImplementInterfaceForAddress(_interfaceHash, addr)这行代码很重要,有重入风险,同时要求实现者_implementer必须按要求返回指定的值ERC1820_ACCEPT_MAGIC

总结

ERC1820协议主要用于以太坊智能合约的接口查询和管理。它为智能合约之间的交互提供了标准化的方式,使得合约可以公开声明并查询它们所实现的接口。这对于智能合约的互操作性和扩展性非常重要。

以下是一些具体的应用场景:

  1. 合约功能发现:通过ERC1820协议,合约可以公开声明它们实现的接口,其他合约就可以查询这些接口,了解如何与该合约交互。这使得合约之间的交互更为灵活和高效。
  2. 合约升级:智能合约一旦部署,其代码就不能更改。但是,通过ERC1820协议,可以将接口实现的逻辑放在另一个可以升级的合约中。这样,即使主合约的代码不变,也可以通过更改实现接口的合约来升级功能。
  3. 合约互操作性:ERC1820协议支持任意接口的注册,这使得不同的合约可以实现和支持各种各样的接口,大大增强了合约之间的互操作性。
  4. 合约安全性:ERC1820协议的查询机制可以避免对未实现特定接口的合约进行错误的调用,从而增加了智能合约的安全性。

总的来说,ERC1820协议的作用就是提供了一种标准化的方式,让智能合约可以公开声明和查询接口,从而简化了合约之间的互动。

有了这些前置知识,就可以继续学习ERC777了。

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部分。

image.png

2.1 Core部分

解读 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接口,并将其记录在注册表中。

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:

    // 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()

返回默认操作员列表。

2.1.3 Send Tokens
    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函数,需要 tokenHoldermsg.sender授权,并且检查授权额度的操作在_approve(holder, spender, _allowances[holder][spender].sub(amount)),函数体没有显示的判断授权额度,而是通过了直接减的方法来验证,如果amount大于授权额度这个减法操作肯定会报错,也算一种另类的隐式检验了。

2.1.4 Mint & Burn Tokens
_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)才能成功调用。

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

// 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))
        }
    }

}

image.png

3. ERC777的安全隐患

ERC777协议存在的安全隐患便是:重入漏洞(基本上就是在tokensToSend()tokensReceived())。该漏洞存在于_callTokensToSend()_callTokensReceived()。具体的攻击事件和攻击步骤,后面再补充。

码字不易,请点个赞😜再走呗~

点赞 1
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
BY_DLIFE
BY_DLIFE
0x994d...4240
立志成为一名智能合约安全审计师。文章都是我的个人理解,如果有不对的地方欢迎在评论区指出来。