NFT & Digital Assets
NFT Royalties with ERC-2981: Implementation and Marketplace Support
TL;DR
I implement ERC-2981 in every NFT contract I build because it is the closest thing we have to a universal royalty standard. But I am honest with my clients: ERC-2981 is a signal, not an enforcement mechanism. Marketplaces can query the royalty info and choose to ignore it. This article covers the full implementation — from basic single-recipient royalties to multi-party splits — and then dives into the uncomfortable truth about marketplace support, on-chain enforcement patterns like the Operator Filter Registry, and where creator compensation is heading.
The Royalty Problem
When I first started building NFT platforms, the royalty conversation was simple. You set a percentage on OpenSea, and creators got paid on every resale. No standard. No on-chain mechanism. Just a gentleman's agreement between the marketplace and the creator.
Then competition happened.
Marketplaces like LooksRare, X2Y2, and eventually Blur started cutting royalty fees to attract volume. Why pay 10% royalties when you can trade on a platform that charges 0.5% or nothing at all? Overnight, the revenue streams that artists and creators had built their entire business models around started evaporating.
The core issue is architectural. ERC-721 — the standard that defines NFTs — has no concept of royalties. When a token transfers from one wallet to another via transferFrom(), there is no hook, no callback, no mechanism to extract a fee. The transfer just happens. Royalties were always an off-chain, marketplace-level feature bolted on after the fact.
This created three problems I see repeatedly in client projects:
- No interoperability — each marketplace implemented royalties differently. A royalty set on OpenSea did not carry over to Rarible or Foundation.
- No on-chain source of truth — there was nowhere for a new marketplace to query "what royalty does this collection expect?"
- No enforcement — even if a marketplace knew the royalty, honoring it was optional.
ERC-2981 solves the first two problems. The third remains unsolved at the protocol level, and that tension defines the current state of NFT royalties.
ERC-2981 Standard Explained
ERC-2981 is elegantly simple. It adds exactly one function to your NFT contract:
function royaltyInfo(
uint256 tokenId,
uint256 salePrice
) external view returns (address receiver, uint256 royaltyAmount);That is it. Given a token ID and a sale price, the contract returns who should receive the royalty and how much they should receive. No state changes. No transfers. Just information.
The standard also requires your contract to declare support via ERC-165:
function supportsInterface(bytes4 interfaceId) public view returns (bool) {
return interfaceId == type(IERC2981).interfaceId || super.supportsInterface(interfaceId);
}This lets marketplaces detect whether a contract supports royalties by calling supportsInterface(0x2a55205a) — the interface ID for ERC-2981.
What ERC-2981 deliberately does not do:
- It does not transfer funds. The function is
view— it only returns data. The marketplace is responsible for actually sending the royalty. - It does not specify a payment token. The
salePriceis abstract. It could be ETH, USDC, or any ERC-20. The marketplace decides. - It does not enforce compliance. There is no on-chain mechanism that prevents a sale without royalty payment.
- It does not handle splits natively. It returns a single
receiveraddress. Multi-party splits require additional logic.
I appreciate this minimalism. The standard does one thing well: it creates a universal, on-chain source of truth for royalty information. Everything else is left to implementation.
Implementing ERC-2981
Here is how I implement ERC-2981 in production NFT contracts. I use OpenZeppelin's ERC2981 base contract because there is no reason to rewrite what is already battle-tested.
Basic Implementation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {ERC2981} from "@openzeppelin/contracts/token/common/ERC2981.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract RoyaltyNFT is ERC721, ERC2981, Ownable {
uint256 private _nextTokenId;
constructor(
address royaltyReceiver,
uint96 royaltyBps
) ERC721("RoyaltyNFT", "RNFT") Ownable(msg.sender) {
// Set default royalty for all tokens
// royaltyBps is in basis points: 500 = 5%
_setDefaultRoyalty(royaltyReceiver, royaltyBps);
}
function mint(address to) external onlyOwner returns (uint256) {
uint256 tokenId = _nextTokenId++;
_safeMint(to, tokenId);
return tokenId;
}
function updateDefaultRoyalty(
address receiver,
uint96 feeBps
) external onlyOwner {
_setDefaultRoyalty(receiver, feeBps);
}
function setTokenRoyalty(
uint256 tokenId,
address receiver,
uint96 feeBps
) external onlyOwner {
_setTokenRoyalty(tokenId, receiver, feeBps);
}
function supportsInterface(
bytes4 interfaceId
) public view override(ERC721, ERC2981) returns (bool) {
return super.supportsInterface(interfaceId);
}
}Key decisions in this implementation:
- Default royalty applies to all tokens unless overridden. I set this in the constructor so royalties are active from the first mint.
- Per-token royalty lets you override the default for specific tokens. Useful for collaborations or special editions with different royalty terms.
- Basis points — OpenZeppelin uses basis points (1/100th of a percent). 500 = 5%, 1000 = 10%. The maximum is 10000 (100%).
- `supportsInterface` override is required because both ERC721 and ERC2981 implement it. Solidity needs you to resolve the diamond.
How It Works Under the Hood
When a marketplace calls royaltyInfo(tokenId, salePrice), OpenZeppelin's implementation does this:
- Checks if a per-token royalty exists for that
tokenId. - If yes, uses the per-token receiver and fee.
- If no, falls back to the default royalty.
- Calculates the royalty amount:
(salePrice * feeBps) / 10000. - Returns the receiver address and calculated amount.
For a 1 ETH sale with 5% royalty: (1 ether * 500) / 10000 = 0.05 ether.
Custom Royalty Splits
ERC-2981 returns a single receiver address. But most real-world projects need to split royalties between the artist, the platform, a DAO treasury, and sometimes multiple collaborators. I handle this with a royalty splitter contract.
Payment Splitter Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {ERC2981} from "@openzeppelin/contracts/token/common/ERC2981.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract RoyaltySplitter {
struct Split {
address payable recipient;
uint256 share; // basis points out of 10000
}
Split[] public splits;
uint256 public totalShares;
constructor(
address payable[] memory recipients,
uint256[] memory shares
) {
require(
recipients.length == shares.length,
"Length mismatch"
);
uint256 total;
for (uint256 i; i < recipients.length; ++i) {
require(recipients[i] != address(0), "Zero address");
require(shares[i] > 0, "Zero share");
splits.push(Split(recipients[i], shares[i]));
total += shares[i];
}
require(total == 10000, "Shares must total 10000");
totalShares = total;
}
receive() external payable {
distribute();
}
function distribute() public {
uint256 balance = address(this).balance;
require(balance > 0, "No balance");
for (uint256 i; i < splits.length; ++i) {
uint256 amount = (balance * splits[i].share) / totalShares;
(bool sent, ) = splits[i].recipient.call{value: amount}("");
require(sent, "Transfer failed");
}
}
}Then in the NFT contract, you set the splitter as the royalty receiver:
// Deploy splitter first
address payable[] memory recipients = new address payable[](3);
recipients[0] = payable(artist); // 70%
recipients[1] = payable(platform); // 20%
recipients[2] = payable(treasury); // 10%
uint256[] memory shares = new uint256[](3);
shares[0] = 7000;
shares[1] = 2000;
shares[2] = 1000;
RoyaltySplitter splitter = new RoyaltySplitter(recipients, shares);
// Set splitter as the royalty receiver
_setDefaultRoyalty(address(splitter), 500); // 5% totalWhen a marketplace pays royalties to the splitter address, anyone can call distribute() to forward the funds to all recipients. The receive() fallback also triggers distribution automatically on incoming ETH.
I prefer this pull-based approach over trying to split inside the NFT contract itself. It keeps the NFT contract clean and makes the royalty logic independently upgradeable — you can deploy a new splitter and update the royalty receiver without touching the NFT contract.
For production deployments, I typically use OpenZeppelin's PaymentSplitter or 0xSplits, which handles ERC-20 tokens and has been audited extensively. The custom implementation above is for illustration.
Marketplace Support Reality
Here is where I have to be honest with clients, and where many articles on ERC-2981 stop short.
ERC-2981 is advisory. Marketplaces can ignore it.
The current landscape as of late 2024:
| Marketplace | Honors ERC-2981? | Notes |
|---|---|---|
| OpenSea | Yes | Enforced via Operator Filter (optional) |
| Blur | Minimum 0.5% | Reduced from full enforcement |
| Rarible | Yes | Full royalty support |
| Foundation | Yes | Art-focused, full support |
| LooksRare | Optional | Buyer can choose to pay |
| X2Y2 | Optional | Flexible royalty model |
| Zora | Yes | Protocol-level support |
| Sudoswap | No | AMM model, no royalties by design |
The trend is clear: volume-focused marketplaces treat royalties as optional. Art-focused marketplaces honor them. And the creators caught in the middle are building on a standard that was never designed to force compliance.
I had a client in early 2024 who launched a 10,000-piece generative art collection with 7.5% royalties. Their financial model projected $200K in annual royalty revenue based on secondary volume. Within three months, over 60% of secondary sales were happening on platforms that either reduced or skipped royalties entirely. Their actual royalty revenue was under $40K.
This is not a technical failure. ERC-2981 works exactly as designed — it provides information. The failure is in the expectation that information equals enforcement.
Enforcing Royalties On-Chain
Can you actually enforce royalties at the smart contract level? The answer is: partially, with significant trade-offs.
Transfer Hook Approach
The idea is to override the transfer function and require a royalty payment:
function _update(
address to,
uint256 tokenId,
address auth
) internal override returns (address) {
address from = _ownerOf(tokenId);
// Skip royalty on mints and burns
if (from != address(0) && to != address(0)) {
(address receiver, uint256 amount) = royaltyInfo(
tokenId,
msg.value
);
require(msg.value >= amount, "Insufficient royalty");
(bool sent, ) = receiver.call{value: amount}("");
require(sent, "Royalty transfer failed");
}
return super._update(to, tokenId, auth);
}This approach has serious problems:
- You do not know the sale price.
msg.valueon a transfer is usually zero because the payment happens in a separate transaction on the marketplace contract. - Wrapper contracts bypass it. Someone can wrap your NFT in a new contract, trade the wrapper, and unwrap — never triggering your transfer hook.
- OTC deals bypass it. Two parties can agree off-chain, with the seller transferring the NFT and the buyer sending payment in a separate transaction.
The fundamental issue is that ERC-721 transfers are agnostic to payment. The contract cannot distinguish between a sale and a gift, between a 1 ETH trade and a 100 ETH trade.
The Operator Filter Registry
OpenSea's Operator Filter Registry was the most significant attempt at royalty enforcement. The concept: block your NFT from being traded on marketplaces that do not honor royalties.
How It Worked
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {ERC2981} from "@openzeppelin/contracts/token/common/ERC2981.sol";
import {DefaultOperatorFilterer} from
"operator-filter-registry/DefaultOperatorFilterer.sol";
contract FilteredNFT is
ERC721,
ERC2981,
DefaultOperatorFilterer
{
function setApprovalForAll(
address operator,
bool approved
) public override onlyAllowedOperatorApproval(operator) {
super.setApprovalForAll(operator, approved);
}
function approve(
address operator,
uint256 tokenId
) public override onlyAllowedOperatorApproval(operator) {
super.approve(operator, tokenId);
}
function transferFrom(
address from,
address to,
uint256 tokenId
) public override onlyAllowedOperator(msg.sender) {
super.transferFrom(from, to, tokenId);
}
function safeTransferFrom(
address from,
address to,
uint256 tokenId,
bytes memory data
) public override onlyAllowedOperator(msg.sender) {
super.safeTransferFrom(from, to, tokenId, data);
}
}The onlyAllowedOperator modifier checked against a centralized registry of blocked marketplace contracts. If a marketplace did not honor royalties, its contracts were added to the blocklist, and NFTs using the filter would revert on transfer attempts through those contracts.
Why It Was Controversial
The Operator Filter Registry was a centralized solution to a decentralization problem. OpenSea maintained the blocklist. They decided which marketplaces were compliant. This gave a single company control over where NFTs could trade — exactly the kind of gatekeeping that Web3 was supposed to eliminate.
In August 2023, OpenSea announced they would stop enforcing the filter for new collections and would make it optional for existing ones. The experiment was over. The market had spoken: traders valued freedom of movement over royalty enforcement.
I still see projects deploying with operator filtering, and I understand why. If your collection trades primarily on OpenSea and you want to maximize royalty revenue, the filter provides a real mechanism. But I always advise clients to understand the trade-off: you are restricting where your NFT can be traded, which can reduce liquidity and, paradoxically, reduce total secondary volume.
Testing Royalties
Testing royalty logic is straightforward with Foundry. Here is my standard test suite:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {RoyaltyNFT} from "../src/RoyaltyNFT.sol";
contract RoyaltyTest is Test {
RoyaltyNFT nft;
address owner = makeAddr("owner");
address artist = makeAddr("artist");
address buyer = makeAddr("buyer");
function setUp() public {
vm.prank(owner);
nft = new RoyaltyNFT(artist, 500); // 5% royalty
}
function test_DefaultRoyalty() public view {
uint256 salePrice = 1 ether;
(address receiver, uint256 amount) = nft.royaltyInfo(0, salePrice);
assertEq(receiver, artist);
assertEq(amount, 0.05 ether); // 5% of 1 ETH
}
function test_PerTokenRoyaltyOverride() public {
address collaborator = makeAddr("collaborator");
vm.prank(owner);
nft.mint(buyer);
vm.prank(owner);
nft.setTokenRoyalty(0, collaborator, 1000); // 10%
(address receiver, uint256 amount) = nft.royaltyInfo(
0,
1 ether
);
assertEq(receiver, collaborator);
assertEq(amount, 0.1 ether);
}
function test_SupportsERC2981Interface() public view {
assertTrue(nft.supportsInterface(0x2a55205a));
}
function test_SupportsERC721Interface() public view {
assertTrue(nft.supportsInterface(0x80ac58cd));
}
function testFuzz_RoyaltyCalculation(
uint256 salePrice
) public view {
salePrice = bound(salePrice, 0, type(uint128).max);
(, uint256 amount) = nft.royaltyInfo(0, salePrice);
// Royalty should never exceed sale price
assertLe(amount, salePrice);
// Royalty should be exactly 5%
assertEq(amount, (salePrice * 500) / 10000);
}
function test_UpdateRoyalty() public {
address newReceiver = makeAddr("new");
vm.prank(owner);
nft.updateDefaultRoyalty(newReceiver, 750); // 7.5%
(address receiver, uint256 amount) = nft.royaltyInfo(
0,
1 ether
);
assertEq(receiver, newReceiver);
assertEq(amount, 0.075 ether);
}
function test_OnlyOwnerCanUpdateRoyalty() public {
vm.prank(buyer);
vm.expectRevert();
nft.updateDefaultRoyalty(buyer, 1000);
}
}Key things I always test:
- Default royalty returns correct receiver and amount for known sale prices.
- Per-token overrides take precedence over the default.
- ERC-165 interface detection returns true for both ERC-2981 (
0x2a55205a) and ERC-721 (0x80ac58cd). - Fuzz testing with random sale prices to catch overflow or rounding issues.
- Access control ensures only authorized addresses can modify royalty settings.
- Edge cases — zero sale price, maximum uint values, royalty changes after minting.
Run the tests with:
forge test --match-contract RoyaltyTest -vvvFuture of Creator Compensation
The royalty debate is not really about ERC-2981. It is about whether the NFT ecosystem values creators enough to build infrastructure that compensates them.
Here is where I see things heading:
Protocol-Level Royalties
ERC-721C by Limit Break introduces a transfer validation system that lets creators define programmable transfer policies. Instead of blocking marketplaces, the contract itself validates whether the transfer meets the creator's requirements — including royalty payment. This is closer to true enforcement because the validation happens inside the transfer function, not through an external registry.
Marketplace-Level Solutions
Some marketplaces are building royalty payment into the protocol itself. Zora's protocol, for instance, handles royalty distribution at the contract level during the sale — not as an afterthought. When the sale and the royalty are atomic (same transaction), enforcement is natural.
New Business Models
The most pragmatic creators I work with are not waiting for enforcement. They are building business models that do not depend on secondary royalties:
- Utility-gated access — holding the NFT grants access to services, content, or communities. Revenue comes from the utility, not the resale.
- Staking and rewards — token holders stake their NFTs for ecosystem rewards. Engagement creates value.
- Edition-based drops — instead of one expensive drop with royalty expectations, frequent affordable editions that generate primary sale revenue.
- On-chain licensing — the NFT represents a license, and commercial use requires on-chain registration with fee payment.
My Recommendation
For every NFT project I build, I implement ERC-2981 with a reasonable royalty (2.5%–5%). I set up proper royalty splitting. I add the Operator Filter if the client wants it, with full disclosure about the trade-offs. And then I help them build a business model that treats royalties as bonus revenue, not baseline income.
The standard works. The enforcement does not — yet. Build accordingly.
Key Takeaways
- ERC-2981 is the royalty standard. Implement it in every NFT contract. It is simple, gas-efficient, and universally recognized.
- Royalties are advisory, not enforced. Marketplaces query
royaltyInfo()but are not obligated to pay. Build business models that account for this. - Use OpenZeppelin's implementation. Battle-tested, gas-optimized, and supports both default and per-token royalties.
- Payment splitters handle multi-party royalties. Deploy a splitter contract as the royalty receiver for clean separation of concerns.
- The Operator Filter is a trade-off. It restricts trading venues in exchange for royalty enforcement on compliant platforms.
- Test royalties with fuzz testing. Random sale prices catch overflow and rounding bugs that unit tests miss.
- Build utility beyond royalties. The most sustainable NFT projects do not depend on secondary sale fees for revenue.
If you are building an NFT platform and need ERC-2981 implemented correctly — with proper splits, marketplace strategy, and a sustainable revenue model — reach out through my services page. I have shipped royalty systems for collections ranging from 1,000 to 50,000 pieces, and I will help you navigate the enforcement landscape honestly.
*Uvin Vindula is a Web3 and AI engineer based between Sri Lanka and the UK, building production-grade smart contracts, decentralized applications, and full-stack platforms. Follow his work at uvin.lk↗ or reach out at contact@uvin.lk.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.