ERC-6059 Parent-governed nestable NFT (Parent-Governed Nestable Non-Fungible Tokens)
Final · NFTs & multi-token · created 2022-11-15
A standard that allows an NFT to own other NFTs. Anyone can propose a child token, but the owner of the parent token decides whether to accept it.
Why it is needed
Under the existing NFT standard (ERC-721), the owner of a token must be a regular account (EOA, Externally Owned Account) or a smart contract. As a result, there was no standardized way to bundle NFTs from several collections and sell them at once, to organize NFTs into groups by one's own criteria, or to attach bonus NFTs directly to a membership NFT. Also, if any token could be forcibly attached, there is a risk of being flooded with spam or malicious tokens. This standard defines a parent-child relationship in which an NFT owns NFTs, and a child token first enters a pending list and becomes a proper child only when the root owner of the parent token accepts it. This makes uses such as bundle sales, collection organization, membership rewards and vote delegation possible in a safe way.
What to implement
event AllChildrenRejected(uint256 indexed tokenId);: Emitted when a token rejects all of the children in its pending array and empties it.function ownerOf(uint256 tokenId) external view returns (address owner);: Looks up the root owner of a token. If the token is owned by another NFT, the lookup must recursively follow parents until an address that is not an NFT is reached.function burn(uint256 tokenId, uint256 maxRecursiveBurns) external returns (uint256);: Burns the token and also recursively burns its child tokens. If there are more children to burn than maxRecursiveBurns, the call must revert, and it returns the number of children burned recursively.function addChild(uint256 parentId, uint256 childId) external;: Adds a child token to the pending child array of the parent token. It must not be called directly and must be called by an IERC6059 implementation during the nestTransfer or transferChild process.function rejectAllChildren(uint256 parentId, uint256 maxRejections) external;: Rejects and removes all child tokens in the pending array. If there are more children than maxRejections, it fails, which prevents accidentally rejecting a token that slipped in just before the call.function childrenOf(uint256 parentId) external view returns (Child[] memory);: Returns the active child token array of the parent token as an array of Child structs (tokenId, contractAddress).function pendingChildrenOf(uint256 parentId) external view returns (Child[] memory);: Returns the pending child token array of the parent token.function childOf(uint256 parentId, uint256 index) external view returns (Child memory);: Returns a single child token at the specified index of the active child array.function pendingChildOf(uint256 parentId, uint256 index) external view returns (Child memory);: Returns a single child token at the specified index of the pending child array.
In depth
Ownership structure: root owner and direct owner
ownerOf is the same function as in ERC-721, but if the token is owned by another NFT, it must recursively follow parents until an address that is not an NFT is reached and return the root owner. directOwnerOf returns the nearest owner as three values: the owner address, the parent token ID and a flag indicating whether the owner is an NFT. If the direct owner is not an NFT, the token ID must be 0. Internally it is stored as a DirectOwner struct (tokenId, ownerAddress), and if the owner is another token, ownerAddress must be the address of the parent token's collection contract. For this reason a token ID must never be 0. 0 is used as the marker that the owner or destination is not an NFT.
Propose-commit pattern
Adding a child token must follow the propose-accept pattern. A child token first enters the parent's pending array and becomes a proper child only when the root owner of the parent token moves it to the active array. The pending array SHOULD be limited to 128 slots to prevent spam and griefing. Minting malicious or spam tokens is cheap and easy, so limiting the pending array keeps legitimate tokens from being buried under spam. The pending array is bounded without pagination because it is not a storage buffer but a candidate list that must be managed by accepting and rejecting regularly. The active array has no limit. Only the root owner can accept, so no one can force an unwanted child onto another owner.
Parent Governed pattern and permission rules
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. On the other hand, 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. If the token is owned by an NFT, only the parent NFT can execute these operations, and transfers must go through the parent's transferChild. transferChild is recommended to call the child contract's nestTransferFrom or safeTransferFrom depending on whether the destination is an NFT. This restriction exists to prevent inconsistent state in the parent contract, because only transferChild handles removing the child from the parent. To burn a nested token, it must first be taken out to an EOA and then burned.
The 5 state transitions possible with transferChild
A single transferChild handles 5 state transitions of a child token. 1) Reject: removes a child from the pending array. 2) Abandon: removes the child from the array without updating the owner (when to is the 0x0 address). 3) Unnest: takes the child out of the parent. 4) Transfer to an EOA or ERC721Receiver. 5) Transfer to a new parent token: in this case the child enters the new parent's pending array and must be accepted by the new parent's root owner before it moves to the active array. The isPending parameter specifies whether the child is in the pending array or the active array.
Index-based access and race condition prevention
The reason for using an index (childIndex) instead of a token ID when accepting or transferring a child is to save gas. Looking up by token ID requires iterating the array, so the cost is proportional to the array size, while an index has a fixed cost. However, to prevent race conditions where the index changes, the expected child's collection address (childAddress) and token ID (childId) are passed together to verify that the token at the index position is the expected one. The maxRejections of rejectAllChildren serves the same purpose: if a new token slips into the pending array while the call is being prepared, the transaction is reverted. An implementation that manages 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 not included in the standard.
Recursive burn and the ban on circular nesting
burn(tokenId, maxRecursiveBurns) recursively burns child tokens as well when burning a token. If there are more children to burn than maxRecursiveBurns, execution MUST revert, and if the value is 0 only the specified token is attempted to be burned, and it must revert if there is even one child. Also, nesting a token into its own child or any descendant of that child is prohibited. If it were allowed, an ownership loop would form and no token in the loop could be managed. The reason there is no separate safe nested transfer function is that nestTransfer must always check the destination's IERC6059 compatibility, so it is always safe.
ERC-721 compatibility and interface identifier
This standard extends ERC-721 and is designed to be compatible with ERC-721 so that existing ERC-721 tooling and infrastructure can be used as is. Backwards compatibility is provided only for ERC-721 to keep the interface simple. Nesting is not confined to a single contract and can be performed between completely separate NFT collections. The ERC-165 interface identifier is 0x42b0e56f. This proposal is a standard finalized with Final status.
Things to watch out for
- Possibility of hidden logic: The same caution as ERC-721 is required. Any function, including burn, adding a child and accepting a child, may contain hidden logic.
- Removing a child token just before a sale: Because the current owner can manage the token, after listing a parent token for sale they can pull out a child token just before the sale, so the buyer may not receive the child they expected. This is a risk inherent in the standard's design, and marketplaces must provide a way to verify at the time of sale that the expected child token is present, or otherwise prevent such malicious behavior.
- Beware of unaudited contracts: Take care when dealing with contracts that have not been audited.