This ERC proposes Royalty Distribution, a standalone royalty distribution for Referable Non-Fungible Tokens (rNFTs). It enables royalty distribution to multiple recipients at the primary level and referenced NFTs in the directed acyclic graph (DAG), with a single depth limit to control propagation. The standard is independent of ERC-2981. and token-standard-agnostic, but expects ERC-5521 rNFTs, which in practice build on ERC-721 ownership semantics. It includes a function to query fixed royalty amounts (in basis points) for transparency. Royalties are voluntary, transparent, and configurable on-chain, supporting collaborative ecosystems and fair compensation.
ERC-5521 introduces Referable NFTs (rNFTs), which form a DAG through "referring" and "referred" relationships. Existing royalty standards like ERC-2981 do not account for this structure or support multiple recipients per level. This EIP addresses the need for a royalty mechanism that:
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
The IRNFTRoyalty interface defines the royalty distribution for rNFTs and MUST inherit ERC165 so that supporting contracts can advertise compliance via ERC-165:
// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.0;
interface IRNFTRoyalty is ERC165 {
struct RoyaltyInfo {
address recipient; // Address to receive royalty
uint256 royaltyAmount; // Royalty amount (in wei for sale-based queries, basis points for fixed queries)
}
struct ReferenceRoyalty {
RoyaltyInfo[] royaltyInfos; // Array of recipients and their royalty amounts
uint256 referenceDepth; // Maximum depth in the reference DAG for royalty distribution
}
event ReferenceRoyaltiesPaid(
address indexed rNFTContract,
uint256 indexed tokenId,
address indexed buyer,
address marketplace,
ReferenceRoyalty royalties
);
function getReferenceRoyaltyInfo(
address rNFTContract,
uint256 tokenId,
uint256 salePrice
) external view returns (ReferenceRoyalty memory royalties);
function getReferenceRoyaltyInfo(
address rNFTContract,
uint256 tokenId
) external view returns (ReferenceRoyalty memory royalties);
function setReferenceRoyalty(
address rNFTContract,
uint256 tokenId,
address[] memory recipients,
uint256[] memory royaltyFractions,
uint256 referenceDepth
) external;
function setReferenceRoyalty(
address rNFTContract,
uint256 tokenId,
address[] memory recipients,
uint256[] memory royaltyFractions,
uint256 referenceDepth,
address signer,
uint256 deadline,
bytes calldata signature
) external;
function supportsReferenceRoyalties() external view returns (bool);
function royaltyNonce(address signer, address rNFTContract, uint256 tokenId) external view returns (uint256);
}
supportsInterface(type(IRNFTRoyalty).interfaceId).super.supportsInterface(interfaceId) when using inheritance.To support gas-efficient and flexible configuration, implementations MUST support the following semantics for the signature overload:
IERC5521(rNFTContract).ownerOf(tokenId) at verification time.RECOMMENDED EIP-712 Domain
RECOMMENDED Typed Struct
SetReferenceRoyalty(
address rNFTContract,
uint256 tokenId,
bytes32 recipientsHash, // keccak256(abi.encode(recipients))
bytes32 royaltyFractionsHash, // keccak256(abi.encode(royaltyFractions))
uint256 referenceDepth,
address signer,
uint256 deadline,
uint256 nonce
)
RoyaltyInfo:royaltyAmount: The royalty amount, in wei for getReferenceRoyaltyInfo with salePrice, or basis points (e.g., 100 = 1%) for getReferenceRoyaltyInfo without salePrice.ReferenceRoyalty:royaltyInfos: An array of RoyaltyInfo for multiple recipients at the primary level and referenced NFTs.referenceDepth: A single value limiting royalty distribution to referenced NFTs in the DAG.getReferenceRoyaltyInfo(address rNFTContract, uint256 tokenId, uint256 salePrice):referenceDepth.getReferenceRoyaltyInfo(address rNFTContract, uint256 tokenId):setReferenceRoyalty(address rNFTContract, uint256 tokenId, address[] recipients, uint256[] royaltyFractions, uint256 referenceDepth):setReferenceRoyalty(address rNFTContract, uint256 tokenId, address[] recipients, uint256[] royaltyFractions, uint256 referenceDepth, address signer, uint256 deadline, bytes signature):supportsReferenceRoyalties():Returns true if the contract implements this standard. Discovery MUST rely on ERC-165.
royaltyNonce(address signer, address rNFTContract, uint256 tokenId) external view returns (uint256):
ReferenceRoyaltiesPaid: Emitted when royalties are paid, logging the rNFT contract, token ID, buyer, marketplace, and ReferenceRoyalty details (with royaltyAmount in wei).referenceDepth; this reference implementation enforces <= 3 (RECOMMENDED).getReferenceRoyaltyInfo function without salePrice returns royalty fractions in basis points, enabling transparent inspection.For an rNFT (contract 0xABC, token ID 1) with referenceDepth = 2:
getReferenceRoyaltyInfo(0xABC, 1):
Returns:
{ royaltyInfos: [ {recipient: creator, royaltyAmount: 300}, {recipient: collaborator, royaltyAmount: 200}, {recipient: tokenA_owner, royaltyAmount: 100}, {recipient: tokenB_owner, royaltyAmount: 100} ], referenceDepth: 2 }. - Sale for 100 ETH: - getReferenceRoyaltyInfo(0xABC, 1, 100 ether) returns:
{ royaltyInfos: [ {recipient: creator, royaltyAmount: 3 ether}, {recipient: collaborator, royaltyAmount: 2 ether}, {recipient: tokenA_owner, royaltyAmount: 1 ether}, {recipient: tokenB_owner, royaltyAmount: 1 ether} ], referenceDepth: 2 }.
getReferenceRoyaltyInfo function without salePrice allows users to inspect fixed royalty fractions (in basis points), improving transparency.RoyaltyInfo array supports collaborative projects.This standard is independent of ERC-2981 and targets ERC-5521 rNFTs, which in practice build on ERC-721 ownership semantics. Marketplaces can integrate by:
supportsInterface(type(IRNFTRoyalty).interfaceId).getReferenceRoyaltyInfo (with or without sale price).// SPDX-License-Identifier: CC0-1.0
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/introspection/IERC165.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "./IRNFTRoyalty.sol";
interface IERC5521 {
function referred(uint256 tokenId) external view returns (uint256[] memory);
function ownerOf(uint256 tokenId) external view returns (address);
}
contract RNFTRoyalty is IRNFTRoyalty, AccessControl, EIP712, ReentrancyGuard {
using ECDSA for bytes32;
bytes32 public constant CONFIGURATOR_ROLE = keccak256("CONFIGURATOR_ROLE");
uint256 private constant MAX_ROYALTY_FRACTION = 1000; // 10%
uint256 private constant REFERRED_ROYALTY_FRACTION = 200; // 2%
uint256 private constant MAX_CHAIN_STEPS = 32;
uint256 private constant MAX_RECIPIENTS = 64;
// storage
mapping(address => mapping(uint256 => ReferenceRoyalty)) private _royalties;
event ReferenceRoyaltyConfigured(
address indexed rNFTContract,
uint256 indexed tokenId,
address indexed setter,
address[] recipients,
uint256[] royaltyFractions,
uint256 referenceDepth,
bool viaSignature
);
// EIP-712 typed data & nonce
bytes32 private constant _SET_TYPEHASH =
keccak256("SetReferenceRoyalty(address rNFTContract,uint256 tokenId,bytes32 recipientsHash,bytes32 royaltyFractionsHash,uint256 referenceDepth,address signer,uint256 deadline,uint256 nonce)");
// (signer => rNFT => tokenId => nonce)
mapping(address => mapping(address => mapping(uint256 => uint256))) private _sigNonces;
constructor() EIP712("RNFTRoyalty", "2") {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(CONFIGURATOR_ROLE, msg.sender);
}
// ===== IRNFTRoyalty =====
// expose nonce for off-chain signing
function royaltyNonce(address signer, address rNFTContract, uint256 tokenId)
external
view
returns (uint256)
{
return _sigNonces[signer][rNFTContract][tokenId];
}
function setReferenceRoyalty(
address rNFTContract,
uint256 tokenId,
address[] calldata recipients,
uint256[] calldata royaltyFractions,
uint256 referenceDepth
) external onlyRole(CONFIGURATOR_ROLE) {
_configureRoyalty(rNFTContract, tokenId, recipients, royaltyFractions, referenceDepth);
emit ReferenceRoyaltyConfigured(
rNFTContract,
tokenId,
msg.sender,
recipients,
royaltyFractions,
referenceDepth,
false
);
}
/// @notice Configure reference royalty via EIP-712 signature (supports relayers).
/// @dev
/// - Uses explicit `signer` for nonce lookup and authorization; caller can be a relayer.
/// - Includes `deadline` in the signed struct; reverts with "Signature expired" if now > deadline.
/// - Non-reentrant to defend against malicious `rNFT.ownerOf` implementations.
/// - Authorization: `signer` must have `CONFIGURATOR_ROLE` or be current `ownerOf(tokenId)`.
/// - Nonce scope: per-signer-per-token; increments on success to prevent replay.
function setReferenceRoyalty(
address rNFTContract,
uint256 tokenId,
address[] calldata recipients,
uint256[] calldata royaltyFractions,
uint256 referenceDepth,
address signer,
uint256 deadline,
bytes calldata signature
) external nonReentrant {
_checkParams(rNFTContract, recipients, royaltyFractions, referenceDepth);
bytes32 recipientsHash = keccak256(abi.encode(recipients));
bytes32 fractionsHash = keccak256(abi.encode(royaltyFractions));
// Compute expected signer digest and use per-signer-per-token nonce (explicit signer for relaying)
require(signer != address(0), "Invalid signer");
uint256 nonce = _sigNonces[signer][rNFTContract][tokenId];
require(block.timestamp <= deadline, "Signature expired");
bytes32 structHash = keccak256(
abi.encode(
_SET_TYPEHASH,
rNFTContract,
tokenId,
recipientsHash,
fractionsHash,
referenceDepth,
signer,
deadline,
nonce
)
);
bytes32 digest = _hashTypedDataV4(structHash);
address recovered = ECDSA.recover(digest, signature);
require(recovered == signer && signer != address(0), "Invalid signature");
// Authorization: CONFIGURATOR_ROLE or current owner
bool authorized = hasRole(CONFIGURATOR_ROLE, signer);
if (!authorized) {
address owner = _safeOwnerOf(IERC5521(rNFTContract), tokenId);
require(signer == owner, "Signer not authorized");
}
// effects: bump nonce to prevent replay
_sigNonces[signer][rNFTContract][tokenId] = nonce + 1;
// configure royalties
_configureRoyalty(rNFTContract, tokenId, recipients, royaltyFractions, referenceDepth);
emit ReferenceRoyaltyConfigured(
rNFTContract,
tokenId,
signer,
recipients,
royaltyFractions,
referenceDepth,
true
);
}
/// @notice Compute reference royalty distribution for a concrete sale price (values in wei).
/// @param rNFTContract RNFT contract implementing IERC5521
/// @param tokenId Token id
/// @param salePrice Sale price in wei
function getReferenceRoyaltyInfo(
address rNFTContract,
uint256 tokenId,
uint256 salePrice
) external view returns (ReferenceRoyalty memory royalties) {
royalties = _royalties[rNFTContract][tokenId];
RoyaltyInfo[] memory chainRoyalties = _calculateChainRoyalties(rNFTContract, tokenId, salePrice);
royalties.royaltyInfos = chainRoyalties;
return royalties;
}
/// @notice Compute reference royalty distribution in basis points (bps), i.e. relative amounts.
/// @param rNFTContract RNFT contract implementing IERC5521
/// @param tokenId Token id
function getReferenceRoyaltyInfo(
address rNFTContract,
uint256 tokenId
) external view returns (ReferenceRoyalty memory royalties) {
royalties = _royalties[rNFTContract][tokenId];
if (royalties.royaltyInfos.length == 0) return royalties;
RoyaltyInfo[] memory bpsRoyalties = _calculateChainRoyalties(rNFTContract, tokenId, 0);
royalties.royaltyInfos = bpsRoyalties;
return royalties;
}
function supportsReferenceRoyalties() external pure returns (bool) {
return true;
}
// ===== ERC-165 =====
function supportsInterface(bytes4 interfaceId)
public
view
override(AccessControl, IERC165)
returns (bool)
{
return
interfaceId == type(IRNFTRoyalty).interfaceId ||
super.supportsInterface(interfaceId);
}
// ===== Internal =====
function _checkParams(
address rNFTContract,
address[] calldata recipients,
uint256[] calldata royaltyFractions,
uint256 referenceDepth
) internal pure {
require(rNFTContract != address(0), "Invalid contract");
require(recipients.length == royaltyFractions.length, "Length mismatch");
require(recipients.length <= MAX_RECIPIENTS, "Too many recipients");
require(referenceDepth <= 3, "Depth too high");
for (uint256 i = 0; i < recipients.length; ++i) {
require(recipients[i] != address(0), "Zero recipient");
}
}
function _configureRoyalty(
address rNFTContract,
uint256 tokenId,
address[] calldata recipients,
uint256[] calldata royaltyFractions,
uint256 referenceDepth
) internal {
uint256 totalFraction = 0;
for (uint256 i = 0; i < royaltyFractions.length; i++) {
totalFraction += royaltyFractions[i];
}
require(totalFraction <= MAX_ROYALTY_FRACTION, "Royalty cap exceeded");
ReferenceRoyalty memory config;
config.referenceDepth = referenceDepth;
config.royaltyInfos = new RoyaltyInfo[](recipients.length);
for (uint256 i = 0; i < recipients.length; i++) {
config.royaltyInfos[i] = RoyaltyInfo(recipients[i], royaltyFractions[i]);
}
_royalties[rNFTContract][tokenId] = config;
}
function _safeOwnerOf(IERC5521 rNFT, uint256 tokenId) internal view returns (address) {
address owner = rNFT.ownerOf(tokenId);
require(owner != address(0), "No owner");
return owner;
}
function _calculateChainRoyalties(
address rNFTContract,
uint256 tokenId,
uint256 salePrice
) internal view returns (RoyaltyInfo[] memory) {
ReferenceRoyalty memory currentRoyalty = _royalties[rNFTContract][tokenId];
if (currentRoyalty.royaltyInfos.length == 0) {
return new RoyaltyInfo[](0);
}
IERC5521 rNFT = IERC5521(rNFTContract);
RoyaltyInfo[] memory staged = new RoyaltyInfo[](MAX_CHAIN_STEPS * 32 + 32);
uint256 count = 0;
uint256 totalShare = _sumShares(currentRoyalty);
(uint256 netPrimary, uint256 remainder) = _splitRoyalty(totalShare, salePrice, currentRoyalty.referenceDepth > 0);
count = _appendDistribution(staged, count, currentRoyalty, netPrimary);
if (remainder == 0) {
return _shrink(staged, count);
}
// Layered aggregation (BFS) to support multi-parent merges
uint256 maxItems = MAX_CHAIN_STEPS * 32 + 32;
uint256[] memory curIds = new uint256[](maxItems);
uint256[] memory curAmts = new uint256[](maxItems);
uint256[] memory curDepths = new uint256[](maxItems);
uint256 curCount = 0;
if (remainder > 0 && currentRoyalty.referenceDepth > 0) {
curIds[0] = tokenId;
curAmts[0] = remainder;
curDepths[0] = currentRoyalty.referenceDepth;
curCount = 1;
}
uint256[] memory processed = new uint256[](maxItems);
uint256 processedCount = 0;
while (curCount > 0) {
uint256[] memory nextIds = new uint256[](maxItems);
uint256[] memory nextAmts = new uint256[](maxItems);
uint256[] memory nextDepths = new uint256[](maxItems);
uint256 nextCount = 0;
for (uint256 iL = 0; iL < curCount; iL++) {
uint256 curId = curIds[iL];
uint256 amt = curAmts[iL];
uint256 depth = curDepths[iL];
if (amt == 0) continue;
bool seen = false;
for (uint256 p = 0; p < processedCount; p++) {
if (processed[p] == curId) { seen = true; break; }
}
if (seen) {
address cycOwner = _safeOwnerOf(rNFT, curId);
staged[count++] = RoyaltyInfo(cycOwner, amt);
continue;
}
uint256[] memory refs = rNFT.referred(curId);
if (depth == 0 || refs.length == 0) {
address fallbackOwner = _safeOwnerOf(rNFT, curId);
staged[count++] = RoyaltyInfo(fallbackOwner, amt);
processed[processedCount++] = curId;
continue;
}
uint256 keepBase = (amt * (10_000 - REFERRED_ROYALTY_FRACTION)) / 10_000;
uint256 passBase = amt - keepBase;
uint256 children = refs.length;
if (children > 32) children = 32;
uint256[] memory childIds = new uint256[](children);
uint256[] memory childWeights = new uint256[](children);
ReferenceRoyalty[] memory childConfigs = new ReferenceRoyalty[](children);
uint256 sumWeights = 0;
for (uint256 j = 0; j < children; j++) {
uint256 cid = refs[j];
childIds[j] = cid;
ReferenceRoyalty memory cfg = _royalties[rNFTContract][cid];
childConfigs[j] = cfg;
if (cfg.royaltyInfos.length > 0) {
uint256 w = _sumShares(cfg);
childWeights[j] = w;
sumWeights += w;
}
}
if (sumWeights == 0) {
uint256 each = amt / children;
uint256 rem = amt - (each * children);
for (uint256 j = 0; j < children; j++) {
address ow = _safeOwnerOf(rNFT, childIds[j]);
uint256 share = each + (j == children - 1 ? rem : 0);
staged[count++] = RoyaltyInfo(ow, share);
}
processed[processedCount++] = curId;
continue;
}
uint256 passDistributed = 0;
uint256 lastWeightedIdx = 0;
uint256[] memory keepShares = new uint256[](children);
uint256[] memory passShares = new uint256[](children);
for (uint256 j = 0; j < children; j++) {
if (childWeights[j] == 0) continue;
lastWeightedIdx = j;
uint256 kShare = (keepBase * childWeights[j]) / sumWeights;
uint256 pShare = (passBase * childWeights[j]) / sumWeights;
keepShares[j] = kShare;
passShares[j] = pShare;
passDistributed += pShare;
}
// Remainders: pass and keep
uint256 passRemainder = passBase - passDistributed;
if (passRemainder > 0) {
passShares[lastWeightedIdx] += passRemainder;
}
uint256 keepDistributed = 0;
for (uint256 j2 = 0; j2 < children; j2++) {
keepDistributed += keepShares[j2];
}
uint256 keepRemainder = keepBase - keepDistributed;
if (keepRemainder > 0) {
keepShares[lastWeightedIdx] += keepRemainder;
}
for (uint256 j = 0; j < children; j++) {
if (childWeights[j] == 0) continue;
uint256 kShare = keepShares[j];
uint256 pShare = passShares[j];
ReferenceRoyalty memory cfgj = childConfigs[j];
uint256 cid2 = childIds[j];
uint256 nextDepth = depth > 0 ? depth - 1 : 0;
if (nextDepth == 0) {
// Depth exhausted: distribute both keep and pass to child's recipients
count = _appendDistribution(staged, count, cfgj, kShare + pShare);
} else {
if (kShare > 0) {
count = _appendDistribution(staged, count, cfgj, kShare);
}
if (pShare > 0) {
bool merged = false;
for (uint256 nx = 0; nx < nextCount; nx++) {
if (nextIds[nx] == cid2) {
nextAmts[nx] += pShare;
if (nextDepth > nextDepths[nx]) {
nextDepths[nx] = nextDepth;
}
merged = true;
break;
}
}
if (!merged) {
nextIds[nextCount] = cid2;
nextAmts[nextCount] = pShare;
nextDepths[nextCount] = nextDepth;
nextCount++;
}
}
}
}
processed[processedCount++] = curId;
}
for (uint256 k = 0; k < nextCount; k++) {
curIds[k] = nextIds[k];
curAmts[k] = nextAmts[k];
curDepths[k] = nextDepths[k];
}
curCount = nextCount;
}
return _shrink(staged, count);
}
function _splitRoyalty(uint256 totalRate, uint256 salePrice, bool canPropagate)
internal
pure
returns (uint256 netPrimary, uint256 forwardedAmount)
{
if (totalRate == 0) {
return (0, 0);
}
if (!canPropagate) {
if (salePrice == 0) {
return (totalRate, 0);
}
return ((salePrice * totalRate) / 10_000, 0);
}
if (salePrice == 0) {
if (totalRate <= REFERRED_ROYALTY_FRACTION) {
return (0, totalRate);
}
return (totalRate - REFERRED_ROYALTY_FRACTION, REFERRED_ROYALTY_FRACTION);
}
uint256 gross = (salePrice * totalRate) / 10_000;
uint256 forwarded = (salePrice * REFERRED_ROYALTY_FRACTION) / 10_000;
if (forwarded > gross) {
forwarded = gross;
}
return (gross > forwarded ? gross - forwarded : 0, forwarded);
}
function _sumShares(ReferenceRoyalty memory config) internal pure returns (uint256 total) {
for (uint256 i = 0; i < config.royaltyInfos.length; i++) {
total += config.royaltyInfos[i].royaltyAmount;
}
}
function _appendDistribution(
RoyaltyInfo[] memory staged,
uint256 count,
ReferenceRoyalty memory config,
uint256 amount
) internal pure returns (uint256) {
uint256 len = config.royaltyInfos.length;
if (len == 0) {
return count;
}
require(count + len <= staged.length, "royalty overflow");
if (amount == 0) {
for (uint256 i = 0; i < len; i++) {
staged[count++] = RoyaltyInfo(config.royaltyInfos[i].recipient, 0);
}
return count;
}
uint256 totalShare = _sumShares(config);
if (totalShare == 0) {
staged[count++] = RoyaltyInfo(config.royaltyInfos[0].recipient, amount);
for (uint256 i = 1; i < len; i++) {
staged[count++] = RoyaltyInfo(config.royaltyInfos[i].recipient, 0);
}
return count;
}
uint256 remaining = amount;
for (uint256 i = 0; i < len; i++) {
uint256 share = config.royaltyInfos[i].royaltyAmount;
if (share == 0) {
staged[count++] = RoyaltyInfo(config.royaltyInfos[i].recipient, 0);
continue;
}
uint256 portion = (amount * share) / totalShare;
if (portion > remaining) {
portion = remaining;
}
remaining -= portion;
staged[count++] = RoyaltyInfo(config.royaltyInfos[i].recipient, portion);
}
if (remaining > 0) {
staged[count - 1].royaltyAmount += remaining;
}
return count;
}
function _shrink(RoyaltyInfo[] memory staged, uint256 count)
internal
pure
returns (RoyaltyInfo[] memory out)
{
out = new RoyaltyInfo[](count);
for (uint256 i = 0; i < count; i++) {
out[i] = staged[i];
}
}
function recordRoyaltyPayment(
address rNFTContract,
uint256 tokenId,
address buyer,
ReferenceRoyalty memory royalties
) external {
emit ReferenceRoyaltiesPaid(rNFTContract, tokenId, buyer, msg.sender, royalties);
}
}
setReferenceRoyalty MUST be restricted to authorized roles (e.g., via AccessControl).referenceDepth MUST be capped (e.g., ≤ 3) to avoid high gas costs.Copyright and related rights waived via CC0.