Development of NFT with Royalties
In 2022-2023, marketplaces started making royalties optional—Blur offered zero fees, part of OpenSea's audience moved there. Collection creators lost millions. This led to two camps: those who enforce royalties on-chain and those who rely on marketplace goodwill. The choice between them is not technical but a product decision. We implement both approaches.
ERC-2981 as the Basic Standard
ERC-2981 is a signaling standard. The contract declares royaltyInfo(tokenId, salePrice), and the marketplace reads and (optionally) pays it. Blur can ignore it. OpenSea respects 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 see ERC-2981 support on ERC-165 check.
Operator Filter: On-chain Enforcement
If royalties are commercially important—use operator filter. The idea: the contract checks every transferFrom and safeTransferFrom, allowing transfer only through approved marketplaces that honestly pay royalties.
OpenSea proposed OperatorFilterRegistry in 2022:
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's address in OperatorFilterRegistry. Blur was initially blocked, then added after negotiations.
Tradeoff: operator filter protects royalties but limits liquidity—users can't trade on unapproved platforms. For some collections, this is unacceptable.
Custom Royalty Enforcement Logic
Independence from OpenSea's registry—via custom logic. Approach: allow transfer only if initiated through a whitelist of contracts (marketplaces that explicitly integrated our royalty mechanism), or if it's wallet-to-wallet (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 marketplace is approved
require(approvedMarketplaces[msg.sender], "Marketplace not approved");
}
Less flexible but independent from external registries.
Splitter for Teams
If royalties are split among multiple addresses, set the receiver in ERC-2981 to PaymentSplitter:
address[] memory payees = [founder, artist, treasury];
uint256[] memory shares = [50, 30, 20];
PaymentSplitter splitter = new PaymentSplitter(payees, shares);
_setDefaultRoyalty(address(splitter), 500); // 5% royalties to splitter
Each recipient calls splitter.release(token) to withdraw accumulated funds. Pull pattern—no reentrancy risk with automatic distribution.
Common Mistakes
Forgotten supportsInterface—the marketplace doesn't see ERC-2981. Royalties to zero address with address(0) receiver—payments go nowhere. Too high royalties (>10%) reduce trading volume. No way to update the receiver—the creator can't change their wallet address.
To make the receiver updatable, add updateDefaultRoyalty() with onlyOwner:
function updateDefaultRoyalty(address receiver, uint96 feeNumerator)
external onlyOwner {
_setDefaultRoyalty(receiver, feeNumerator);
}
Timeline Estimates
NFT contract with ERC-2981 royalties and PaymentSplitter—2-3 days. With operator filter and custom enforcement logic—3-4 days.







