EIP-721的openzeppelin实现

  • bixia1994
  • 更新于 2021-09-24 14:04
  • 阅读 1108

这段时间总是与NFT打交道,大部分NFT都采用了EIP721标准,且均采用了Openzepplin的EIP721实现。前段时间详细看过Openzepplin的相关实现,但是偷懒了,没有整理成文档,导致后面的记忆总是不深刻,理解也不深刻。此次正好将其实现全部整理一下。

EIP-721的openzeppelin实现

这段时间总是与NFT打交道,大部分NFT都采用了EIP721标准,且均采用了Openzepplin的EIP721实现。前段时间详细看过Openzepplin的相关实现,但是偷懒了,没有整理成文档,导致后面的记忆总是不深刻,理解也不深刻。此次正好将其实现全部整理一下。 image.png

EIP-721标准

首先简单介绍下EIP-721标准,可以参考EIP-721: Non-Fungible Token Standard (ethereum.org)

EIP-721接口

在EIP-721标准中,定义了如下的标准函数和标准事件,任何NFT合约都必须实现EIP-721标准中定义的函数和事件

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);

从EIP-721标准中,定义的事件来看,一个NFT的标准事件其实只有三种,Transfer,Approval和ApprovalForAll。其中Transfer事件与EIP-20中定义的Transfer一致,Approval指的是一个NFT的所有者批准使用者使用指定的一个tokenId的NFT,ApprovalForAll指的是NFT的所有者批准操作员使用其所有的NFT。

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 calldata 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);

从上述的方法名来看,EIP-721定义的方法中balanceOf,ownerOf,transferFrom这些是与ERC20中的函数签名一致。但是需要明确如下几点:

  1. transferFrom的逻辑与ERC20的transferFrom的逻辑不同。在ERC-20中,当调用transferFrom时,需要事先approve,而ERC-721中,作为owner或者operator或者已经获批的地址调用时,不需要approve
  2. 针对transferFrom方法,其必须在方法内部验证to地址不能是address(0), 且需要验证tokenId对应的NFT事先存在
  3. EIP-721中新增了safeTransferFrom方法,主要目的是在transfer结束后,判断to地址是否是一个合约地址,如果to地址是一个合约地址,则需要调用to地址上的onERC721Received方法,并返回特定的值,即:bytes4(keccak256("onERC721Received(address,address,uint256,bytes)")),这样就可以避免将一个NFT转移到一个不支持的地址中锁死。
  4. 当调用safeTransferFrom方法时,需要满足如下条件:
参数 要求
msg.sender 要求msg.sender 必须为owner或者是获批的operator或者是获批的approved地址
from 要求from字段必须填写owner地址,不能是其他地址
to 要求to字段不能是address(0)
tokenId 要求该tokenId必须是有效的NFT,即存在
  1. 针对setApprovalForAll方法,一个owner可以给多个operator进行全量授权,而不是仅限一个operator。

EIP-165实现

在实现EIP-721的合约中,其必须也要实现EIP-165标准,即通用接口注册标准。用于接口发现和验证。其思路是合约实现EIP-165中定义的supportsInterface(bytes4 interfaceId)方法,该方法中将一个合约中所有的external函数签名进行亦或求值得到一个bytes4. 然后验证时遵循如下思路进行验证:

  1. 调用目标合约的supportInterface方法,并传入参数:bytes4(keccak256("supportsInterface(bytes4)"))0x01ffc9a7, 此时应该返回true
  2. 调用目标合约的supportsInterface方法,并传入参数:0xffffffff,此时应该返回false
  3. 调用目标合约的supportsInterface方法,并传入参数:this.interfaceId, 此时应该返回true
this.balanceOf.selector ^ this.ownerOf.selector ^ this.safeTransferFrom ^ this.transferFrom ^ this.approve ^ this.setApprovalForAll ^ this.getApproved ^ this.isApprovedForAll = this.interfaceId

A bytes4 value containing the EIP-165 interface identifier of the given interface I. This identifier is defined as the XOR of all function selectors defined within the interface itself - excluding all inherited functions.

Metadata元数据

在目前的NFT合约实现中,基本所有的NFT都实现了MetaData这一部分的接口定义。其主要作用是定义NFT的名称,符号和tokenURI. 在EIP-721中,tokenURI的定义是要符合RFC-3986标准,但事实上目前的NFT合约中基本上都是一个自定义的状态。可能是项目方的一个网址,或者是一个IPFS文件,也可能是一串字符串。

