本文作者:bixia1994[1]

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

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 中的函数签名一致。但是需要明确如下几点:

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

参考资料

[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