ERC NFT Standards Guide (ERC-721, ERC-1155, ERC-2981, ERC-4907)
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
| Feature | ERC-721 | ERC-1155 | ERC-2981 | ERC-4907 | ERC-5192 | ERC-6372 |
|---|---|---|---|---|---|---|
| Purpose | Unique NFT | Multi-token | Royalty info | Rentable NFT | Soulbound | Contract clock |
| Token model | 1 token = 1 ID | Multiple tokens per ID | Extension | Extension | Extension | Extension |
| Batch support | Limited | Native | N/A | N/A | N/A | N/A |
| Gas (batch mint) | High | Low | N/A | N/A | N/A | N/A |
| Use case | Art, PFPs | Gaming items, SFTs | Marketplace royalties | Rental markets | SBTs, credentials | Governance 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
| Scenario | Use |
|---|---|
| 1-of-1 art, unique items | ERC-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-efficiently | ERC-1155 |
| Token needs per-token approval | ERC-721 |
| Mix of fungible + non-fungible in one contract | ERC-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
- Lost NFTs: Sending ERC-721 with
transferFrom(notsafeTransferFrom) to a contract that doesn't implementonERC721Receivedpermanently locks the token. - Royalty bypass: ERC-2981 royalties are not enforced on-chain — most marketplaces can choose to ignore them.
- tokenId = 0: Many implementations have bugs with tokenId 0. Start tokenIds from 1 or handle 0 explicitly.
- Gas estimation for batch minting: ERC-721 batch mints using loops can hit block gas limits. Consider lazy minting or ERC-1155.
- Soulbound + approvals: ERC-5192 doesn't automatically block
approve— you must also override approval functions.