function name() external view returns(string);
function symbol() external view returns(string);
function tokenURI(uint256 _tokenId) view returns(string);

NFT枚举

Enumerable的目的是给用户提供一个快速查询NFT的方法。接口设计上是让用户可以根据用户自己的索引查询她所拥有的NFT对应的tokenId,另一个是根据索引查询合约中的NFT的tokenId, 然后是总的供给量查询,很多的NFT合约的总供给量反应的是现在所有的NFT的数量。简单来讲就是提供两个索引,一个索引用来索引整个合约中的NFT,另一个索引是用来索引用户所拥有的NFT

function totalSupply() external view returns (uint256);
function tokenByIndex(uint256 _tokenId) external view returns(uint256);
function tokenOfOwnerByIndex(address _owner, uint256 _index) external view returns(uint256);

EIP-721接受合约

作为EIP-721的要求,如果一个合约要接受EIP-721,其必须要实现onERC721Received方法,当用户调用safeTransferFrom时,会在转账结束时,调用to地址的onERC721Received方法,此时该方法的返回值应该为bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))

function onERC721Received(address _operator,address _from,uint256 _tokenId,bytes calldata _data) external returns(bytes4);

openzeppelin的EIP-721实现

由于目前见到的所有的NFT合约其都是基于Openzepplin的EIP-721实现,故充分了解Openzepplin的EIP-721实现是非常有必要的,也是非常有帮助的。

在openzeppelin的实现中,其实现EIP-721的主要在ERC721.sol文件中,实现枚举部分在ERC721Enumberable.sol文件中。

ERC721.sol

ERC721文件中,需要实现的接口有EIP-721和metadata两部分,含EIP-165部分。

  1. 首先是需要设计全局变量:

name()   => string private name;
symbol() => string private symbol;
balanceOf() => map(address=>uint256) private _balances; 
ownerOf() => map(uint256=>address) private _owners;
getApproved() => map(uint256=>address) private _tokenApproves;
isApprovedForAll() =>  map(address=>map(address=>bool)) private _operatorApproves;

然后是依次实现EIP-721中定义的接口方法:

  1. EIP-165中定义的supportsInterface:

function supportsInterface(bytes4 interfaceId) public view returns (bool) {
    bytes4 EIP165Interface = bytes4(keccak256("supportsInterface(bytes4)"));
    bytes4 dummyInterface = bytes4(0xffffffff);

    if (interfaceId == dummyInterface) {
        return false;

    }
    if (interfaceId == EIP165Interface) {
        return true;
    }
    if (interfaceId == type(IERC721).interfaceId) {
        return true;
    }
    if (interfaceId == type(IERC721Metadata).interfaceId) {
        return true;
    }
    return false;
}
  1. 实现EIP-721中定义的get方法:

function balanceOf(address _owner) public view returns (uint256) {
    //要求_owner不能为address(0)
    require(_owner != address(0), "ERC721/balanceOf owner can not be address(0)");
    return _balances[_owner];    
}
function ownerOf(uint256 _tokenId) public view returns (address) {
    //要求任何一个tokenId的owner都不能是address(0)
    address owner = _owners[_tokenId];
    require(owner != address(0), "ERC721/ownerOf owner can not be address(0)");
    return owner;
}
function getApproved(uint256 _tokenId) public view returns (address) {
    //要求_tokenId必须是有效的tokenId
    //怎么判断一个tokenId是否是有效的tokenId呢?添加一个辅助函数_exists,即判断该tokenId的owner不应该是address(0)
    //address(0)能否是一个被授权的地址呢?是可以的,意味着该TokenId不对其他任何地址授权
    require(_exists(_tokenId), "ERC721/getApproved not a valid tokenId");
    return _tokenApproved[_tokenId];
}
function isApprovedForAll(address _owner, address _operator) public view returns (bool) {
    return _operatorApproved[_owner][_operator];
}
function _exists(uint256 _tokenId) internal view returns (bool) {
    return _owners[_tokenId] != address(0);
}
  1. 实现EIP-721 Metadata中定义的get方法:

function name() public view returns (string) {
    return name;
}
function symbol() public view returns (string) {
    return symbol;
}
function tokenURI(uint256 _tokenId) public view returns (string) {
    //tokenURI指向一个特定的JSON文件,也可以是一个字符串,其是由baseURI和tokenId进行组合得到
    //要求tokenId是一个有效的tokenId
    require(_exists(_tokenId), "ERC721/tokenURI not a valid tokenID");
    //首先检查是否定义了baseURI,如果定义了baseURI则将其与tokenID进行组合得到tokenURI,如果没有定义baseURI,则直接返回空
    bytes memory baseURI = _baseURI();
    if (bytes(baseURI).length > 0) {
        return string(abi.encodePacked(baseURI,_tokenId.toString()));
    }
    return "";
}
function _baseURI() internal view returns (string) {
    return "";
}
  1. 实现EIP-721中定义的transfer方法:

