ERC-7401 Parent-governed NFT nesting standard (Parent-Governed Non-Fungible Tokens Nesting)
Final · NFTs & multi-token · created 2023-07-26
Standardizes a parent-child nesting relationship, where an NFT owns other NFTs, as an ERC-721 extension. Anyone can propose a child token, but only the owner of the parent token can accept and manage it.
Why it is needed
In ERC-721, the owner of a token can only be an externally owned account (EOA) or a smart contract, so a token cannot own a token. As a result, there was no standard way to implement uses such as bundles that combine NFTs from multiple collections and sell them in a single transaction, collections that gather related tokens under a specific NFT, minting a bonus NFT directly into a membership token for its holder, and delegation by placing a voting NFT inside another member's NFT. ERC-6059, which addressed the same problem earlier, had a defect: as the proposal was refined, one parameter was dropped from the specification but remained in the interface ID, so the interface ID and the actual specification did not match. This standard defines a framework for parent-child relationships between NFTs and is a functionally equivalent replacement for ERC-6059 that corrects that mismatch.
What to implement
event AllChildrenRejected(uint256 indexed tokenId);: Emitted when a token rejects all of the child tokens in its pending array.function ownerOf(uint256 tokenId) external view returns (address owner);: Returns the root owner of the token. If the owner is an NFT, it recursively looks up the parent until a non-NFT address is reached.function burn(uint256 tokenId, uint256 maxRecursiveBurns) external returns (uint256);: Burns the token and recursively burns its child tokens as well. It reverts if there are more children than the specified maximum. It returns the number of burned children.function addChild(uint256 parentId, uint256 childId) external;: Adds a child token to the parent token's pending children array. It must not be called directly and is called only during nestTransfer or transferChild.function rejectAllChildren(uint256 parentId, uint256 maxRejections) external;: Rejects and clears all child tokens in the pending array. Specifying a maximum rejection count prevents children that arrived just before the call from also being rejected.function childrenOf(uint256 parentId) external view returns (Child[] memory);: Returns the parent token's list of active child tokens as an array of Child structs.function pendingChildrenOf(uint256 parentId) external view returns (Child[] memory);: Returns the parent token's list of pending child tokens as an array of Child structs.function childOf(uint256 parentId, uint256 index) external view returns (Child memory);: Returns the child token information at the specified index in the active children array.function pendingChildOf(uint256 parentId, uint256 index) external view returns (Child memory);: Returns the child token information at the specified index in the pending children array.
In depth
Root owner and direct owner
Unlike ERC-721, ownerOf recursively looks up parents while the owner is an NFT, until it reaches a non-NFT address, and returns the root owner. directOwnerOf returns the immediate owner as three values: the owner address, the parent token ID, and a boolean indicating whether the owner is an NFT. If the direct owner is not an NFT, the token ID must be 0. Internally, a DirectOwner struct (tokenId, ownerAddress) holds this information, and if the owner is another token, ownerAddress must be the address of the parent token's collection contract. Because a 0 value is used as the marker for "not an NFT", a token ID can never be 0.
Propose-commit pattern
Adding a child token always follows a propose-accept pattern. A new child first enters the pending array, and the root owner of the parent token must move it to the active array. The pending array SHOULD be limited to 128 slots to prevent spam and harassment. addChild must not be called directly. It must only be called on an IERC7059 instance as part of nestTransfer or transferChild, and the child contract's directOwnerOf must point to the calling contract. In addition to the index, acceptChild takes the expected contract address and token ID, which prevents accepting the wrong token in a race condition where indexes shift. maxRejections in rejectAllChildren works on the same principle: if children that arrived while the call was being prepared would also be rejected, the transaction is reverted.
Parent-driven permission model
The true owner of a nested token is the parent NFT and its root owner. The root owner or an approved party MUST be able to execute acceptChild, rejectAllChildren, and transferChild on a child. transferFrom, safeTransferFrom, nestTransferFrom, and burn can be executed by the root owner or an approved party only when the token is not owned by an NFT. When the token is owned by an NFT, only the parent NFT can execute these operations, and a transfer must go through the parent token's transferChild, which calls the child contract's nestTransferFrom or safeTransferFrom depending on the destination. Only transferChild handles removing the child from the parent, so this restriction prevents inconsistent state in the parent contract. To burn a nested token, it must first be moved out to an EOA and then burned.
Five state transitions of transferChild
A single transferChild handles five state transitions: rejecting a child token, abandoning it, unnesting it, transferring it to an EOA or an ERC721Receiver, and transferring it to a new parent token. The parameter combination distinguishes them. destinationId must be 0 if the destination is not a token, and isPending indicates whether the child is in the pending array (true) or the active array (false). If to is the 0x0 address, the child token's ownership information is not updated. When a child is sent to a new parent token, it enters the new parent's pending array, so the new parent's root owner must accept it again before it is placed in the active array.
Recursive burn and maxRecursiveBurns
burn(tokenId, maxRecursiveBurns) burns a token and recursively burns its child tokens as well. If there are more children to burn than the specified maximum, execution MUST revert. If maxRecursiveBurns is set to 0, only that token should be burned (SHOULD), and if it has any child at all, execution MUST revert. The return value is the number of child tokens burned recursively. Without this mechanism, burning a deeply nested token could destroy more tokens than intended, so the caller is given explicit control over the burn scope.
Using indexes and forbidding circular nesting
Indexes are used instead of token IDs when accepting or rejecting a child because of gas cost. Looking up by token ID requires iterating the array, so the cost grows with array size, while an index has a fixed cost. An implementation that tracks indexes internally with a mapping was also tried, but it raised the cost of accepting a child by more than 20% and the cost of minting by more than 15%, so it was left out of the standard and kept as an extension. Also, nesting a parent token inside its own child or any descendant is forbidden. If allowed, an ownership loop would form and no token in the loop could be managed. There is no separate safe nested-transfer function because nestTransfer must always check the destination's IERC7059 compatibility, which makes it safe on its own.
Superseding ERC-6059 and ERC-721 compatibility
This standard supersedes ERC-6059. As that proposal evolved, one parameter was dropped from the interface specification but remained in the interface ID, so the two did not match. This standard is a functionally equivalent proposal that fixes only that. The ERC-165 identifier of this interface is 0x42b0e56f. The only incompatibility with ERC-721 is that token ID 0 cannot be used, and ownerOf differs in behavior by returning the root owner. When a token is owned by an EOA or an ERC-721 Receiver, ownerOf behaves the same as in ERC-721. This proposal is a standard finalized in Final status.
Things to watch out for
- Possible hidden logic in functions: The same caution as ERC-721 applies. Any function, including burn, add child, and accept child, may contain hidden logic, so be careful when dealing with unaudited contracts.
- Removing a child token just before a sale: After a parent token is listed for sale, the seller can pull out a child token just before the sale, so the buyer may not receive the child tokens they expected. This is a risk inherent in the design of this standard, so marketplaces should prepare for it, for example by verifying the child token composition at the time of sale.
- balanceOf counts only direct ownership: balanceOf counts only the tokens an address owns directly. Tokens nested inside the tokens that address holds are not reflected, because computing them would require a recursive lookup that could become so deep that the method breaks.