standards/erc-nft-standards

ERC NFT Standards Guide (ERC-721, ERC-1155, ERC-2981, ERC-4907)

ethereumstandard-guide🏛️ Officialconfidence highhealth 100%
v1.0.0·Updated 3/26/2026

Overview

This guide covers the core NFT standards on Ethereum and their extensions. Understanding when to use ERC-721 vs ERC-1155 and how royalties/rental/soulbound extensions work is critical for any NFT or gaming dApp.

Standards Comparison Table

FeatureERC-721ERC-1155ERC-2981ERC-4907ERC-5192ERC-6372
PurposeUnique NFTMulti-tokenRoyalty infoRentable NFTSoulboundContract clock
Token model1 token = 1 IDMultiple tokens per IDExtensionExtensionExtensionExtension
Batch supportLimitedNativeN/AN/AN/AN/A
Gas (batch mint)HighLowN/AN/AN/AN/A
Use caseArt, PFPsGaming items, SFTsMarketplace royaltiesRental marketsSBTs, credentialsGovernance timing

ERC-721 — Non-Fungible Token Standard

ERC-721 is the foundation of NFTs. Each token has a unique tokenId and a single owner.

interface IERC721 {
    // Core transfers
    function transferFrom(address from, address to, uint256 tokenId) external;
    function safeTransferFrom(address from, address to, uint256 tokenId) external;
    function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;

    // Approvals
    function approve(address to, uint256 tokenId) external;
    function setApprovalForAll(address operator, bool approved) external;
    function getApproved(uint256 tokenId) external view returns (address operator);
    function isApprovedForAll(address owner, address operator) external view returns (bool);

    // Queries
    function ownerOf(uint256 tokenId) external view returns (address owner);
    function balanceOf(address owner) external view returns (uint256 balance);

    // Events
    event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
    event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
    event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
}

ERC-721 Metadata Extension

interface IERC721Metadata is IERC721 {
    function name() external view returns (string memory);
    function symbol() external view returns (string memory);
    function tokenURI(uint256 tokenId) external view returns (string memory);
}

tokenURI best practices:

  • Return JSON conforming to OpenSea metadata standard
  • Use IPFS URIs for decentralization (ipfs://Qm...)
  • For on-chain SVG: encode as data:application/json;base64,...

Safe Transfer Receiver

interface IERC721Receiver {
    function onERC721Received(
        address operator,
        address from,
        uint256 tokenId,
        bytes calldata data
    ) external returns (bytes4); // Must return 0x150b7a02
}

Critical: Always use safeTransferFrom when sending to a contract. transferFrom to a non-receiver contract will lock the NFT permanently.

Common ERC-721 Implementation (OpenZeppelin)

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";

contract MyNFT is ERC721URIStorage {
    uint256 private _nextTokenId;

    constructor() ERC721("MyNFT", "MNFT") {}

    function mint(address to, string calldata uri) external returns (uint256) {
        uint256 tokenId = _nextTokenId++;
        _safeMint(to, tokenId);
        _setTokenURI(tokenId, uri);
        return tokenId;
    }
}

ERC-1155 — Multi-Token Standard

ERC-1155 handles both fungible and non-fungible tokens in one contract. Each id can have multiple copies (SFT — Semi-Fungible Token).

interface IERC1155 {
    // Single transfers
    function safeTransferFrom(
        address from, address to, uint256 id, uint256 amount, bytes calldata data
    ) external;

    // Batch transfers (gas efficient)
    function safeBatchTransferFrom(
        address from, address to,
        uint256[] calldata ids, uint256[] calldata amounts, bytes calldata data
    ) external;

    // Queries
    function balanceOf(address account, uint256 id) external view returns (uint256);
    function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids)
        external view returns (uint256[] memory);

    // Approvals (operator-level only, no per-token approval)
    function setApprovalForAll(address operator, bool approved) external;
    function isApprovedForAll(address account, address operator) external view returns (bool);
}

ERC-721 vs ERC-1155 Decision Guide

ScenarioUse
1-of-1 art, unique itemsERC-721
Limited edition prints (100 copies)ERC-1155
Game items (100 swords, 50 shields)ERC-1155
PFP collection (unique traits per token)ERC-721
Batch minting 10,000 NFTs cost-efficientlyERC-1155
Token needs per-token approvalERC-721
Mix of fungible + non-fungible in one contractERC-1155

Gas savings with ERC-1155: Batch transfer of 10 tokens costs ~30% less gas than 10 individual ERC-721 transfers.

ERC-2981 — NFT Royalty Standard

