ERC-4910 NFT with built-in royalties (Royalty Bearing NFTs)
Final · NFTs & multi-token · created 2022-03-14
Extends the NFT standard (ERC-721) so that the definition, distribution and payment of royalties are all handled onchain. It builds a layered royalty structure linking original works and derivative works, so revenue reaches creators without a central authority.
Why it is needed
Royalty management is a long-standing problem marked by complicated contracts, opaque operations, and fraud and deception. The problem is worse for layered royalties, where derivative works come from an original, such as a print made from a painting or a song that samples another song. The original creator should receive a share each time a derivative work is sold and resold, but without a mechanism to guarantee this, intermediaries can manipulate or skip payments entirely. Even in existing NFT trades, buyers and sellers can avoid royalties by exchanging money outside the contract. This standard is designed so that the NFT contract itself takes part directly in sales and payments, which means royalties go to the people legally entitled to receive them.
What to implement
function getRoyaltyAccount (uint256 tokenId) public view virtual returns (address, RoyaltyAccount memory, RASubAccount[] memory);: Looks up the royalty account linked to a tokenId and the list of its sub-accounts.function updateRoyaltyAccount (uint256 _tokenId, `RoyaltyAccount memory _raAccount) public virtual returns (bool): Updates a royalty account and its sub-accounts. Asset identifier, ancestors, balance and similar fields cannot be changed, and only reducing one's own distribution ratio and adding sub-accounts are allowed.function deleteRoyaltyAccount (uint256 _tokenId) public virtual returns (bool): Deletes a royalty account. This is possible only when all NFTs linked to the NFT by lineage have been burned and all balances are zero.function mint(address to, NFTToken[] memory nfttoken, address tokenType) public virtual: Mints one or more NFTs together with royalty metadata, creates a royalty account, and links it to the NFT and to the accounts of the owner and ancestors.function listNFT (uint256[] calldata tokenIds, uint256 price, address tokenType) public virtual returns (bool): Lists one or more NFTs for direct sale, specifying the price and the type of payment token.function removeNFTListing (uint256 listingId) public virtual returns (bool): Removes a listing from the sale list when there is no registered payment.function executePayment (address receiver, address seller, uint 256[] tokenIds, uint256 payment, string tokenType, int256 trxnType) public virtual nonReentrant returns (bool): Pays the NFT purchase amount to the contract in ERC-20 tokens. For a direct sale (0) it handles the transfer immediately, and for an exchange-mediated sale (1) it registers the payment.function reversePayment(uint256 paymentId, string memory tokenType) public virtual returns (bool): Lets the buyer get back a registered payment before the sale is completed.function safeTransferFrom(address from, address to, uint256 tokenId, bytes memory _data) external virtual override: Verifies the payment information in _data, distributes royalties, and then transfers NFT ownership. This is the only transfer function allowed in this standard.function distributePayment (uint256 tokenId, uint265 payment) internal virtual returns (bool): An internal function that traces the payment amount back up the royalty account chain to the ancestors and distributes it to each sub-account by ratio.function royaltyPayOut (uint256 tokenId, address RAsubaccount, address payable payoutAccount, uint256 amount) public virtual nonReentrant returns (bool): A sub-account owner withdraws their own royalty balance to a specified address.function _royaltyPayOut (uint256 tokenId, address RAsubaccount, address payable payoutAccount, uint256 amount) public virtual returns (bool): An internal withdrawal function used during transfers, which deducts the balance only when the payout succeeds.
In depth
Royalty account and sub-account structure
Each NFT is linked to one Royalty Account (RA) through its tokenId, and an RA must have at least one sub-account (R1). The RA identifier raAccountId must be unique (R2) and must be linked to the tokenId (R3). A sub-account can be an individual account or another RA, and only individual accounts can hold a withdrawable balance. If a sub-account is an RA, its share must be redistributed to that RA's sub-accounts, so its final balance must be 0. There must be a direct link between parent and child NFTs (R4), and the contract must also maintain the list of supported token types (R5), the last verified balance per token (R6), sale listings (R7, R8) and registered payments (R9, R10).
Minting rules and contract ownership structure
When an NFT is minted, an RA and a sub-account are created together, and the NFT, its owner, and the ancestor's RA (if there is an ancestor) must be linked to them (R18). Unusually, the owner of a new NFT must be the NFT contract itself (R19), and the actual owner is designated through isApproved and acts like the owner (R20). This design lets the contract serve as the escrow for payments and royalties. The mint input carries parent, canBeParent, maxChildren, royaltySplitForItsChildren, and uri (R21), and rules apply such as: the number of tokens issued must not be 0, msg.sender must hold MINTER_ROLE or CREATOR_Role, royaltySplitForItsChildren must be 0 for an NFT that cannot have children, and the parent's generation level must be below the maximum allowed number of generations (R23).
Two Sale Methods and Payment Handling
Sales are divided into direct sales (trxnType = 0) and exchange-brokered sales (trxnType = 1). Direct sales must be listed and delisted through the contract with listNFT and removeNFTListing (R24). The buyer always pays the NFT contract, not the seller. For ERC-20, the buyer calls executePayment after approve (R31). For a protocol token such as ETH, the buyer encodes seller, a tokenId array, receiver, and trxnType into msg.data and sends it to the payable fallback function (R39 to R41). receive() cannot accept msg.data, so it is not permitted. A direct sale verifies the payment, transfers immediately with the internal _safeTransferFrom, and removes the listing (R37, R38). A brokered sale registers the payment in registeredPayment and verifies it later in a separate safeTransferFrom (R36). Before completion, the buyer can reverse the payment with reversePayment (R44, R45).
Transfer Function Restrictions and _data Validation
To prevent royalty bypass, only one transfer function is kept. transferFrom and safeTransferFrom without data must be disabled (R46). The one remaining safeTransferFrom uses the ERC-721 interface as is, but encodes the information for transferring multiple tokens in _data. _data must contain at least the seller, buyer, and receiver addresses, the tokenId array, the payment token type, the payment amount, the registered payment identifier, and block.chainid (R48), and it must pass rules such as seller == from, the receiver is not the seller, and each token matches the token in the registered payment (R49). The transfer happens only after all royalties have been distributed, and it works by changing the approved address with _approve(to, tokenId[i]), so the technical owner remains the contract (R53). After the approval is updated, the registered payment is removed (R54).
Royalty Distribution by Layer Decomposition
distributePayment splits the recursive problem of a royalty tree of depth N into N per-layer problems and processes them from the bottom layer up to the root ancestor. The payment amount is divided equally among all NFTs included in the transfer. For each sub-account, if isIndividual is true, it adds the amount after applying the royalty ratio to that account's royaltybalance and emits an event containing assetId, accountId, tokenType, and royaltybalance. If false, it holds the amount in a temporary variable, moves on to the next RA along ancestor, and repeats the same rules, ending when it reaches an RA that has no ancestor and whose sub-accounts are all individual (R51). Ratio calculation represents 100% as an integer such as 10000, multiplies, then divides, so overflow and underflow need attention.
Royalty Withdrawal and Settlement During Transfer
There are two withdrawal functions, a public version and an internal version. The public royaltyPayOut requires msg.sender == RAsubaccount, that the token exists and has not been burned, that the sub-account's isIndividual == true, and that amount is at most royaltybalance (R56), and it has nonReentrant protection because funds leave the contract. The internal _royaltyPayOut sends the payment and deducts the balance only if it succeeds (R58, R59). During a transfer, the entire sub-account balance of the from address (the approved actual owner) is first paid out to from, and only after royaltybalance reaches 0 is the sub-account owner updated to to (R52).
Constraints on Account Updates and Deletion
In updateRoyaltyAccount, the asset identifier, ancestor, payment token type, sub-account balance, and the distribution ratio inherited from the parent cannot be changed. The sum of all distribution ratios must always be 1, msg.sender must be the sub-account owner of the RA being modified, and one can only reduce one's own ratio. The amount reduced must be added to existing accounts' ratios or to a new sub-account with a zero balance (R15). deleteRoyaltyAccount is a very gas-expensive operation, and it is allowed only when the tokenId has been burned, all ancestor and descendant tokens linked by lineage have been burned, and all sub-account balances are 0 (R17). If the rules are not followed, part of the tree can collapse and funds can be locked.
Things to watch out for
- Reentrancy and Unauthorized Withdrawal: Reentrancy protection is applied to all payment functions to prevent reentrancy attacks. Withdrawal requires at minimum that msg.sender be the owner of the sub-account, which blocks unauthorized payouts. If executePayment fails and the funds are stuck in the contract, they can be returned with reversePayment.
- Royalty Bypass: Selling a private key off-chain for payment cannot be prevented by any design. A bypass in which a smart contract wallet controlled by several addresses owns the NFT and transfers the asset inside the wallet is mitigated by prohibiting contracts from owning NFTs unless explicitly permitted.
- Disrupting Royalty Distribution (Gas Exhaustion): If an attacker adds a large number of sub-accounts with very low ratios and keeps minting derivative works up to the generation limit, the recursive distribution function can run out of gas or fail with an execution error. This is mitigated by limiting the number of sub-accounts per NFT and the distribution ratios, and by restricting the number of NFTs in a single listing and maxNumberGeneration.
- Phishing Attacks: In this design the contract itself is the owner of every NFT, so common NFT phishing that targets approve and setApprovalForAll does not work. However, an attack is possible in which the victim is tricked into listing at a very low price and the attacker pays immediately, so mitigations are proposed such as a waiting period before purchase confirmation or requiring two-step authentication.
- Responsibility for Parameter Design: The creator of an NFT family must examine the business model carefully and set the upper and lower limits for the maximum number of generations, the number of sub-accounts, the number of derivative works, the number of NFTs per listing, and the distribution ratios. Use of security analysis tools and a separate security audit are also recommended.