How to design a scalable NFT marketplace smart contract with royalty support?

Summary

Royalty enforcement at the smart contract level is not natively supported by ERC standards. This postmortem analyzes the architectural pitfalls of implementing royalties in an NFT marketplace and provides a senior engineer’s perspective on robust contract design. The core takeaway is that royalty logic must be off-chain in the metadata or on-chain in the marketplace’s trade execution logic, rather than inside the NFT contract itself.

Root Cause

The primary root cause of confusion and failure in royalty implementations stems from misunderstanding the separation of concerns between NFT ownership and marketplace execution. ERC-721 and ERC-1155 define ownership and transfer functions but do not enforce payment logic during a transfer. When engineers attempt to bake royalty enforcement directly into the NFT’s transfer function, they break composability and violate gas efficiency requirements.

Specific failures observed include:

  • Tight Coupling: Embedding marketplace logic into NFT contracts, preventing the NFTs from being sold on other platforms.
  • Proxy Storage Collisions: Improper use of upgradeable proxies leading to immutable state variables being overwritten during upgrades.
  • Reentrancy Vulnerabilities: Failing to implement checks-effects-interactions patterns when handling multiple asset transfers (e.g., paying royalties to multiple parties).

Why This Happens in Real Systems

This happens because developers often prioritize “enforceability” over “compatibility.” In a decentralized ecosystem, an NFT contract acts as a permissionless primitive. If a developer adds a modifier to safeTransferFrom that requires a royalty payment to a specific address, the token becomes non-standard and unusable on aggregators like OpenSea or Blur.

Real-world systems prioritize composability. The Ethereum ecosystem relies on the assumption that a transfer function only moves ownership. Marketplaces operate as separate entities that agree to pay royalties voluntarily to maintain reputation or access specific pools of liquidity.

Real-World Impact

  • Reduced Liquidity: NFTs with restrictive transfer functions cannot be listed on major secondary markets, severely impacting value.
  • Broken Upgradability: If storage layouts are not managed correctly during a proxy upgrade (e.g., changing an implementation contract that introduces new state variables before old ones), the contract’s state becomes irreversibly corrupted.
  • Excessive Gas Costs: On-chain royalty enforcement loops (e.g., iterating through a list of royalty payees) can make minting or transferring prohibitively expensive, discouraging user adoption.

Example or Code

The recommended architectural pattern uses a Marketplace Contract to handle royalty logic during the execution of a sale, leaving the NFT contract standard-compliant.

1. The NFT Contract (Standard & Upgradeable)

This contract should focus solely on ownership. It follows the UUPS pattern to allow future logic updates without changing the storage layout.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts-upgradeable/token/ERC721/ERC721Upgradeable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract ScalableNFT is Initializable, ERC721Upgradeable, UUPSUpgradeable, OwnableUpgradeable {

    // Storage layout must be preserved for proxies
    uint256 private _nextTokenId;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }

    function initialize() public initializer {
        __ERC721_init("ScalableNFT", "SCAL");
        __Ownable_init(msg.sender);
        __UUPSUpgradeable_init();
    }

    function mint(address to) public onlyOwner returns (uint256) {
        uint256 tokenId = _nextTokenId++;
        _safeMint(to, tokenId);
        return tokenId;
    }

    // Mandatory override for UUPS
    function _authorizeUpgrade(address newImplementation) internal override onlyOwner {}
}

2. The Marketplace Contract (Royalty Logic)

This contract is responsible for executing trades and distributing royalties. It calculates the fee dynamically during the sale.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import "@openzeppelin/contracts/token/ERC1155/IERC1155.sol";

interface IRoyaltyPayer {
    function payRoyalty(address recipient, uint256 amount) external payable;
}

contract NFTMarketplace {
    uint256 public constant FEE_BASIS_POINTS = 250; // 2.5%

    event ItemSold(address indexed seller, address indexed buyer, address indexed nftAddress, uint256 tokenId, uint256 price);

    // Logic for ERC721
    function executeTrade721(
        address nftAddress, 
        uint256 tokenId, 
        address seller, 
        address buyer, 
        uint256 price
    ) external payable {
        require(msg.value == price, "Incorrect ETH sent");

        uint256 royaltyAmount = (price * 500) / 10000; // 5% Royalty
        uint256 marketplaceFee = (price * FEE_BASIS_POINTS) / 10000;
        uint256 sellerProceeds = price - royaltyAmount - marketplaceFee;

        // 1. Transfer NFT from seller to buyer
        IERC721(nftAddress).safeTransferFrom(seller, buyer, tokenId);

        // 2. Pay Royalty (Off-chain defined or on-chain registry)
        // Assuming royalty recipient is the original minter (or fetched from a registry)
        address royaltyRecipient = getRoyaltyRecipient(nftAddress, tokenId); 
        payable(royaltyRecipient).transfer(royaltyAmount);

        // 3. Pay Marketplace Fee
        payable(owner()).transfer(marketplaceFee);

        // 4. Pay Seller
        payable(seller).transfer(sellerProceeds);

        emit ItemSold(seller, buyer, nftAddress, tokenId, price);
    }

    // Helper to simulate fetching royalty info (EIP-2981 implementation usually goes here)
    function getRoyaltyRecipient(address nftAddress, uint256 tokenId) internal view returns (address) {
        // In a real system, this would call IERC2981(nftAddress).royaltyInfo(tokenId, price)
        // For this example, we assume the NFT contract has a method to retrieve the creator.
        // We return a default for safety here.
        return address(0xDead); 
    }
}

How Senior Engineers Fix It

Senior engineers approach this problem by strictly separating the token standard from the business logic.

  1. Implement EIP-2981 (Royalty Standard):
    Instead of hardcoding royalty logic into the marketplace, the NFT contract should implement the IERC2981 interface. This returns the royalty recipient and percentage.

    • Benefit: Any marketplace can query the NFT contract itself to determine where to send royalties, ensuring consistency across platforms.
  2. Use Non-Upgradeable Proxies or Careful Storage Management:
    While UUPS proxies are gas-efficient, seniors often prefer Immutable Proxies for core NFT logic once deployed, or they use a Storage Gap pattern (uint256[50] __gap) to reserve storage slots for future upgrades. This prevents the “Storage Collision” issue where new variables in the upgraded implementation overwrite critical ownership data.

  3. Pausable & Emergency Controls:
    Implement PausableUpgradeable to stop transfers if a vulnerability is found, but ensure these controls are time-locked or multi-sig governed to maintain decentralization.

  4. Gas Optimization:
    Avoid loops in royalty distribution. If multiple royalties are required (e.g., to multiple creators), require the user to specify the recipients upfront or use a pull-payment pattern rather than iterating.

Why Juniors Miss It

  • Focus on Enforcement over Standards: Juniors often believe “if it’s not enforced in code, it’s not real.” They struggle to understand the social consensus layer of EIP standards (EIP-2981) and try to force on-chain execution.
  • Underestimating Proxy Complexity: The mechanics of delegatecall and storage layout are advanced. Juniors often treat upgradeable contracts like regular contracts, failing to initialize correctly or retaining uninitialized implementations.
  • Ignoring Composability: They fail to realize that an NFT contract is a primitive building block. By adding custom logic to the transfer function, they inadvertently make the token incompatible with the broader ecosystem (wallets, aggregators).