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.
// 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.
// 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.
ERC-721, tokenURI, mint, ownerOf