本文作者:bixia1994[1]
这段时间总是与 NFT[2] 打交道,大部分 NFT 都采用了 EIP721 标准,且均采用了 Openzepplin 的 EIP721 实现。前段时间详细看过 Openzepplin 的相关实现,但是偷懒了,没有整理成文档,导致后面的记忆总是不深刻,理解也不深刻。此次正好将其实现全部整理一下。
EIP-721 标准
首先简单介绍下 EIP-721 标准,可以参考 EIP-721: Non-Fungible Token Standard (ethereum.org)[3]
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 中的函数签名一致。但是需要明确如下几点:
-
transferFrom
的逻辑与 ERC20 的transferFrom
的逻辑不同。在 ERC-20 中,当调用transferFrom
时,需要事先approve
,而 ERC-721 中,作为owner
或者operator
或者已经获批的地址调用时,不需要approve
。 -
针对
transferFrom
方法,其必须在方法内部验证 to 地址不能是address(0)
, 且需要验证tokenId
对应的 NFT 事先存在 -
EIP-721 中新增了
safeTransferFrom
方法,主要目的是在transfer
结束后,判断 to 地址是否是一个合约地址,如果 to 地址是一个合约地址,则需要调用 to 地址上的onERC721Received
方法,并返回特定的值,即:bytes4(keccak256("onERC721Received(address,address,uint256,bytes)"))
,这样就可以避免将一个 NFT 转移到一个不支持的地址中锁死。 -
当调用
safeTransferFrom
方法时,需要满足如下条件:
参数 | 要求 |
---|---|
msg.sender | 要求 msg.sender 必须为 owner 或者是获批的 operator 或者是获批的 approved 地址 |
from | 要求 from 字段必须填写 owner 地址,不能是其他地址 |
to | 要求 to 字段不能是 address(0) |
tokenId | 要求该 tokenId 必须是有效的 NFT,即存在 |
- 针对
setApprovalForAll
方法,一个 owner 可以给多个 operator 进行全量授权,而不是仅限一个 operator。
EIP-165 实现
在实现 EIP-721 的合约中,其必须也要实现 EIP-165 标准,即通用接口注册标准。用于接口发现和验证。其思路是合约实现 EIP-165 中定义的
supportsInterface(bytes4 interfaceId)
方法,该方法中将一个合约中所有的 external 函数签名进行亦或求值得到一个 bytes4. 然后验证时遵循如下思路进行验证:
-
调用目标合约的 supportInterface 方法,并传入参数:
bytes4(keccak256("supportsInterface(bytes4)"))
即0x01ffc9a7
, 此时应该返回 true -
调用目标合约的 supportsInterface 方法,并传入参数:
0xffffffff
,此时应该返回 false -
调用目标合约的 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 部分。
-
首先是需要设计全局变量:
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 中定义的接口方法:
-
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; }
-
实现 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); }
-
实现 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 ""; }
-
实现 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; }
-
实现 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
-
其他的辅助方法: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;
- 枚举中的 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]; }
- 枚举中的 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(); }
参考资料
[1]
bixia1994: https://learnblockchain.cn/people/3295
[2]
NFT: https://learnblockchain.cn/article/2850
[3]
EIP-721: Non-Fungible Token Standard (ethereum.org): https://eips.ethereum.org/EIPS/eip-721