ERC-2981: How NFT Royalties Work at the Contract Level
Before ERC-2981, NFT royalties were a handshake agreement between creators and marketplaces. Each platform did things differently. Most did nothing. A creator could mint a collection, watch it trade for millions in secondary sales, and receive nothing past the initial mint.
ERC-2981 is a standard interface that gives any NFT contract a way to report royalty information: who gets paid, and how much, for a given sale price. Marketplaces that support the standard query this information and route payments correctly.
This post explains how the standard works, how to implement it in Solidity using OpenZeppelin, and what its real limitations are.
The Interface
The entire standard is built around one function:
interface IERC2981 {
function royaltyInfo(
uint256 tokenId,
uint256 salePrice
) external view returns (
address receiver,
uint256 royaltyAmount
);
}
When a supported marketplace processes a sale, it calls royaltyInfo() with the token ID and the sale price in wei. The contract returns two values: the address that should receive the royalty, and the amount in wei.
That's the full interface. The standard does not define how royalties are stored, calculated, or updated. That's left to the implementation.
Royalties in Basis Points
Royalty percentages in ERC-2981 are expressed in basis points. One basis point equals 0.01%, so 10,000 basis points equals 100%.
| Percentage | Basis Points |
|---|---|
| 1% | 100 |
| 2.5% | 250 |
| 5% | 500 |
| 7.5% | 750 |
| 10% | 1000 |
The calculation used to convert a sale price to a royalty amount is:
royaltyAmount = (salePrice * feeBasisPoints) / 10000
For a 2 ETH sale at 5%:
royaltyAmount = (2_000_000_000_000_000_000 * 500) / 10000
royaltyAmount = 100_000_000_000_000_000 wei = 0.1 ETH
Basis points avoid floating point entirely, which Solidity does not support.
A Basic Implementation with OpenZeppelin
OpenZeppelin provides an ERC2981 base contract that handles the storage and calculation logic. You inherit it alongside ERC721 and override supportsInterface() to declare both:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/common/ERC2981.sol";
contract MyNFT is ERC721, ERC2981 {
constructor() ERC721("MyNFT", "MNFT") {
// 5% royalty to the deployer for all tokens
_setDefaultRoyalty(msg.sender, 500);
}
// Required when inheriting multiple contracts that implement supportsInterface
function supportsInterface(bytes4 interfaceId)
public
view
virtual
override(ERC721, ERC2981)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
The _setDefaultRoyalty() call sets a royalty that applies to every token in the collection unless overridden.
Default Royalties vs Per-Token Royalties
ERC-2981 supports two royalty models. You can use either or both in the same contract.
Default royalty applies to every token that does not have its own royalty set:
_setDefaultRoyalty(creatorAddress, 750); // 7.5% for all tokens
Per-token royalty overrides the default for a specific token:
_setTokenRoyalty(tokenId, specificCreator, 1000); // 10% for this token only
Per-token royalties are useful for collaborative collections where different artists contribute individual pieces and each should receive royalties from their own work.
A Complete Example: Mintable Collection with Updatable Royalties
Here is a more realistic contract. Each token sets its royalty to the minting address at mint time. The token owner can update the royalty later.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/common/ERC2981.sol";
contract ArtCollection is ERC721, ERC2981 {
uint256 private _nextTokenId;
constructor() ERC721("Art Collection", "ART") {}
function mint(address to) public returns (uint256) {
uint256 tokenId = ++_nextTokenId;
_safeMint(to, tokenId);
// 2.5% royalty going to the minting address
_setTokenRoyalty(tokenId, to, 250);
return tokenId;
}
function updateRoyalty(
uint256 tokenId,
address receiver,
uint96 feeNumerator
) external {
require(ownerOf(tokenId) == msg.sender, "Not token owner");
_setTokenRoyalty(tokenId, receiver, feeNumerator);
}
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, ERC2981)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
Allowing token owners to update royalties is a design choice with real tradeoffs. It gives flexibility but also lets someone change where royalties go after a token is sold. Consider whether that fits your use case before including
updateRoyalty().
How Marketplaces Check for Support
Before calling royaltyInfo(), a marketplace should verify the contract declares ERC-2981 support through ERC-165:
bytes4 private constant _INTERFACE_ID_ERC2981 = 0x2a55205a;
function supportsRoyalties(address nftContract) public view returns (bool) {
try IERC165(nftContract).supportsInterface(_INTERFACE_ID_ERC2981)
returns (bool supported)
{
return supported;
} catch {
return false;
}
}
The interface ID 0x2a55205a is derived from the royaltyInfo() function selector. It is fixed by the standard.
Since royaltyInfo() is a view function, calling it costs no gas. Marketplaces can query royalty data off-chain when building a transaction, then include the royalty transfer in the sale transaction itself.
What ERC-2981 Cannot Do
The standard has three limitations worth understanding before you rely on it.
Royalties are not enforced. A marketplace can call royaltyInfo() and then ignore the result. The standard provides information, not enforcement. There is no mechanism in the standard itself to block a transfer on a non-compliant platform.
OpenSea attempted to address this in 2022 with the Operator Filter Registry, which let creators block transfers through contracts that did not honor royalties. The approach had mixed results and generated significant debate about creator rights versus open market access. It remains an unsolved problem at the protocol level.
Only one recipient per token. royaltyInfo() returns a single address. If you want royalties split among multiple collaborators, you need a separate payment splitter contract that acts as the recipient and redistributes funds internally:
contract RoyaltySplitter {
address[] public payees;
uint256[] public shares;
constructor(address[] memory _payees, uint256[] memory _shares) {
// store payees and their share ratios
}
receive() external payable {
// distribute incoming ETH proportionally
}
}
You would deploy this splitter, then set its address as the royalty receiver in your NFT contract.
Marketplace adoption is not guaranteed. Major platforms like OpenSea, Rarible, and LooksRare support ERC-2981. Smaller exchanges, aggregators, and peer-to-peer tools may not. If someone trades your NFT through a non-supporting venue, the royalty is not paid and nothing in the standard can stop that.
When to Use ERC-2981
ERC-2981 is the right choice when:
- You are building a new NFT collection and want royalty support on major marketplaces
- Different tokens in your collection should have different royalty rates or recipients
- You want a standard that existing tooling already understands
It is not a complete solution when on-chain royalty enforcement is a hard requirement. For that, you need transfer restrictions at the token level, which come with their own tradeoffs around composability and user experience.
Summary
ERC-2981 solves a specific, narrow problem: it gives NFT contracts a standard way to report royalty information. Any marketplace that queries royaltyInfo() and acts on the result will pay royalties correctly. The standard works well for that purpose.
What it does not solve is enforcement. A standard can only define a shared language. Whether platforms respect that language is a social and economic question, not a technical one.