NFT royalties were supposed to be one of Web3's most compelling creator-economy primitives: a mechanism that automatically compensates artists and builders every time their work changes hands on secondary markets. EIP-2981 formalized the interface in 2021, and OpenZeppelin's ERC2981 implementation made it trivially easy to add royalty logic to any token contract. Yet royalty theft remains widespread, and many exploits don't require a sophisticated attacker—they stem from mundane Solidity mistakes that any automated scanner can catch.
This post breaks down six recurring vulnerability classes in EIP-2981 royalty implementations. For each one you'll find a vulnerable code example, a clear explanation of the impact, a corrected version, and concrete detection tips.
1. royaltyInfo Not Enforced On-Chain
Vulnerable Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
contract SimpleNFT is ERC721, ERC2981 {
constructor() ERC721("SimpleNFT", "SNFT") {
_setDefaultRoyalty(msg.sender, 500); // 5%
}
function mint(address to, uint256 tokenId) external {
_mint(to, tokenId);
}
}
Explanation
EIP-2981 is a reporting standard, not an enforcement mechanism. royaltyInfo() tells marketplaces how much royalty to pay and to whom—but the ERC-721 transfer functions (transferFrom, safeTransferFrom) have no built-in hook to validate that payment actually occurred. Any marketplace, aggregator, or peer-to-peer trader can call transferFrom directly, completely ignoring the royalty data returned by royaltyInfo. OpenSea, Blur, and others have repeatedly shown that operator fee policies are negotiable business decisions, not protocol-level guarantees.
On-chain enforcement requires intercepting transfers. ERC-721C (Limit Break's extension) and similar approaches use _beforeTokenTransfer or operator allow-lists to reject transfers that don't originate from trusted, royalty-paying marketplaces. Without such a hook, royaltyInfo is advisory only.
Fixed Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract EnforcedRoyaltyNFT is ERC721, ERC2981, Ownable {
mapping(address => bool) public approvedMarketplaces;
constructor() ERC721("EnforcedNFT", "ENFT") Ownable(msg.sender) {
_setDefaultRoyalty(msg.sender, 500);
}
function setApprovedMarketplace(address market, bool approved) external onlyOwner {
approvedMarketplaces[market] = approved;
}
function _update(address to, uint256 tokenId, address auth)
internal override returns (address)
{
address from = _ownerOf(tokenId);
// Allow mints (from == address(0)) and approved operators only
if (from != address(0)) {
require(
approvedMarketplaces[msg.sender] || msg.sender == from,
"Transfers only via approved marketplaces"
);
}
return super._update(to, tokenId, auth);
}
function mint(address to, uint256 tokenId) external onlyOwner {
_mint(to, tokenId);
}
}
Detection Tips
- Search for contracts that inherit
ERC2981but contain no_beforeTokenTransfer,_update, or operator filtering logic. - Grep for
transferFromcalls in test suites that bypass marketplace contracts—if they succeed without royalty payment, enforcement is missing. - Check whether the contract registers an operator allow-list; absence is a red flag in royalty-dependent collections.
2. Royalty Receiver Set to Zero Address
Vulnerable Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract ZeroAddressBug is ERC721, ERC2981, Ownable {
constructor() ERC721("ZeroBug", "ZBUG") Ownable(msg.sender) {}
// No zero-address check on receiver
function setRoyalty(address receiver, uint96 feeNumerator) external onlyOwner {
_setDefaultRoyalty(receiver, feeNumerator);
}
}
Explanation
OpenZeppelin's _setDefaultRoyalty does validate that receiver != address(0) in recent versions, but custom implementations or forked versions frequently drop this guard. When the receiver is set to the zero address, any marketplace that respects EIP-2981 will send royalty payments to address(0)—effectively burning ETH or ERC-20 tokens. Funds sent to the zero address are unrecoverable. The creator loses all secondary-sale income silently; nothing reverts, and the transaction appears successful.
Fixed Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract SafeRoyaltyReceiver is ERC721, ERC2981, Ownable {
constructor(address royaltyRecipient) ERC721("SafeNFT", "SNFT") Ownable(msg.sender) {
require(royaltyRecipient != address(0), "Zero address receiver");
_setDefaultRoyalty(royaltyRecipient, 500);
}
function setRoyalty(address receiver, uint96 feeNumerator) external onlyOwner {
require(receiver != address(0), "Zero address receiver");
_setDefaultRoyalty(receiver, feeNumerator);
}
}
Detection Tips
- Static analysis: flag every call to
_setDefaultRoyaltyor_setTokenRoyaltywhere the first argument is not validated againstaddress(0). - Search constructor and setter functions for missing require/revert guards on the receiver parameter.
- Review upgrade history—a previously valid receiver can be overwritten to zero in a botched admin call.
3. Royalty Percentage Exceeds 100%
Vulnerable Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract UnboundedRoyalty is ERC721, ERC2981, Ownable {
constructor() ERC721("UnboundedNFT", "UNFT") Ownable(msg.sender) {}
// feeNumerator can be set to any value, including > 10000
function setRoyalty(address receiver, uint96 feeNumerator) external onlyOwner {
_setDefaultRoyalty(receiver, feeNumerator); // no cap enforced here
}
}
Explanation
EIP-2981 uses a fee denominator of 10,000 by default (representing basis points), so a feeNumerator above 10,000 implies a royalty greater than 100% of the sale price. royaltyInfo would return a royalty amount larger than salePrice. Marketplaces that blindly forward the returned amount will either revert (if the buyer has insufficient funds) or silently under-pay (if they cap the transfer). Either way, the sale breaks or the creator receives incorrect amounts. A misconfigured numerator can permanently break secondary-market trading for the entire collection.
Fixed Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract BoundedRoyalty is ERC721, ERC2981, Ownable {
uint96 public constant MAX_ROYALTY_BPS = 1000; // 10% hard cap
constructor() ERC721("BoundedNFT", "BNFT") Ownable(msg.sender) {}
function setRoyalty(address receiver, uint96 feeNumerator) external onlyOwner {
require(receiver != address(0), "Zero address receiver");
require(feeNumerator <= MAX_ROYALTY_BPS, "Royalty exceeds max");
_setDefaultRoyalty(receiver, feeNumerator);
}
}
Detection Tips
- Look for
_setDefaultRoyaltyor_setTokenRoyaltycalls that acceptfeeNumeratoras an unconstrained function parameter. - Check constructor arguments: royalty numerator passed at deployment time is often not validated.
- Verify that
_feeDenominator()returns the expected value if overridden; a custom denominator changes the scale for all numerator validation.
4. Wrapped NFT Royalty Bypass
Vulnerable Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
// Legitimate collection with royalties
contract RoyaltyNFT is ERC721, ERC2981 {
constructor() ERC721("RoyaltyNFT", "RNFT") {
_setDefaultRoyalty(msg.sender, 750); // 7.5%
}
function mint(address to, uint256 id) external { _mint(to, id); }
}
// Wrapper: holder deposits RoyaltyNFT, receives a wrapper token
// Wrapper is then traded freely with zero royalty obligations
contract WrappedNFT is ERC721 {
RoyaltyNFT public immutable underlying;
constructor(address _underlying) ERC721("WrappedNFT", "WNFT") {
underlying = RoyaltyNFT(_underlying);
}
function wrap(uint256 tokenId) external {
underlying.transferFrom(msg.sender, address(this), tokenId);
_mint(msg.sender, tokenId);
}
function unwrap(uint256 tokenId) external {
require(ownerOf(tokenId) == msg.sender, "Not owner");
_burn(tokenId);
underlying.transferFrom(address(this), msg.sender, tokenId);
}
}
Explanation
Once a token is locked inside a wrapper contract, secondary-market trades move wrapper tokens rather than the underlying NFT. The wrapper has no royalty logic, so buyers and sellers pay zero creator fees. Because the underlying NFT never moves during wrapper-token transfers, royaltyInfo on the original contract is never consulted. This is an economic attack that requires no Solidity exploit—any user can deploy a wrapper and undercut royalties. Mitigation must occur at the transfer-hook level of the original contract.
Fixed Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract AntiWrapNFT is ERC721, ERC2981, Ownable {
mapping(address => bool) public allowedReceivers;
constructor() ERC721("AntiWrapNFT", "AWNFT") Ownable(msg.sender) {
_setDefaultRoyalty(msg.sender, 750);
}
function setAllowedReceiver(address account, bool allowed) external onlyOwner {
allowedReceivers[account] = allowed;
}
function _update(address to, uint256 tokenId, address auth)
internal override returns (address)
{
// Reject transfers to non-EOA contracts not on the allow-list
if (to.code.length > 0 && !allowedReceivers[to]) {
revert("Transfers to unapproved contracts blocked");
}
return super._update(to, tokenId, auth);
}
function mint(address to, uint256 id) external onlyOwner { _mint(to, id); }
}
Detection Tips
- Identify contracts that lock ERC-721 tokens and issue synthetic receipts without implementing ERC2981 themselves.
- Check whether the original collection's transfer hooks block deposits into arbitrary smart contracts.
- Monitor for bulk deposits (single address receiving many tokenIds in quick succession) followed by thin secondary-market activity on a sibling contract.
5. Royalty Receiver Not Updateable
Vulnerable Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
contract ImmutableRoyalty is ERC721, ERC2981 {
// Receiver baked in at deployment — no setter function
constructor(address creator) ERC721("ImmutableNFT", "IMFT") {
_setDefaultRoyalty(creator, 500);
}
function mint(address to, uint256 id) external { _mint(to, id); }
}
Explanation
Hardcoding the royalty receiver at deployment with no update path creates a single point of failure. If the creator's wallet is compromised, phished, or lost, all future royalty payments flow permanently to an attacker or into an inaccessible wallet. There is no recovery mechanism. Collections with long lifespans—generative art, gaming assets—need to be able to redirect royalties to a multisig, a DAO treasury, or a replacement wallet without redeploying the contract.
Fixed Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC2981/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable2Step.sol";
contract UpdateableRoyalty is ERC721, ERC2981, Ownable2Step {
event RoyaltyUpdated(address indexed newReceiver, uint96 newFee);
constructor(address creator) ERC721("UpdateableNFT", "UNFT") Ownable(msg.sender) {
require(creator != address(0), "Zero address");
_setDefaultRoyalty(creator, 500);
}
function updateRoyalty(address newReceiver, uint96 newFee) external onlyOwner {
require(newReceiver != address(0), "Zero address");
require(newFee <= 1000, "Exceeds 10% cap");
_setDefaultRoyalty(newReceiver, newFee);
emit RoyaltyUpdated(newReceiver, newFee);
}
function mint(address to, uint256 id) external onlyOwner { _mint(to, id); }
}
Detection Tips
- Search for contracts that call
_setDefaultRoyaltyonly inside the constructor, with no corresponding public or external setter function. - Verify whether the royalty receiver is a multisig (e.g., a Gnosis Safe) rather than an EOA; immutable EOA receivers are particularly risky.
- Confirm that ownership uses
Ownable2Stepor an equivalent two-step transfer to reduce admin-key compromise risk.
6. Royalty Info Manipulation via Re-Initialization
Vulnerable Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC2981/ERC2981Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract UpgradeableNFT is Initializable, ERC721Upgradeable, ERC2981Upgradeable, OwnableUpgradeable {
// Missing initializer modifier — can be called multiple times
function initialize(address creator, uint96 fee) public {
__ERC721_init("UpgradeableNFT", "UNFT");
__ERC2981_init();
__Ownable_init(msg.sender);
_setDefaultRoyalty(creator, fee);
}
}
Explanation
Upgradeable contracts that expose an initialize function without the initializer or reinitializer modifier can be called repeatedly by anyone. An attacker who calls initialize a second time can overwrite the royalty receiver and fee to values they control, redirecting all future royalty income. Because royalty storage is not separately guarded, re-initialization is equivalent to a full royalty hijack. This is structurally similar to the Parity wallet initialization bug and has appeared in several NFT contracts audited post-deployment.
Fixed Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/token/ERC2981/ERC2981Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract SecureUpgradeableNFT is
Initializable,
ERC721Upgradeable,
ERC2981Upgradeable,
OwnableUpgradeable
{
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() { _disableInitializers(); }
function initialize(address creator, uint96 fee) public initializer {
require(creator != address(0), "Zero address");
require(fee <= 1000, "Exceeds 10% cap");
__ERC721_init("SecureUpgradeableNFT", "SUNFT");
__ERC2981_init();
__Ownable_init(msg.sender);
_setDefaultRoyalty(creator, fee);
}
function updateRoyalty(address newReceiver, uint96 newFee) external onlyOwner {
require(newReceiver != address(0), "Zero address");
require(newFee <= 1000, "Exceeds 10% cap");
_setDefaultRoyalty(newReceiver, newFee);
}
}
Detection Tips
- For upgradeable contracts: verify that every
initializefunction carries theinitializerorreinitializer(n)modifier. - Check that the implementation constructor calls
_disableInitializers()to prevent direct initialization of the logic contract. - Use a storage-layout diff tool (e.g., OpenZeppelin Upgrades plugin) to detect whether a new implementation version accidentally shifts royalty storage slots.
Protecting Your NFT Collection
EIP-2981 royalties sit at the intersection of economic design and smart contract security. Mistakes in royalty implementations are silent—funds drain, payments route to dead addresses, and wrapping exploits unfold without touching a single critical function. The six vulnerability classes above span everything from advisory-only enforcement to re-initialization hijacks, and all of them are detectable with automated analysis before deployment.
ContractScan scans NFT contracts for all of these patterns—zero-address receivers, uncapped fee numerators, missing initializer guards, and missing transfer hooks—before your collection goes live. Run a free scan at contractscan.io and get a detailed report of every royalty risk in your contract alongside remediation guidance.
Related Posts
- ERC-721 Approval Security: setApprovalForAll and Operator Vulnerability
- NFT Mint Mechanics Security: Dutch Auction and Allowlist Vulnerabilities
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.