function safeTransferFrom(address _from, address _to, uint256 _tokenId, bytes calldata _data) external payable {
    //要求msg.sender必须是owner或者授权的operator或者是授权的地址
    //要求from必须是owner的地址,不能是operator的地址或者其他地址
    //要求to必须不能是address(0)
    //要求tokenId必须是有效的tokenId
    //要求当transfer结束时,检查to地址是否是合约地址,如果是合约地址则需要调用onERC721Received方法,返回特定的值
    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));   
}
function safeTransferFrom(address _from, address _to, uint256 _tokenId) external payable {
    safeTransferFrom(_from,_to,_tokenId,"");
}
function transferFrom(address _from, address _to, uint256 _tokenId) external payable {
    //要求msg.sender必须是owner或者授权的operator或者是授权的地址
    //要求from必须是owner的地址,不能是operator的地址或者其他地址
    //要求to必须不能是address(0)
    //要求tokenId必须是有效的tokenId
    _transfer(_from,_to,_tokenId);
}
function _transfer(address _from, address _to, uint256 _tokenId) internal {
    //要求from必须是owner的地址,不能是operator的地址或者其他地址
    //要求to必须不能是address(0)
    require(from == ownerOf(_tokenId), "EIP721/safeTransferFrom from not correct");
    require(to != address(0), "EIP721/safeTransferFrom to not correct");
    //更改tokenId对应的所有权,取消相应tokenId的授权地址的权限,但不能取消经销商的权限
    _balances[_from] = _balances[_from].sub(1);
    _balances[_to] = _balances[_to].add(1);
    _owners[_tokenId] = _to;
    _tokenApproves[_tokenId] = address(0);
}
function _checkOnERC721Received(address _from, address _to, uint256 _tokenId, bytes calldata _data) internal returns (bool) {
    //作用是判断地址to是否是一个合约地址,如果不是一个合约地址则直接返回true,如果是一个合约地址,则需要调用地址to的onERC721Received方法来判断返回值是否是一个特定的返回值
    //是EOA,必须同我直接交互,不能通过proxy
    bytes4 funcSelector = bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"));
    if (msg.sender == tx.origin) {
        return true;
    }
    //是合约地址
    //这样写会把to地址的报错给吞掉,没有把报错信息抛出来
    if (_to.isContract()) {
        bytes4 retVal = IERC721Received(_to).onERC721Received(msg.sender,_from,_tokenId,_data);
        return funcSelector == retVal;
    }
    //是合约地址
    //这样写可以把to地址的报错抛出来
    if (_to.isContract()) {
        (bool success, bytes memory res) = _to.call(abi.encodeWithSelector(funcSelector,_from,_to,_tokenId,_data));
        bytes4 retVal;
        uint256 retSize;
        assembly {
            retSize := mload(res)
            retVal := mload(add(res,0x20))
        }
        if (success) {
            require(retSize == 0x04);
            return funcSelector == retVal;
        } else {
            if (retSize == 0) {
                revert("ERC721: transfer to non ERC721Receiver Implementer");
            } else {
                assembly {
                    revert(add(0x20, res),mload(res))
                }
            }
        }
    }
    return false;
}
  1. 实现EIP-721中定义的set方法

function approve(address _approved, uint256 _tokenId) external payable {
    //要求msg.sender 必须是owner或者是授权的经销商
    //要求tokenId必须是存在的tokenId
    //可以给address(0)授权,意味着该tokenId没有授权的地址
    //不能给自己授权
    address owner = ownerOf(_tokenId);
    require(msg.sender == owner || isApprovedForAll[owner][msg.sender]);
    require(_exists[_tokenId]);
    _tokenApproves[_tokenId] = _approved;
}
function setApprovalForAll(address _operator,bool _approved) external{
    //要求经销商不能是自己
    require(msg.sender != _operator,"ERC721/setApprovalForAll msg.sender can not be the operator itself");
    _operatorApproves[owner][_operator] = _approved;
}

关键点:自己不能是自己的经销商!

