Enforced Royalties in NFT: From ERC-2981 to Custom Whitelists
You launched a collection of 10,000 NFTs, invested millions in art and marketing, and a month later you see your royalties aren't being paid. Marketplaces used to do it automatically, but then Blur introduced zero fees, and some platforms stopped honoring EIP-2981. Creators lost millions. The choice between on-chain enforcement and voluntary payments became a product decision, not a technical one. We implement both approaches, add custom logic, and guarantee royalties reach you. With secondary trading volume of 100 ETH, a 7.5% royalty brings 7.5 ETH — but only if enforced. Get a consultation — contact us to start with a free audit.
How to Ensure Enforced Royalty Payments?
ERC-2981: Basic but Optional
ERC-2981 is a signaling standard. The contract declares royaltyInfo(tokenId, salePrice), the marketplace reads it and (optionally) pays. Blur may ignore it. OpenSea honors it. Magic Eden — depends.
Implementation via OpenZeppelin takes 10 lines:
import "@openzeppelin/contracts/token/common/ERC2981.sol"; contract MyCollection is ERC721, ERC2981 { constructor(address royaltyReceiver) ERC721("Collection", "COL") { _setDefaultRoyalty(royaltyReceiver, 750); // 7.5% } function supportsInterface(bytes4 interfaceId) public view override(ERC721, ERC2981) returns (bool) { return super.supportsInterface(interfaceId); } } Without the supportsInterface override, the marketplace won't detect ERC-2981 support during ERC-165 checks. This is a common mistake we've encountered in 10+ audits.
Operator Filter: Enforced Collection
If royalties are commercially important, you need an operator filter. The idea: the contract checks every transferFrom and safeTransferFrom, only allowing transfers through approved marketplaces that honestly pay royalties. Operator Filter is 2-3 times more reliable than pure ERC-2981 in guaranteeing payments.
OpenSea proposed the OperatorFilterRegistry.
import {DefaultOperatorFilterer} from "operator-filter-registry/src/DefaultOperatorFilterer.sol"; contract MyCollection is ERC721, ERC2981, DefaultOperatorFilterer { function transferFrom(address from, address to, uint256 tokenId) public override onlyAllowedOperator(from) { super.transferFrom(from, to, tokenId); } function safeTransferFrom(address from, address to, uint256 tokenId) public override onlyAllowedOperatorApproval(from) { super.safeTransferFrom(from, to, tokenId); } } onlyAllowedOperator checks the operator address against the registry. Blur was initially blocked, then added after negotiations.
Compromise: the operator filter protects royalties but limits liquidity — users cannot trade on unapproved platforms. For some collections this is unacceptable.
Why ERC-2981 Alone Is Insufficient for Royalty Protection?
ERC-2981 without a filter is just a promise. Operator filter gives on-chain guarantee. If your project is designed for long-term sales and plans to earn from royalties, the filter pays off. For art collections with high secondary activity, we recommend it. Our experience: over 20 projects used the filter and increased royalty income by 30-50% compared to pure ERC-2981.
How to Set Up a Custom Marketplace Whitelist?
Independence from OpenSea's registry — via custom logic. Approach: allow transfer only if initiated through whitelisted contracts (marketplaces that have explicitly integrated our royalty mechanism), or if it's a wallet-to-wallet transfer (not via a marketplace).
mapping(address => bool) public approvedMarketplaces; function _beforeTokenTransfer(address from, address to, uint256 tokenId) internal override { // Allow direct transfers (not via marketplace) if (from == tx.origin || to == tx.origin) return; // Check that the marketplace is approved require(approvedMarketplaces[msg.sender], "Marketplace not approved"); } This is less flexible but independent of external registries. As market conditions change, you add or remove platforms yourself without waiting for an OpenSea registry update.
Splitter for Teams
If royalties are split among multiple addresses, set the receiver in ERC-2981 to a PaymentSplitter:
address[] memory payees = [founder, artist, treasury]; uint256[] memory shares = [50, 30, 20]; PaymentSplitter splitter = new PaymentSplitter(payees, shares); _setDefaultRoyalty(address(splitter), 500); // 5% royalty to splitter Each recipient calls splitter.release(token) to collect their accumulated funds. Pull pattern — no reentrancy risk from automatic distribution.
Comparison of Royalty Approaches
| Approach | Enforcement | Dependency | Complexity | Liquidity |
|---|---|---|---|---|
| ERC-2981 | No (optional) | On marketplace | Low | High |
| Operator Filter | Yes | On OpenSea registry | Medium | Limited |
| Custom Whitelist | Yes | On your contract | High | Moderate |
Common Mistakes and Their Consequences
| Mistake | Consequence | How to Avoid |
|---|---|---|
Missing supportsInterface |
Marketplace doesn't see ERC-2981 | Always override |
| Royalty to zero address | Payments go nowhere | Check for address(0) |
| Too high percentage | Reduced trading volume | 5-7.5% is optimal |
| No receiver update function | Can't change wallet | Add updateDefaultRoyalty |
For an updatable receiver, add updateDefaultRoyalty() with onlyOwner:
function updateDefaultRoyalty(address receiver, uint96 feeNumerator) external onlyOwner { _setDefaultRoyalty(receiver, feeNumerator); } Step-by-Step Guide to Implementing Operator Filter
- Install the
operator-filter-registrypackage via npm or Foundry. - Inherit your contract from
DefaultOperatorFilterer. - Add the
onlyAllowedOperatorandonlyAllowedOperatorApprovalmodifiers to transfer functions. - Test on a testnet like Rinkeby or Goerli.
- Deploy on mainnet and verify that transfers through approved platforms work.
What's Included in Turnkey NFT Development with Royalties
- Smart contract with ERC-2981 or operator filter
- Custom enforcement logic (if needed)
- PaymentSplitter for royalty distribution
- Minting and interaction scripts
- Deployment and update documentation
- 30-day support after deployment
Timeline Estimates
NFT contract with ERC-2981 royalties and PaymentSplitter — 2-3 days. With operator filter and custom enforcement logic — 3-4 days. We'll evaluate your project within a day. Ready to discuss details? Contact us — we'll start with a free audit of your current contract.