ERC-2981 provides a standardized way for marketplaces to query and honor royalty payments.

interface IERC2981 {
    function royaltyInfo(
        uint256 tokenId,
        uint256 salePrice
    ) external view returns (
        address receiver,
        uint256 royaltyAmount
    );
}

Implementation

import "@openzeppelin/contracts/token/common/ERC2981.sol";

contract MyNFT is ERC721, ERC2981 {
    constructor() ERC721("MyNFT", "MNFT") {
        // Set 5% royalty to deployer for all tokens
        _setDefaultRoyalty(msg.sender, 500); // 500 = 5% (basis points)
    }

    // Override for ERC-165
    function supportsInterface(bytes4 interfaceId)
        public view override(ERC721, ERC2981) returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

Important: ERC-2981 only standardizes reporting royalties. Enforcement depends on marketplace compliance. On-chain royalty enforcement requires custom transfer hooks or wrapper contracts.

ERC-4907 — Rental NFT Standard

ERC-4907 extends ERC-721 with a user role separate from owner, enabling time-limited usage rights (rental).

interface IERC4907 {
    event UpdateUser(uint256 indexed tokenId, address indexed user, uint64 expires);

    // Set a user and expiry (owner or approved can set)
    function setUser(uint256 tokenId, address user, uint64 expires) external;

    // Get current user (returns address(0) if expired)
    function userOf(uint256 tokenId) external view returns (address);

    // Get expiry timestamp
    function userExpires(uint256 tokenId) external view returns (uint256);
}

Rental Pattern

// Renting out an NFT for 7 days
uint64 expires = uint64(block.timestamp + 7 days);
IERC4907(nftContract).setUser(tokenId, renterAddress, expires);

// Check if currently rented
address currentUser = IERC4907(nftContract).userOf(tokenId);
bool isRented = currentUser != address(0);

Use case: In-game item rental, metaverse land access, subscription-based NFT utilities.

ERC-5192 — Soulbound Tokens (SBT)

ERC-5192 marks NFTs as "locked" — non-transferable. Used for credentials, attestations, and identity tokens.

interface IERC5192 {
    event Locked(uint256 tokenId);
    event Unlocked(uint256 tokenId);

    // Returns true if the token is locked (soulbound)
    function locked(uint256 tokenId) external view returns (bool);
}

SBT Implementation

contract SoulboundToken is ERC721, IERC5192 {
    mapping(uint256 => bool) private _locked;

    function _beforeTokenTransfer(
        address from, address to, uint256 tokenId, uint256 batchSize
    ) internal override {
        // Allow minting (from == address(0)) but block transfers
        if (from != address(0) && _locked[tokenId]) {
            revert("SBT: token is soulbound");
        }
        super._beforeTokenTransfer(from, to, tokenId, batchSize);
    }

    function locked(uint256 tokenId) external view returns (bool) {
        return _locked[tokenId];
    }
}

SBT use cases: University degrees, KYC credentials, DAO membership, achievement badges.

ERC-6372 — Contract Clock

ERC-6372 standardizes how contracts report their internal "clock" — block number or timestamp. Critical for governance systems where voting power snapshots need a consistent time reference.

interface IERC6372 {
    // Returns current clock value (block.number or block.timestamp)
    function clock() external view returns (uint48);

    // Returns "mode" string: "timestamp" or "blocknumber"
    function CLOCK_MODE() external view returns (string memory);
}

Why it matters: Governance tokens (like OpenZeppelin's ERC20Votes) need a consistent clock. Using block.number on L2 chains (where block times vary) can cause issues — use block.timestamp instead.

ERC-4906 — Metadata Update Event

ERC-4906 adds an event for signaling that NFT metadata has changed. Marketplaces listen for this to refresh cached metadata.

interface IERC4906 is IERC165, IERC721 {
    event MetadataUpdate(uint256 _tokenId);
    event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId);
}

Common NFT Pitfalls

  1. Lost NFTs: Sending ERC-721 with transferFrom (not safeTransferFrom) to a contract that doesn't implement onERC721Received permanently locks the token.
  2. Royalty bypass: ERC-2981 royalties are not enforced on-chain — most marketplaces can choose to ignore them.
  3. tokenId = 0: Many implementations have bugs with tokenId 0. Start tokenIds from 1 or handle 0 explicitly.
  4. Gas estimation for batch minting: ERC-721 batch mints using loops can hit block gas limits. Consider lazy minting or ERC-1155.
  5. Soulbound + approvals: ERC-5192 doesn't automatically block approve — you must also override approval functions.