原因在于如果alice是alice自己的经销商,意味着_operatorApproves[alice][alice] = true,则当alice作为owner给bob转一个tokenId时,由于在_transfer函数的逻辑设计中,只清楚了该tokenId对应的授权地址的授权,即_tokenApproves[_tokenId] = address(0), 并没有清除相应的经销商的授权。同时,清除经销商的权限也是不合理的。其实此时作为经销商的alice还是无法再去transfer一次tokenId

  1. 其他的辅助方法:mint,burn

在当前的NFT合约中,大量使用了mint方法,然而此方法并不是EIP-721中规定的方法,但是其已经成为事实标准。简单来讲mint方法是新增一个tokenId,该tokenId不能是已经存在的,然后把该tokenId添加到对应的owner中。burn方法是删除该tokenId即可。mint和burn在openzeppelin的实现中都遵循了safeTransfeFrom的思路。mint方法并未提供一个公开的方法,而是一个_safeMint()内部方法,需要项目方自己去结合逻辑实现一个mint方法。

function _safeMint(address _to, uint256 _tokenId, bytes memory _data) internal {
    //要求tokenId必须不能是一个已经存在tokenId
    //要求地址to如果是合约地址,则需要实现onERC721Received方法
    require(!_exists[_tokenId],"ERC721/_safeMint tokenId already exists");
    _mint(_to,_tokenId);
    require(_checkOnERC721Received(address(0),_to,_tokenId,_data),"ERC721/_safeMint not a valid receiver");
}
function _safeMint(address _to, uint256 _tokenId) internal {
    _safeMint(_to,_tokenId,"");
}
function _mint(address _to, uint256 _tokenId) internal {
    //要求_tokenId必须不能是一个已经存在的tokenId
    //要求地址_to必须不能是address(0)
    require(!_exists(_tokenId), "ERC721/_mint tokenId already exists");
    require(_to != address(0),"ERC721/_mint _to can not be address(0)");

    _owners[_tokenId] = _to;
    _balances[_to] += 1;
    emit Transfer(address(0), _to, _tokenId);
}
function _burn(uint256 _tokenId) internal {
    //要求tokenId必须存在,但是不能真的把tokenId转给地址0,只是删除owners中对应的tokenId
    require(_exists(_tokenId),"");
    //要求清除该tokenId对应的授权地址,但不能清除经销商的授权
    _tokenApproves[_tokenId] = address(0);
    _balances[msg.sender] -= 1;
    delete _owners[_tokenId];
    emit Transfer(msg.sender, address(0), _tokenId);
}

ERC721Enumerable.sol

ERC721的枚举部分,该部分与ERC721主体部分分开,其实现的功能主要是提供totalSupply以及提供了两个索引,一个索引是tokenByIndex全局索引,另一个索引是tokenOfOwnerByIndex,即用户的索引。

这里需要思考如何实现这两个索引。目前在ERC721.sol文件中,提供了_owners,_balances,_tokenApproves,_operatorApproves四个map,现在需要提供两个索引,这两个索引应该如何与这些已有的map结合起来?

//要得到最新的总供应量,即返回目前被NFT合约追踪下来的总的有效NFT数量
totalSupply => uint256[] private _allTokens; => totalSupply = _allTokens.length;
//根据全局索引来查找对应的tokenId
tokenByIndex => uint256[] private _allTokens; => return _allTokens[index];
//根据特定的owner的索引查找其拥有的所有tokenId
//tokenOfOwnerByIndex => mapping(address=>uint256[]) private _ownedTokens; => return _ownedTokens[owner][index];
tokenOfOwnerByIndex => mapping(address=>mapping(uint256=>uint256)) private _ownedTokens; => return _ownedTokens[owner][index];
//在索引用户的tokenId时,需要保证index值小于用户的balance

结合目前的需求,因为要delete 列表_allTokens中的某一个tokenId,故还需要额外维护一个tokenId=>index的逆向map。

mapping(uint256=>uint256) private _allTokensIndex;

因为要delete列表_ownedTokens[owner]中的某一个tokenId,故还需要额外维护一个tokenId=>index的逆向map:

mapping(uint256=>uint256) private _ownedTokensIndex;
  1. 枚举中的get方法:
function totalSupply() public view returns (uint256) {
    return _allTokens.length;
}
function tokenByIndex(uint256 _index) public view returns (uint256) {
    require(_index < totalSupply(), "ERC721Enumerable/tokenByIndex index overflow");
    return _allTokens[_index];
}
function tokenOfOwnerByIndex(address _owner,uint256 _index) public view returns (uint256) {
    //要求index不能大于等于owner的余额
    //要求owner不能是地址0
    require(_index < balanceOf(_owner), "ERC721Enumberable/tokenOfOwnerByIndex index overflow balance");
    require(_owner != address(0));
    return _ownedTokens[_owner][_index];
}
  1. 枚举中的set方法

