ERC20 & ERC721 & 1155代码简析

简单记录下这三种ERC协议的要点。源码是openzepplin的实现。

ERC20

IErc20 接口定义的方法共有以下几个:

function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address recipient, uint256 amount) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);

event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);

通过方法我们可以看出,ERC20涉及:获取总量、余额、授权额度 这几个变量。

以下是ERC20 openzeppelin的变量,不出所料。

    mapping (address => uint256) private _balances;

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

    uint256 private _totalSupply;

而 name,symbol,decimal来自ERC20Detailed的实现。同时构造函数也定义在这个合约里。组合使用时,我们一般这样定义:

contract ERC20XXXX is ERC20, ERC20Detailed {...}

可以看到solidity是支持多继承的,这与Java,python等语言不同。其实Solidity的基础,是把父合约的代码完全拷贝过来的,变量slot也是按从父到子合约排布。

使用这种方式应该是为了使用者可以灵活组合。

transfer等方法都比较简单,就是使用SafeMath包装的uint256做一下sub和add,然后触发一下event。不过其实 SafeMath 在 solidity0.8.0 之后可以省略了,我这边源码比较老。

还有一点值得注意的是,transfer,approve等方法中,都使用了下划线+方法名的 internal 方法来实现具体逻辑。这样的好处应该是提取出业务逻辑,方便继承 ERC20 的子方法重写时,调用父合约的下划线方法,复用相同逻辑。

另外说一点就是,transfer 和 transferFrom 的返回值都是bool,这点是我们写合约时,通过call调用,经常用来判断是否转账成功的。如果我们修改实现,需要注意返回值。

ERC721

Erc721是NFT协议,官方实现中,IERC721继承了IERC165接口,用来实现对to地址为合约地址时,是否支持接受NFT的校验,从而防止误转到黑洞。

interface IERC721 is IERC165 {
    event Transfer(address indexed _from, address indexed _to, uint256 indexed _tokenId);
    event Approval(address indexed _owner, address indexed _approved, uint256 indexed _tokenId);
    event ApprovalForAll(address indexed _owner, address indexed _operator, bool _approved);

    function balanceOf(address _owner) external view returns (uint256);
    function ownerOf(uint256 _tokenId) external view returns (address);
    function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes data) external payable;
    function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable;
    function transferFrom(address _from, address _to, uint256 _tokenId) external payable;
    function approve(address _approved, uint256 _tokenId) external payable;
    function setApprovalForAll(address _operator, bool _approved) external;
    function getApproved(uint256 _tokenId) external view returns (address);
    function isApprovedForAll(address _owner, address _operator) external view returns (bool);
}

可以看到主要支持 获取地址余额,获取id对应owner,转账,授权,全部授权等操作。涉及的变量如下:

    string private _name;
    string private _symbol;
    mapping (uint256 => address) private _owners;
    mapping (uint256 => address) private _tokenApprovals;
    mapping (address => Counters.Counter) private _balances;
    mapping (address => mapping (address => bool)) private _operatorApprovals;

这里的_tokenApprovals存储单个tokenId 的 approve ,而 _operatorApprovals 存储的是某个 owner 对另一个地址的全权授权。这意味着,被授权者(operator)可以操作owner名下的所有 tokenId 的 NFT。

其实说到这里我想到,虽然没看过opensea的代码,但opensea应该就是使用setApprovalForAll 来获取我对NFT的控制权的,因为对一个 NFT合约,只需要授权一次,下次再卖就无需授权了。

transfer方法

transfer方法代码:

function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes calldata _data) external payable {
    address owner = ownerOf(_tokenId);
    address approvedAddress = getApproved(_tokenId);
    require(msg.sender == owner || msg.sender == approvedAddress || isApprovedForAll(owner,msg.sender),"EIP721/safeTransferFrom msg.sender not correct");
    require(from == owner, "EIP721/safeTransferFrom from not correct");
    require(to != address(0), "EIP721/safeTransferFrom to not correct");
    require(_exists(_tokenId), "EIP721/safeTransferFrom tokenId not exists");
    _transfer(_from,_to,_tokenId);
    require(_checkOnERC721Received(_from,_to,_tokenId,_data));   
}

