EIP-2981 Royalty Vulnerability Patterns
EIP-2981 defines a standard way to retrieve royalty payment information for NFTs, enabling creators to receive ongoing royalties from secondary sales.
This article summarizes 3 EIP-2981 vulnerability patterns with real audit cases to help you understand the risks of improper royalty implementation.
EIP-2981 Overview
┌─────────────────────────────────────────────────────────────────────────┐
│ WHAT IS EIP-2981? │
└─────────────────────────────────────────────────────────────────────────┘
EIP-2981 is the NFT Royalty Standard:
→ Defines how to query royalty info for any NFT
→ Returns (receiver, royaltyAmount) for a given tokenId and salePrice
→ Enables creators to earn from secondary market sales
→ Adopted by major marketplaces (OpenSea, Blur, etc.)
┌─────────────────────────────────────────────────────────────────────────┐
│ THE INTERFACE │
└─────────────────────────────────────────────────────────────────────────┘
interface IERC2981 {
/// @notice Called by marketplaces to get royalty info
/// @param tokenId - The NFT being sold
/// @param salePrice - The sale price
/// @return receiver - Who gets the royalty
/// @return royaltyAmount - How much they get
function royaltyInfo(uint256 tokenId, uint256 salePrice)
external view returns (address receiver, uint256 royaltyAmount);
}
┌─────────────────────────────────────────────────────────────────────────┐
│ HOW ROYALTIES WORK │
└─────────────────────────────────────────────────────────────────────────┘
Secondary Sale Flow:
┌──────────────┐ Sells NFT #123 ┌──────────────┐
│ Seller │ ───────────────────────►│ Marketplace │
│ │ for 10 ETH │ (OpenSea) │
└──────────────┘ └──────┬───────┘
│
1. Query royaltyInfo(123, 10 ETH)
│
▼
┌──────────────┐
│ NFT Contract│
│ │
│ Returns: │
│ receiver= │
│ 0xArtist │
│ amount= │
│ 0.5 ETH │
└──────────────┘
│
2. Distribute funds │
▼
┌───────────────────────────────────────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Artist │ │ Seller │
│ 0.5 ETH (5%)│ │ 9.5 ETH │
└──────────────┘ └──────────────┘
The flow explained:
1. Seller lists NFT #123 on a marketplace (e.g., OpenSea) for 10 ETH
2. When a buyer purchases, the marketplace calls royaltyInfo(123, 10 ETH)
on the NFT contract to ask: "Who gets royalties and how much?"
3. The contract returns: receiver = 0xArtist (the creator), amount = 0.5 ETH (5%)
4. The marketplace distributes the 10 ETH sale price:
→ 0.5 ETH (5%) goes to the original artist as royalty
→ 9.5 ETH goes to the seller
IMPORTANT: EIP-2981 is just a QUERY interface. The marketplace chooses
whether to honor the royalty. The contract cannot force payment on-chain.
This is why accurate royaltyInfo() data is critical - if it returns wrong
data, the marketplace will pay the wrong person or amount.
┌─────────────────────────────────────────────────────────────────────────┐
│ IMPLEMENTATION OPTIONS │
└─────────────────────────────────────────────────────────────────────────┘
OpenZeppelin provides two approaches:
1. Default Royalty (for all tokens):
┌────────────────────────────────────────────────────────────────────────┐
│ _setDefaultRoyalty(address receiver, uint96 feeNumerator); │
│ │
│ → Sets same royalty for ALL tokens │
│ → Simple but inflexible │
│ → feeNumerator: 500 = 5% (denominator is 10000) │
└────────────────────────────────────────────────────────────────────────┘
2. Per-Token Royalty (for specific tokens):
┌────────────────────────────────────────────────────────────────────────┐
│ _setTokenRoyalty(uint256 tokenId, address receiver, uint96 fee); │
│ │
│ → Sets royalty for SPECIFIC token │
│ → Overrides default royalty │
│ → Required for multi-creator collections │
└────────────────────────────────────────────────────────────────────────┘
Key Insight:
→ royaltyInfo() is READ-ONLY - it just returns data
→ Marketplaces CHOOSE whether to honor it (not enforced on-chain)
→ Contract must maintain correct state for accurate queries
→ Stale or incorrect data = wrong royalty payments
Vulnerability Patterns
1. Default Royalty Misuse
Prerequisite Knowledge:
EIP-2981 requires that royaltyInfo(uint256 _tokenId, uint256 _salePrice) returns royalty information specific to each _tokenId. For multi-creator collections (like NFT bridges or collaborative platforms), each token may have a different original creator who should receive royalties. The _setTokenRoyalty() function enables per-token royalty configuration, while _setDefaultRoyalty() sets a uniform royalty for all tokens.
Root Cause:
The contract uses _setDefaultRoyalty(address(this), _royaltyBps) during initialization, setting a single, uniform royalty recipient (the contract itself) for all tokens. This ignores the original creators of individual NFTs and violates the per-token royalty attribution that EIP-2981 is designed to support.
Attack Vector:
When a marketplace calls royaltyInfo(tokenId, salePrice), it receives the contract address as the recipient instead of the original artist. Royalties are paid to the contract rather than the creators, making it impossible to correctly distribute funds to the rightful owners.
Source:
- [sherlock/2024-08-flayer]
ERC721BridgableandERC1155Bridgableare not EIP-2981 compliant (issue-214.md)moongate/src/libs/ERC721Bridgable.solmoongate/src/libs/ERC1155Bridgable.solBusiness Context: Cross-Chain NFT Bridge
┌─────────────────────────────────────────────────────────────────────────┐ │ WHAT IS MOONGATE? │ └─────────────────────────────────────────────────────────────────────────┘ Moongate is a cross-chain NFT bridge: → Bridges NFTs between different blockchains → Creates "bridged" versions of NFTs on destination chain → Should preserve original creator royalties ┌─────────────────────────────────────────────────────────────────────────┐ │ THE PROBLEM │ └─────────────────────────────────────────────────────────────────────────┘ Original Chain: ┌──────────────────────────────────────────────────────────────────────┐ │ NFT Collection with multiple artists: │ │ │ │ Token #1: Artist A should get 5% royalty │ │ Token #2: Artist B should get 7% royalty │ │ Token #3: Artist C should get 3% royalty │ └──────────────────────────────────────────────────────────────────────┘ │ │ Bridge to L2 ▼ Destination Chain (Bridged Contract): ┌──────────────────────────────────────────────────────────────────────┐ │ _setDefaultRoyalty(address(this), _royaltyBps); │ │ │ │ Token #1: Contract gets X% ← Wrong! Should be Artist A │ │ Token #2: Contract gets X% ← Wrong! Should be Artist B │ │ Token #3: Contract gets X% ← Wrong! Should be Artist C │ └──────────────────────────────────────────────────────────────────────┘ Result: → All royalties go to bridge contract → Artists receive nothing from secondary sales → No mechanism to distribute to original creators ┌─────────────────────────────────────────────────────────────────────────┐ │ FIX │ └─────────────────────────────────────────────────────────────────────────┘ Use per-token royalties: ┌────────────────────────────────────────────────────────────────────────┐ │ function bridgeNFT(uint256 tokenId, address originalCreator, ...) { │ │ // ... mint bridged token ... │ │ │ │ // Set royalty for THIS specific token to original creator │ │ _setTokenRoyalty(tokenId, originalCreator, royaltyBps); │ │ } │ └────────────────────────────────────────────────────────────────────────┘Bridging Flow (L1 → L2):
┌─────────────────────────────────────────────────────────────────────────┐ │ HOW MOONGATE BRIDGES NFTs │ └─────────────────────────────────────────────────────────────────────────┘ Step 1: L1 - User initiates bridge ┌────────────────────────────────────────────────────────────────────────┐ │ User calls: InfernalRiftAbove.crossTheThreshold(params) │ │ │ │ → Transfers NFTs to bridge contract (escrow) │ │ → Collects tokenURIs for each NFT │ │ → Gets royalty from FIRST token only (!) │ │ → Sends package to L2 via Optimism Portal │ └────────────────────────────────────────────────────────────────────────┘ │ │ Optimism Portal (cross-chain message) ▼ Step 2: L2 - Bridge receives package ┌────────────────────────────────────────────────────────────────────────┐ │ InfernalRiftBelow.thresholdCross(packages, recipient) │ │ │ │ → If collection not deployed: Clone ERC721Bridgable template │ │ → Call initialize() with royaltyBps from package │ │ → Mint bridged NFTs to recipient │ └────────────────────────────────────────────────────────────────────────┘ │ ▼ Step 3: L2 - Collection initialization ┌────────────────────────────────────────────────────────────────────────┐ │ ERC721Bridgable.initialize(name, symbol, royaltyBps, chainId, ...) │ │ │ │ → Sets name and symbol │ │ → Calls _setDefaultRoyalty(address(this), royaltyBps) │ │ ↑ VULNERABILITY: All tokens get same royalty, contract receives it │ └────────────────────────────────────────────────────────────────────────┘Vulnerable Code (L1 - Gets royalty from first token only):
// InfernalRiftAbove.sol function crossTheThreshold(ThresholdCrossParams memory params) external payable { // ... for (uint i; i < numCollections; ++i) { package[i] = Package({ // ... // VULNERABILITY: Only gets royalty from FIRST token in the batch royaltyBps: _getCollectionRoyalty(collectionAddress, params.idsToCross[i][0]), // ... }); } // Send to L2 via Optimism Portal PORTAL.depositTransaction{value: msg.value}( INFERNAL_RIFT_BELOW, 0, params.gasLimit, false, abi.encodeCall(InfernalRiftBelow.thresholdCross, (package, params.recipient)) ); }Vulnerable Code (L2 - Sets uniform royalty for all tokens):
// ERC721Bridgable.sol function initialize( string memory _name, string memory _symbol, uint96 _royaltyBps, // Same royalty for ALL tokens uint256 _REMOTE_CHAIN_ID, address _REMOTE_TOKEN ) external { if (msg.sender != INFERNAL_RIFT_BELOW) revert NotRiftBelow(); if (initialized) revert AlreadyInitialized(); name = _name; symbol = _symbol; REMOTE_CHAIN_ID = _REMOTE_CHAIN_ID; REMOTE_TOKEN = _REMOTE_TOKEN; // VULNERABILITY: Sets contract itself as royalty recipient for ALL tokens _setDefaultRoyalty(address(this), _royaltyBps); initialized = true; }
2. Royalty State Desynchronization
Prerequisite Knowledge:
ERC-2981 specifies that royalty information must be stored and retrieved through the royaltyInfo() function. Contracts implementing ERC-2981 should use _setTokenRoyalty() to update royalty parameters. When a contract maintains custom storage for royalty data alongside ERC-2981 state, both must be kept in sync.
Root Cause:
The contract maintains two separate sources of truth for royalty information: the internal ERC-2981 state (accessed via royaltyInfo()) and custom storage (works[tokenId].strategy.royaltyBps). When setFeeStrategy() or publish() updates the custom storage, it fails to call _setTokenRoyalty(), leaving the ERC-2981 state stale.
Attack Vector:
A creator calls setFeeStrategy() to update their royalty rate from 5% to 10%. The new rate is stored in works[tokenId].strategy.royaltyBps, but royaltyInfo() continues to return 5%. Marketplaces querying the standard interface pay incorrect royalties, causing financial loss to creators.
Source:
- [sherlock/2024-04-titles] ERC2981 royalties discrepancy with strategy (
issue-144.md)wallflower-contract-v2/src/editions/Edition.solBusiness Context: Publishing Platform with Dynamic Fees
┌─────────────────────────────────────────────────────────────────────────┐ │ WHAT IS TITLES? │ └─────────────────────────────────────────────────────────────────────────┘ Titles is a publishing platform: → Creators publish "works" (editions) as NFTs → Each work has a fee strategy (including royalties) → Creators can update their fee strategy over time ┌─────────────────────────────────────────────────────────────────────────┐ │ DUAL STATE PROBLEM │ └─────────────────────────────────────────────────────────────────────────┘ The contract has TWO places storing royalty data: Source 1: Custom Storage ┌────────────────────────────────────────────────────────────────────────┐ │ works[tokenId].strategy.royaltyBps = 1000 // 10% │ │ │ │ Updated by: setFeeStrategy(), publish() │ │ Used by: Internal fee calculations │ └────────────────────────────────────────────────────────────────────────┘ Source 2: ERC-2981 State ┌────────────────────────────────────────────────────────────────────────┐ │ _tokenRoyaltyInfo[tokenId] = (receiver, 500) // 5% (old value!) │ │ │ │ Updated by: _setTokenRoyalty() - BUT NEVER CALLED! │ │ Used by: royaltyInfo() - queried by marketplaces │ └────────────────────────────────────────────────────────────────────────┘ ┌─────────────────────────────────────────────────────────────────────────┐ │ THE DESYNC │ └─────────────────────────────────────────────────────────────────────────┘ Timeline: 1. Creator publishes work with 5% royalty → works[1].strategy.royaltyBps = 500 → _tokenRoyaltyInfo[1] = 500 ✓ (in sync) 2. Creator calls setFeeStrategy() to change to 10% → works[1].strategy.royaltyBps = 1000 ✓ → _tokenRoyaltyInfo[1] = 500 ✗ (still old value!) 3. Marketplace sells NFT for 10 ETH → Calls royaltyInfo(1, 10 ETH) → Returns 0.5 ETH (5%) instead of 1 ETH (10%) → Creator loses 0.5 ETH per sale! ┌─────────────────────────────────────────────────────────────────────────┐ │ FIX │ └─────────────────────────────────────────────────────────────────────────┘ Sync both states: ┌────────────────────────────────────────────────────────────────────────┐ │ function setFeeStrategy(uint256 tokenId_, Strategy calldata strat) { │ │ works[tokenId_].strategy = FEE_MANAGER.validateStrategy(strat); │ │ │ │ // MUST also update ERC-2981 state! │ │ _setTokenRoyalty( │ │ tokenId_, │ │ works[tokenId_].creator, │ │ strat.royaltyBps │ │ ); │ │ } │ └────────────────────────────────────────────────────────────────────────┘Vulnerable Code:
function setFeeStrategy(uint256 tokenId_, Strategy calldata strategy_) external { if (msg.sender != works[tokenId_].creator) revert Unauthorized(); works[tokenId_].strategy = FEE_MANAGER.validateStrategy(strategy_); // VULNERABILITY: Does not update ERC2981 royalty info // Missing: _setTokenRoyalty(tokenId_, msg.sender, strategy_.royaltyBps); } function publish(..., Strategy calldata strategy_, ...) external ... { tokenId = ++totalWorks; works[tokenId] = Work({ creator: creator_, // ... strategy: FEE_MANAGER.validateStrategy(strategy_) // VULNERABILITY: Does not set ERC2981 royalty info }); // Missing: _setTokenRoyalty(tokenId, creator_, strategy_.royaltyBps); }
3. Stale Royalty Configuration (Factory Pattern Issue)
Note: This is a general Factory Pattern address caching problem, not specific to EIP-2981. It happens to affect
royaltyInfo()because the cached address is used as the royalty recipient. The same pattern applies to any address that needs to stay in sync with a parent contract.
Prerequisite Knowledge:
In the Factory pattern, a parent contract deploys multiple child contracts. If child contracts cache addresses from the parent at initialization time, they won’t receive updates when the parent’s address changes. This affects any cached address, including EIP-2981’s royalty recipient.
Root Cause:
The contract caches the protocolFeeDestination address during initialization and stores it in contract storage. When the parent factory updates its canonical protocolFeeDestination, already-deployed child contracts continue using the old, stale address. There’s no mechanism to propagate address updates to existing contracts.
Attack Vector:
If the old protocolFeeDestination address is compromised, an attacker can permanently siphon all future royalties from every contract instance created before the address was updated. Alternatively, if the protocol intentionally changes the address and the old address becomes inaccessible, all royalties from existing contracts are permanently lost.
Source:
- [code4rena/2024-08-phi] PhiNFT1155 contracts continue sending fees/royalties to old protocol destination address (
finding-013.md)src/art/PhiNFT1155.solsrc/abstract/CreatorRoyaltiesControl.solBusiness Context: Factory Pattern with Protocol Fees
┌─────────────────────────────────────────────────────────────────────────┐ │ WHAT IS PHI? │ └─────────────────────────────────────────────────────────────────────────┘ Phi is an NFT platform using the factory pattern: → PhiFactory deploys many PhiNFT1155 contracts → Each PhiNFT1155 sends protocol fees to a destination address → Protocol may need to change destination (migration, security, etc.) ┌─────────────────────────────────────────────────────────────────────────┐ │ THE ARCHITECTURE │ └─────────────────────────────────────────────────────────────────────────┘ ┌──────────────────┐ │ PhiFactory │ │ │ │ protocolFee │──────► Can be updated by admin │ Destination = │ │ 0xTreasury_v1 │ └────────┬─────────┘ │ │ Deploys ▼ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ PhiNFT1155 #1 │ │ PhiNFT1155 #2 │ │ PhiNFT1155 #3 │ │ │ │ │ │ │ │ protocolFee │ │ protocolFee │ │ protocolFee │ │ Destination = │ │ Destination = │ │ Destination = │ │ 0xTreasury_v1 │ │ 0xTreasury_v1 │ │ 0xTreasury_v1 │ │ (CACHED!) │ │ (CACHED!) │ │ (CACHED!) │ └──────────────────┘ └──────────────────┘ └──────────────────┘ ┌─────────────────────────────────────────────────────────────────────────┐ │ THE STALE ADDRESS PROBLEM │ └─────────────────────────────────────────────────────────────────────────┘ Day 1: Deploy with Treasury_v1 ┌────────────────────────────────────────────────────────────────────────┐ │ Factory.protocolFeeDestination = 0xTreasury_v1 │ │ NFT#1.protocolFeeDestination = 0xTreasury_v1 (cached at init) │ │ NFT#2.protocolFeeDestination = 0xTreasury_v1 (cached at init) │ └────────────────────────────────────────────────────────────────────────┘ Day 30: Migrate to Treasury_v2 ┌────────────────────────────────────────────────────────────────────────┐ │ Factory.protocolFeeDestination = 0xTreasury_v2 ← Updated! │ │ NFT#1.protocolFeeDestination = 0xTreasury_v1 ← Still old! │ │ NFT#2.protocolFeeDestination = 0xTreasury_v1 ← Still old! │ │ │ │ New deployments: │ │ NFT#3.protocolFeeDestination = 0xTreasury_v2 ← Correct │ └────────────────────────────────────────────────────────────────────────┘ Result: → NFT#1 and NFT#2 forever send fees to Treasury_v1 → If Treasury_v1 is compromised → funds stolen → If Treasury_v1 is deprecated → funds lost ┌─────────────────────────────────────────────────────────────────────────┐ │ FIX │ └─────────────────────────────────────────────────────────────────────────┘ Option 1: Dynamic Resolution (read from factory each time) ┌────────────────────────────────────────────────────────────────────────┐ │ function getProtocolFeeDestination() internal view returns (address) {│ │ return phiFactory.protocolFeeDestination(); // Always current │ │ } │ └────────────────────────────────────────────────────────────────────────┘ Option 2: Update Function ┌────────────────────────────────────────────────────────────────────────┐ │ function updateProtocolFeeDestination() external { │ │ protocolFeeDestination = phiFactory.protocolFeeDestination(); │ │ } │ └────────────────────────────────────────────────────────────────────────┘Vulnerable Code:
function initialize(..., address protocolFeeDestination_) external initializer { // ... initializeRoyalties(protocolFeeDestination_); // VULNERABILITY: Storing static address instead of reading dynamically protocolFeeDestination = phiFactoryContract.protocolFeeDestination(); } function createArtFromFactory(uint256 artId_) external payable ... { uint256 artFee = phiFactoryContract.artCreateFee(); // VULNERABILITY: Using stored static address // Should be: phiFactoryContract.protocolFeeDestination().safeTransferETH(artFee); protocolFeeDestination.safeTransferETH(artFee); } // In CreatorRoyaltiesControl.sol function initializeRoyalties(address _royaltyRecipient) internal { // VULNERABILITY: Storing static royalty recipient address royaltyRecipient = _royaltyRecipient; initilaized = true; } function getRoyalties(uint256 tokenId) public view returns (RoyaltyConfiguration memory) { // ... // VULNERABILITY: Returns stored static address, never updated return RoyaltyConfiguration({ royaltyBPS: 500, royaltyRecipient: royaltyRecipient }); }
EIP-2981 Audit Checklist
When auditing contracts that implement EIP-2981 royalties, check for these issues:
| # | Check Item | Related Pattern |
|---|---|---|
| ✅ | Uses _setDefaultRoyalty() for multi-creator collection? Each token may have different creators with different royalty rates |
Pattern 1 |
| ✅ | Maintains separate royalty storage from ERC-2981? Custom storage and ERC-2981 state must stay synchronized | Pattern 2 |
| ✅ | Updates custom storage without calling _setTokenRoyalty()? Marketplaces query royaltyInfo(), not custom storage |
Pattern 2 |
| ✅ | Caches addresses from parent contract at initialization? Child contracts won’t receive parent’s address updates | Pattern 3 |
| ✅ | Reads royalty recipient dynamically from factory? Static caching causes stale address issues | Pattern 3 |
InfiniteSec