这里需要思考枚举中的set方法应该在什么时候调用:其应该在每一次transfer之前都需要调用一次,因为transfer时肯定就发生了状态的变化。这里就需要用到ERC721中预先留下来的勾子函数:

function _beforeTokenTransfer(address _from,address _to,uint256 _tokenId) internal {}

在这个函数中,需要做如下的逻辑判断:

from to 含义
不为address(0) 不为address(0) 普通的transfer,此时的tokenId应该从from->to
为address(0) 不为address(0) 此时是mint操作
不为address(0) 为address(0) 此时是burn操作

根据上述表格可以看到有三种类型的操作,transfer,mint和burn,需要针对三种不同的类型来分别更新mapping中的值

普通的transfer操作

针对普通的transfer操作:

_allTokens列表应该保持不变;
_ownedTokens列表需要更新 => _ownedTokens[from]相应减去该tokenId,_ownedTokens[to]应增加相应tokenId
_ownedTokensIndex需要更新 => _ownedTokensIndex[_tokenId] = newIndex;
_allTokensIndex 不需要更新

在openzeppelin的实现中,即为:

function _removeTokenFromOwnerEnumeration(address from, uint256 tokenId) private {}
function _addTokenToOwnerEnumeration(address to,uint256 tokenId) internal {}

mint操作

针对mint操作:

_allTokens列表需要新增 => _allTokens.push(_tokenId);
_ownedTokens列表需要新增 => _ownedTokens[to][balanceOf(to)]=_tokenId;
_ownedTokensIndex列表需要新增 => _ownedTokensIndex[_tokenId] = balanceOf(to);
_allTokensIndex 需要新增 => _allTokensIndex[_tokenId] = totalSupply();

在openzeppelin的实现中,即为:

function _addTokenToAllTokensEnumeration(uint256 tokenId) private {
    //注意先后顺序
    _allTokensIndex[tokenId] = _allTokens.length;
    _allTokens.push(tokenId);
}
function _addTokenToOwnerEnumeration(address to, uint256 tokenId) private {
    uint256 length = balanceOf(to);
    _ownedTokens[to][length] = tokenId;
    _ownedTokensIndex[tokenId] = length;
}

burn操作

针对burn操作:

_allTokens列表需要删除 => delete _allTokens[_allTokensIndex[tokenId]];
_allTokensIndex 需要更新 => delete _allTokensIndex[tokenId]; //问题:如果删除后,该map保存的其他index应该都不准确了,应该如何设计?
_ownedTokens列表需要删除 => delete _ownedTokens[from][_ownedTokensIndex[tokenId]];
_ownedTokensIndex 需要更新 => delete _ownedTokensIndex[tokenId];

在openzeppelin的实现中,即为:

function _removeTokenFromOwnerEnumeration(address from, uint256 tokenId) private {
    //为了解决上面提出的问题,这里删除时,预先将要删除的tokenId放置在最后一个槽位,然后只删除最后一个槽位
    //swap and pop
    uint256 lastTokenIndex = balanceOf(from) - 1;
    uint256 tokenIndex = _ownedTokensIndex[tokenId];
    //swap 如果不是最后一个槽位则 swap
    if (tokenIndex != lastTokenIndex) {
        uint256 lastTokenId = _ownedTokens[from][lastTokenIndex];
        //swap
        _ownedTokens[from][tokenIndex] = lastTokenId;
        _ownedTokensIndex[lastTokenId] = tokenIndex;
    }
    //pop
    delete _ownedTokens[from][lastTokenIndex];
    delete _ownedTokensIndex[tokenId];
}
function _removeTokenFromAllTokensEnumeration(uint256 tokenId) private {
    //swap and pop
    uint256 lastTokenIndex = _allTokens.length - 1;
    uint256 tokenIdex = _allTokensIndex[tokenId];
    //swap 为节约gas费用,不考虑是否是最后一个槽位
    uint256 lastTokenId = _allTokens[lastTokenIndex];

    _allTokens[tokenIndex] = lastTokenId;
    _allTokensIndex[lastTokenId] = tokenIndex;

    //pop
    delete _allTokensIndex[tokenId];
    _allTokens.pop();

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

0 条评论

请先 登录 后评论
bixia1994
bixia1994
目前在运营一个微信公众号:bug合约写手,欢迎关注. 对我感兴趣可以加我微信:woodward1993