前言
因为一些机缘,我最近和几个同行朋友一起提交了一个新的 EIP 协议标准,EIP-6150,这是一个支持层级结构的 NFT 协议标准,撰写此文时处在 Review 状态,改为 Last Call 状态的 PR 还在等待通过。
该协议标准有 4 位作者:Keegan Lee、msfew、Kartin 和 qizhou。Keegan Lee 就是我,主要负责了接口的定义和实现代码的编写。Kartin 是这个 EIP 的发起人,也是 Hyper Oracle 的创始人。msfew 则是 Hyper Oracle 的研究员,主要帮忙做一些辅助性的工作,包括完善文档、提交 PR、跟进讨论区的 QA 等。qizhou 是 EthStorage 的创始人,之前就提交过其他 EIP,熟悉申请 EIP 的流程,也对以太坊基金会的人比较熟悉,为这个协议提供了很多指导。以下是该 EIP-6150 的 github 地址:
Hierarchical NFTs
目前的 NFT 几乎都是扁平化的,不同 NFT 之间不存在关联关系。而层级化的 NFT,则可以将所有 NFT 串联起来组成树状结构,就像文件系统一样。
如上图所示,可以想象成每个文件夹都是一个单独的 NFT,不同文件夹之间的层级关系也就是 NFT 之间的层级关系。
层级化的 NFT 可用于多种不同的应用场景,比如:
- 组织架构
- 社交关系图谱
- 电商商品类目结构
- 层级评论系统
可以说,任何具有层级结构的场景都可以适用这个 EIP-6150 协议标准。层级结构的 NFT 在去中心化社交、去中心化电商等领域都将可能产生广泛应用。
接口定义
EIP-6150 总共定义了 5 个接口文件:
- IERC6150
- IERC6150Enumerable
- IERC6150Burnable
- IERC6150ParentTransferable
- IERC6150AccessControl
IERC6150
IERC6150 是规定必须实现的接口,最小化定义了一个事件和四个函数,且要求继承 IERC165 和 IERC721 接口,接口定义如下:
/** * @title ERC-6150 Hierarchical NFTs Token Standard * @dev See https://eips.ethereum.org/EIPS/eip-6150 * Note: the ERC-165 identifier for this interface is 0x897e2c73. */ interface IERC6150 is IERC721, IERC165 { /** * @notice Emitted when `tokenId` token under `parentId` is minted. * @param minter The address of minter * @param to The address received token * @param parentId The id of parent token, if it's zero, it means minted `tokenId` is a root token. * @param tokenId The id of minted token, required to be greater than zero */ event Minted( address indexed minter, address indexed to, uint256 parentId, uint256 tokenId ); /** * @notice Get the parent token of `tokenId` token. * @param tokenId The child token * @return parentId The Parent token found */ function parentOf(uint256 tokenId) external view returns (uint256 parentId); /** * @notice Get the children tokens of `tokenId` token. * @param tokenId The parent token * @return childrenIds The array of children tokens */ function childrenOf( uint256 tokenId ) external view returns (uint256[] memory childrenIds); /** * @notice Check the `tokenId` token if it is a root token. * @param tokenId The token want to be checked * @return Return `true` if it is a root token; if not, return `false` */ function isRoot(uint256 tokenId) external view returns (bool); /** * @notice Check the `tokenId` token if it is a leaf token. * @param tokenId The token want to be checked * @return Return `true` if it is a leaf token; if not, return `false` */ function isLeaf(uint256 tokenId) external view returns (bool); }
Minted 事件需在铸造一个新的 NFT 时发出,记录了新 NFT 的铸造者(minter)、接收者(to)、父节点 NFT 的 ID(parentId)、新 NFT ID(tokenId)。当铸造一个根节点 NFT 时,那 parentId 则记为 0,即 0 表示一个无效的空节点,因此,有效的节点 NFT 的 tokenId 就不可以为 0。
parentOf 函数用于查询指定 tokenId 的 NFT 的父节点 NFT。
childrenOf 函数则查询出指定 tokenId 的 NFT 的所有子节点 NFTs。
isRoot 和 isLeaf 函数则分别可查询指定 tokenId 在整个 NFT 层级树中是不是根节点或叶子节点。
IERC6150Enumerable
IERC6150Enumerable 是可选的扩展接口,主要补充了几个跟层级相关的 Enumerable 的查询接口,接口定义如下:
/** * @title ERC-6150 Hierarchical NFTs Token Standard, optional extension for enumerable * @dev See https://eips.ethereum.org/EIPS/eip-6150 * Note: the ERC-165 identifier for this interface is 0xba541a2e. */ interface IERC6150Enumerable is IERC6150, IERC721Enumerable { /** * @notice Get total amount of children tokens under `parentId` token. * @dev If `parentId` is zero, it means get total amount of root tokens. * @return The total amount of children tokens under `parentId` token. */ function childrenCountOf(uint256 parentId) external view returns (uint256); /** * @notice Get the token at the specified index of all children tokens under `parentId` token. * @dev If `parentId` is zero, it means get root token. * @return The token ID at `index` of all chlidren tokens under `parentId` token. */ function childOfParentByIndex( uint256 parentId, uint256 index ) external view returns (uint256); /** * @notice Get the index position of specified token in the children enumeration under specified parent token. * @dev Throws if the `tokenId` is not found in the children enumeration. * If `parentId` is zero, means get root token index. * @param parentId The parent token * @param tokenId The specified token to be found * @return The index position of `tokenId` found in the children enumeration */ function indexInChildrenEnumeration( uint256 parentId, uint256 tokenId ) external view returns (uint256); }
继承 IERC721Enumerable 也是可选的,但为了更好地兼容 ERC721,最好可以继承。childrenCountOf 函数用于查询指定节点下有多少个子节点,如果参数 parentId 为 0,则表示查询根节点的数量。childOfParentByIndex 函数则是从指定的父节点下的所有子节点数组中找出指定索引位置的子节点的 tokenId,比如指定父节点 parentId = 110,其下有 10 个子节点,找出索引位置为 5 的子节点 tokenId = 1105,则查询结果返回 1105。indexInChildrenEnumeration 函数则是查询指定的 tokenId 在指定父节点下的子节点数组中所在的索引位置,比如指定 tokenId = 1105,父节点 parentId = 110,1105 在子节点数组中的索引位置为 5,则查询结果返回 5。如果指定 tokenId 并不在指定父节点 parentId 下面,则需要抛出错误。
IERC6150Burnable
IERC6150Burnable 也是可选的扩展接口,定义了销毁节点的操作,接口定义如下:
/** * @title ERC-6150 Hierarchical NFTs Token Standard, optional extension for burnable * @dev See https://eips.ethereum.org/EIPS/eip-6150 * Note: the ERC-165 identifier for this interface is 0x4ac0aa46. */ interface IERC6150Burnable is IERC6150 { /** * @notice Burn the `tokenId` token. * @dev Throws if `tokenId` is not a leaf token. * Throws if `tokenId` is not a valid NFT. * Throws if `owner` is not the owner of `tokenId` token. * Throws unless `msg.sender` is the current owner, an authorized operator, or the approved address for this token. * @param tokenId The token to be burnt */ function safeBurn(uint256 tokenId) external; /** * @notice Batch burn tokens. * @dev Throws if one of `tokenIds` is not a leaf token. * Throws if one of `tokenIds` is not a valid NFT. * Throws if `owner` is not the owner of all `tokenIds` tokens. * Throws unless `msg.sender` is the current owner, an authorized operator, or the approved address for all `tokenIds`. * @param tokenIds The tokens to be burnt */ function safeBatchBurn(uint256[] memory tokenIds) external; }
只定义了两个函数,safeBurn 用于安全销毁单个节点,但要求只有指定节点为叶子节点时才允许销毁。就和 Linux 的文件系统一样,如果某目录下存在其他文件或文件夹,是不允许直接删除的。若强制删除,则目录下的所有文件和文件夹都会被级联式全部删除。当前协议没有定义级联式删除的函数,若有这个需求,可以自己再额外去添加函数实现。safeBatchBrun 函数则是用于批量销毁多个叶子节点。
IERC6150ParentTransferable
IERC6150ParentTransferable 也是一个可选的扩展接口,支持层级关系的转移,就和文件夹可以从一个目录移动到另一个目录一样,接口定义如下:
/** * @title ERC-6150 Hierarchical NFTs Token Standard, optional extension for parent transferable * @dev See https://eips.ethereum.org/EIPS/eip-6150 * Note: the ERC-165 identifier for this interface is 0xfa574808. */ interface IERC6150ParentTransferable is IERC6150 { /** * @notice Emitted when the parent of `tokenId` token changed. * @param tokenId The token changed * @param oldParentId Previous parent token * @param newParentId New parent token */ event ParentTransferred( uint256 tokenId, uint256 oldParentId, uint256 newParentId ); /** * @notice Transfer parentship of `tokenId` token to a new parent token * @param newParentId New parent token id * @param tokenId The token to be changed */ function transferParent(uint256 newParentId, uint256 tokenId) external; /** * @notice Batch transfer parentship of `tokenIds` to a new parent token * @param newParentId New parent token id * @param tokenIds Array of token ids to be changed */ function batchTransferParent( uint256 newParentId, uint256[] memory tokenIds ) external; }
接口定义了两个函数和一个事件,支持单节点的转移,也支持多节点的批量转移。每个节点发生层级关系转移时,需要抛出 ParentTransferred 事件,记录下所转移的 tokenId、旧的父节点 ID 和新的父节点 ID。transferParent 将指定的 tokenId 转移到指定的父节点下,若指定的父节点为 0,则表示指定节点改为了根节点。batchTransferParent 则可以指定多个 tokenId,批量将这些节点都转移到指定的父节点下。
IERC6150AccessControl
最后一个接口 IERC6150AccessControl 也是可选的,提供了几个权限控制的函数,接口定义如下:
/** * @title ERC-6150 Hierarchical NFTs Token Standard, optional extension for access control * @dev See https://eips.ethereum.org/EIPS/eip-6150 * Note: the ERC-165 identifier for this interface is 0x1d04f0b3. */ interface IERC6150AccessControl is IERC6150 { /** * @notice Check the account whether a admin of `tokenId` token. * @dev Each token can be set more than one admin. Admin have permission to do something to the token, like mint child token, * or burn token, or transfer parentship. * @param tokenId The specified token * @param account The account to be checked * @return If the account has admin permission, return true; otherwise, return false. */ function isAdminOf( uint256 tokenId, address account ) external view returns (bool); /** * @notice Check whether the specified parent token and account can mint children tokens * @dev If the `parentId` is zero, check whether account can mint root nodes * @param parentId The specified parent token to be checked * @param account The specified account to be checked * @return If the token and account has mint permission, return true; otherwise, return false. */ function canMintChildren( uint256 parentId, address account ) external view returns (bool); /** * @notice Check whether the specified token can be burnt by specified account * @param tokenId The specified token to be checked * @param account The specified account to be checked * @return If the tokenId can be burnt by account, return true; otherwise, return false. */ function canBurnTokenByAccount( uint256 tokenId, address account ) external view returns (bool); }
总共定义了三个函数,isAdminOf 用于查询指定的 account 对指定的 tokenId 是否有管理员权限。在 ERC721 中,每个 NFT 都只有唯一的 owner 并拥有管理权限。但在层级式的结构中,一个 NFT 是可以有多个管理员的,就和文件系统中可以有多个管理员一样。这个扩展接口就提供了支持多管理员的模式,但对于怎么设置多个管理员,则难以定义通用函数,所以就没做标准化的定义。不过,需要保证,NFT 的 owner 同时也是管理员。
canMintChildren 则用来判定某个 account 对指定的 parentId 是否具有铸造子节点的权限。
canBurnTokenByAccount 则用来检查某个 account 对指定的 tokenId 是否具有销毁的权限。
参考实现
EIP 的 github 上,我对每个接口都提供了对应的参考实现代码,代码地址如下:
但这里我不打算对每个实现代码都一一讲解,我只讲最核心的 ERC6150.sol 的实现。因为代码相对有点长,就不贴代码出来了,大家可以点击链接进去看代码。我主要讲讲实现的一些逻辑和思路。
存储上,用了三个 mapping:_parentOf、_childrenOf、_indexInChildrenArray,分别用来存储指定节点的:父节点、子节点数组、所在子节点数组里的索引位置。有了这三个 mapping 之后,几个查询函数的实现就非常简单了,我就不细说了。核心是 _safeMintWithParent 和 _safeBurn 函数,分别是铸造 NFT 和销毁 NFT 的内部函数。
铸造函数的代码如下:
function _safeMintWithParent( address to, uint256 parentId, uint256 tokenId, bytes memory data ) internal virtual { require(tokenId > 0, "ERC6150: tokenId is zero"); if (parentId != 0) require(_exists(parentId), "ERC6150: parentId doesn't exist"); _beforeMintWithParent(to, parentId, tokenId); _parentOf[tokenId] = parentId; _indexInChildrenArray[tokenId] = _childrenOf.length; _childrenOf[parentId].push(tokenId); _safeMint(to, tokenId, data); emit Minted(msg.sender, to, parentId, tokenId); _afterMintWithParent(to, parentId, tokenId); }
实现逻辑其实也很简单,有两个校验需要注意下,一是要铸造的新 NFT 的 tokenId 需要大于 0,正如前面所说的,0 为无效节点;二是当 parentId 不为 0 时,需保证 parentId 是存在的,当 parentId 为 0 时,则表示铸造的是根节点 NFT。 _beforeMintWithParent 和 _afterMintWithParent 是为了增加扩展性而增加的,可由继承此合约的上层合约根据需求再去实现。中间代码就是对几个 mapping 进行赋值了,然后调用了 ERC721 的 _safeMint 函数实现底层的铸造,接着就发送 Minted 事件了。
这个铸造函数是 internal virtual 的,上层合约可以重载该函数,且上层的实现合约需要再根据具体需求自己添加开放的铸造函数。
接着看看销毁函数,代码如下:
function _safeBurn(uint256 tokenId) internal virtual { require(_exists(tokenId), "ERC6150: tokenId doesn't exist"); require(isLeaf(tokenId), "ERC6150: tokenId is not a leaf"); uint256 parent = _parentOf[tokenId]; uint256 lastTokenIndex = _childrenOf[parent].length - 1; uint256 targetTokenIndex = _indexInChildrenArray[tokenId]; uint256 lastIndexToken = _childrenOf[parent][lastTokenIndex]; if (lastTokenIndex > targetTokenIndex) { _childrenOf[parent][targetTokenIndex] = lastIndexToken; _indexInChildrenArray[lastIndexToken] = targetTokenIndex; } delete _childrenOf[parent][lastIndexToken]; delete _indexInChildrenArray[tokenId]; delete _parentOf[tokenId]; _burn(tokenId); }
销毁时,要求 tokenId 是存在的且需是叶子节点才允许销毁。另外,销毁时,需要从子节点数组中移除,而为了节省 gas,同时把子节点数组中的最后一个元素移到了销毁的索引位置。
另外,实现代码中,也封装了批量铸造的内部函数,方便扩展支持批量铸造多个子节点的需求。
其实,整个协议并不复杂,但已经足以覆盖到很多应用场景,后续我会结合一些具体的应用场景,再增加示例代码作为案例,以促进该协议的落地应用。