前面进行了大量校验逻辑。校验了是否为owner,此tokenid是否被授权,是否被授权整个系列,参数是否正确等。

这里最后就是检查to地址是否注册为 onERC721Received 方法。

function _transfer(address _from, address _to, uint256 _tokenId) internal {
        require(ERC721.ownerOf(tokenId) == from, "ERC721: transfer of token that is not own");
        require(to != address(0), "ERC721: transfer to the zero address");
        _beforeTokenTransfer(from, to, tokenId);  //hock
        _approve(address(0), tokenId);  // Clear approvals from the previous owner
        _balances[from] -= 1;
        _balances[to] += 1;
        _owners[tokenId] = to;
        emit Transfer(from, to, tokenId);
}

可以看到修改了 from 和 to 的 _balances,修改了 _owners,清理了 _tokenApprovals 授权。 这里看到,transfer 并没有修改 _operatorApprovals。通过这里我想到了前段时间频繁出现的opeasea漏洞,有人的 NFT被低价买走。

这个漏洞的原因是,拥有者曾在opensea低价挂单,后面没有进行撤单,直接转到了其他地址。某一天他再次转到旧地址,当时的挂单直接被成交了。从这里就可以解释了,因为 opensea 没有处理旧挂单,transfer 也没有清理opensea合约的授权,导致了这个悲剧。

checkOnERC721Received

    function _checkOnERC721Received(
        address from,
        address to,
        uint256 tokenId,
        bytes memory _data
    ) private returns (bool) {
        if (to.isContract()) {
            try IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, _data) returns (bytes4 retval) {
                return retval == IERC721Receiver.onERC721Received.selector;
            } catch (bytes memory reason) {
                if (reason.length == 0) {
                    revert("ERC721: transfer to non ERC721Receiver implementer");
                } else {
                    assembly {
                        revert(add(32, reason), mload(reason))
                    }
                }
            }
        } else {
            return true;
        }
    }

这部分逻辑主要是,如果to是合约地址,调用onERC721Received,判断返回值是否为selector常量。并且如果调用出错时,处理并抛出错误原因

扩展合约

ERC721Enumerable,实现了两个功能:

  1. 获取某个owner拥有的第index个 token 的 tokenId

  2. 获取第index个 token 的 tokenId

代码具体实现比较复杂,定义了几个存储。通过实现_beforeTokenTransfer 这个hock,修改这些存储。

IERC721Metadata,定义了获取 name,symbol,tokenURI几个方法。其中tokenURI是很重要的,存储了我们token 的 json元数据,例如opensea等广泛采用的 name,description,image 三个字段的格式。其中image字段甚至可以包含 base64编码的 html。这样就能实现纯链上存储 NFT。

ERC1155

Erc1155可以说是结合了Erc20和 Erc721,是一种更通用的表达,更灵活,也比较符合现实世界的一些编程逻辑,广泛应用在游戏中。

interface IERC1155 is IERC165 {
    function balanceOf(address account, uint256 id) external view returns (uint256);
    function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids)
        external
        view
        returns (uint256[] memory);
    function setApprovalForAll(address operator, bool approved) external;
    function isApprovedForAll(address account, address operator) external view returns (bool);
    function safeTransferFrom(
        address from,
        address to,
        uint256 id,
        uint256 amount,
        bytes calldata data
    ) external;
    function safeBatchTransferFrom(
        address from,
        address to,
        uint256[] calldata ids,
        uint256[] calldata amounts,
        bytes calldata data
    ) external;

可以看到和 Erc721有很大相似之处。只是在转账的时候,支持对某个 tokenid 转一定的amount。并且添加了批量操作的接口。

合约内变量:

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

    mapping(address => mapping(address => bool)) private _operatorApprovals;

对比Erc721省略了_tokenApprovals,这一点很棒,简化了模型。

transfer中的逻辑也类似,只是除了before的hock,还增加了after的hook。

https://embed.0xecho.com.ipns.page/?color-theme=light&desc=&has-h-padding=true&has-v-padding=true&modules=comment&receiver=suden.eth&target_uri=https%3A%2F%2Fmirror.xyz%2Fsuden.eth%2Fys8GollEBdLwENX0CMCvkHbXR-Ep8arNPf4NhdoY7zI&height=800&display=iframe