ERC-721 NFTs

Difficulty: Intermediate

ERC-721, defined in EIP-721, is the standard for non-fungible tokens (NFTs) on Ethereum. Unlike ERC-20 tokens where every token is identical and interchangeable, each ERC-721 token has a unique `tokenId` that distinguishes it from every other token in the collection. This uniqueness makes ERC-721 ideal for representing ownership of distinct digital or physical assets: artwork, collectibles, domain names, real estate deeds, gaming items, and membership passes.

The core of ERC-721 revolves around ownership tracking. The `ownerOf(tokenId)` function returns the current owner of a specific token. The `balanceOf(address)` function returns how many tokens an address owns (but not which ones). Transfers are handled by `transferFrom(from, to, tokenId)` and `safeTransferFrom(from, to, tokenId)`. The `safe` variant checks whether the recipient is a contract and, if so, calls `onERC721Received` on the recipient to confirm it can handle NFTs. This prevents tokens from being permanently locked in contracts that are not designed to hold them.

The approval system in ERC-721 works differently from ERC-20. Instead of approving an amount, you approve a specific operator for a specific tokenId using `approve(to, tokenId)`. For bulk authorization, `setApprovalForAll(operator, approved)` grants or revokes an operator's permission to manage all of the caller's tokens. This is how NFT marketplaces work: users call `setApprovalForAll` for the marketplace contract, which can then transfer any of the user's NFTs when a sale occurs.

Metadata is managed through the optional ERC-721 Metadata extension, which adds `name()`, `symbol()`, and `tokenURI(tokenId)`. The `tokenURI` function returns a URI (typically an HTTPS or IPFS URL) pointing to a JSON file containing the token's name, description, image, and attributes. This metadata standard enables wallets and marketplaces to display NFTs with their associated artwork and properties. The JSON format follows a conventional schema with fields like `name`, `description`, `image`, and `attributes` (an array of trait objects).

When implementing NFTs, consider whether your token supply is fixed or open-ended, whether metadata is stored on-chain or off-chain, and how royalties should be handled. EIP-2981 (NFT Royalty Standard) provides a standardized way to signal royalty information, though enforcement ultimately depends on marketplace cooperation. On-chain metadata (storing SVGs or data directly in the contract) makes NFTs fully decentralized but costs significantly more gas.

Code examples

Basic ERC-721 NFT Contract

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

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

contract ArtCollection is ERC721, ERC721URIStorage, ERC721Enumerable, Ownable {
    uint256 private _nextTokenId;
    uint256 public constant MAX_SUPPLY = 10_000;
    uint256 public mintPrice = 0.05 ether;
    string private _baseTokenURI;
    bool public mintingActive;

    event Minted(address indexed to, uint256 indexed tokenId);

    constructor(
        string memory baseURI,
        address initialOwner
    )
        ERC721("Art Collection", "ART")
        Ownable(initialOwner)
    {
        _baseTokenURI = baseURI;
    }

    function mint(uint256 quantity) external payable {
        require(mintingActive, "Minting not active");
        require(quantity > 0 && quantity <= 10, "1-10 per tx");
        require(_nextTokenId + quantity <= MAX_SUPPLY, "Exceeds supply");
        require(msg.value >= mintPrice * quantity, "Insufficient payment");

        for (uint256 i = 0; i < quantity; i++) {
            uint256 tokenId = _nextTokenId++;
            _safeMint(msg.sender, tokenId);
            emit Minted(msg.sender, tokenId);
        }
    }

    function setMintingActive(bool active) external onlyOwner {
        mintingActive = active;
    }

    function setMintPrice(uint256 price) external onlyOwner {
        mintPrice = price;
    }

    function setBaseURI(string memory baseURI) external onlyOwner {
        _baseTokenURI = baseURI;
    }

    function withdraw() external onlyOwner {
        payable(owner()).transfer(address(this).balance);
    }

    // Required overrides for multiple inheritance
    function _baseURI() internal view override returns (string memory) {
        return _baseTokenURI;
    }

    function tokenURI(uint256 tokenId)
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }

    function _update(address to, uint256 tokenId, address auth)
        internal
        override(ERC721, ERC721Enumerable)
        returns (address)
    {
        return super._update(to, tokenId, auth);
    }

    function _increaseBalance(address account, uint128 value)
        internal
        override(ERC721, ERC721Enumerable)
    {
        super._increaseBalance(account, value);
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721, ERC721URIStorage, ERC721Enumerable)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

A production-ready NFT contract using OpenZeppelin. It includes minting with supply cap, configurable pricing, pausable minting, base URI management for metadata, and Enumerable extension for on-chain token enumeration.

NFT Metadata JSON Example

// This JSON would be returned by the tokenURI(tokenId) endpoint
// For example: https://api.example.com/metadata/42
// Or IPFS: ipfs://QmHash.../42

/*
{
  "name": "Cosmic Ape #42",
  "description": "A unique cosmic ape from the Art Collection.",
  "image": "ipfs://QmImageHash.../42.png",
  "external_url": "https://artcollection.io/token/42",
  "attributes": [
    {
      "trait_type": "Background",
      "value": "Nebula"
    },
    {
      "trait_type": "Fur",
      "value": "Golden"
    },
    {
      "trait_type": "Eyes",
      "value": "Laser"
    },
    {
      "trait_type": "Rarity Score",
      "display_type": "number",
      "value": 87
    },
    {
      "trait_type": "Generation",
      "display_type": "number",
      "value": 1
    }
  ]
}
*/

NFT metadata follows a conventional JSON schema. The 'attributes' array defines traits that marketplaces like OpenSea display as filterable properties. Images are typically stored on IPFS for decentralization.

Key points

Concepts covered

ERC-721, tokenURI, mint, ownerOf