- Authors
What is ERC-721?
Non-Fungible Tokens (NFT)
NFT (Non-Fungible Token) is a token used to uniquely identify something or someone.
Why NFTs are needed:
- Collectibles: Digital art, trading cards, and other items with varying values
- Access Rights: Concert tickets, membership cards
- Game Items: Weapons, characters, skins with different stats
- Numbered Seats: Specific seats at venues or stadiums
Fungible vs Non-Fungible
ERC-721 tokens are non-fungible digital assets. Even tokens issued from the same contract have unique values and properties.
- ERC-20 (Fungible): Alice's 100 USDT = Bob's 100 USDT (completely identical)
- ERC-721 (Non-Fungible): Alice's NFT #1 ≠ Bob's NFT #2 (each is unique)
tokenId: The Unique Identifier
Every NFT has a tokenId of type uint256.
// Globally unique NFT identification
(contract address, tokenId) = unique NFT
For example:
- CryptoKitties: tokenId = cat #1 has rare genetic combinations
- Bored Ape: tokenId = ape #1 has unique appearance and attributes
- Game Items: tokenId = sword #1 has +10 attack, sword #2 has +5 attack
Even NFTs from the same contract can have different ages, rarities, stats, and visuals depending on their tokenId. This is why NFTs trade at different prices.
The Birth of ERC-721 Standard
ERC-721 (Ethereum Request for Comment 721) is an NFT standard proposed in 2018.
Why standards are needed:
- All NFTs work with the same interface
- Marketplaces like OpenSea can automatically support all ERC-721 NFTs
- Usable across various fields like games, art, real estate
- Wallets and dApps can handle NFTs without knowing the contract structure
Where are NFTs Stored?
NFT Ownership Storage
NFTs are not stored in wallets. The ERC-721 contract acts as a ledger that records ownership.
The contract uses mappings to record who owns which NFT:
// Inside ERC-721 contract
mapping(uint256 => address) private _owners;
mapping(address => uint256) private _balances;
// Example:
// _owners[1] = 0x1234... ← Alice owns NFT #1
// _owners[2] = 0x5678... ← Bob owns NFT #2
// _balances[alice] = 2 ← Alice owns 2 NFTs total
- tokenId → address: Records who owns each NFT ID
- address → uint256: Counts how many NFTs each address owns
Where is the Metadata?
NFT metadata (images, attributes, etc.) is stored off-chain.
Storing images directly on blockchain would be extremely expensive:
- Storing a 1MB image on Ethereum: Millions in gas fees
- Increased blockchain size → More burden on node operators
- Instead, only the image location is stored on-chain
Storage Comparison
| Item | On-chain Storage | Off-chain Storage |
|---|---|---|
| Ownership Info | ✅ Stored on blockchain | - |
| tokenURI (Metadata URL) | ✅ Stored on blockchain | - |
| Image Files | ❌ (Too expensive) | ✅ IPFS/Server |
| JSON Metadata | ❌ (Too expensive) | ✅ IPFS/Server |
The contract provides the location (URL) of the metadata JSON file through the tokenURI() function. Actual images and attribute data are stored in off-chain storage like IPFS.
ERC-721 Core Functions
1. View Functions
// Query NFT owner
// Returns the owner address of a specific token ID
function ownerOf(uint256 tokenId) public view returns (address)
// Returns the number of NFTs owned by a specific address
function balanceOf(address owner) public view returns (uint256)
// Returns the token's metadata URI
// Location of JSON file containing image, name, description
function tokenURI(uint256 tokenId) public view returns (string)
// Query approved address
// Check which address can transfer a specific NFT
function getApproved(uint256 tokenId) public view returns (address)
// Check if operator has permission to manage all of owner's NFTs
function isApprovedForAll(address owner, address operator) public view returns (bool)
2. Transaction Functions
// Transfer NFT from 'from' to 'to'
// The most basic transfer method
function transferFrom(address from, address to, uint256 tokenId) public
// Same as transferFrom but checks safety if 'to' is a contract
// Prevents NFTs from being locked in contracts
function safeTransferFrom(address from, address to, uint256 tokenId) public
// Approve 'approved' address to transfer a specific NFT
function approve(address approved, uint256 tokenId) public
// Approve/revoke operator to manage all of owner's NFTs
// Commonly used by marketplaces
function setApprovalForAll(address operator, bool approved) public
3. Events
// Emitted whenever an NFT is transferred
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)
// Emitted when NFT usage is approved
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId)
// Emitted when full approval is set/revoked
event ApprovalForAll(address indexed owner, address indexed operator, bool approved)
ERC-721 Contract Implementation Example
Here's how a real ERC-721 NFT contract is implemented:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SimpleNFT {
// NFT info
string public name = "My NFT";
string public symbol = "MNFT";
// Owner of each token ID
mapping(uint256 => address) private _owners;
// Number of NFTs owned by each address
mapping(address => uint256) private _balances;
// Approved address for each token ID
mapping(uint256 => address) private _tokenApprovals;
// Owner grants operator permission to manage all NFTs
mapping(address => mapping(address => bool)) private _operatorApprovals;
// 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);
// Mint NFT
function mint(address to, uint256 tokenId) public {
require(to != address(0), "Invalid mint target");
require(_owners[tokenId] == address(0), "Token already exists");
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(address(0), to, tokenId);
}
// Query owner
function ownerOf(uint256 tokenId) public view returns (address) {
address owner = _owners[tokenId];
require(owner != address(0), "Token does not exist");
return owner;
}
// Query balance
function balanceOf(address owner) public view returns (uint256) {
require(owner != address(0), "Invalid address");
return _balances[owner];
}
// Transfer NFT
function transferFrom(address from, address to, uint256 tokenId) public {
require(_isApprovedOrOwner(msg.sender, tokenId), "No transfer permission");
require(ownerOf(tokenId) == from, "Owner mismatch");
require(to != address(0), "Invalid recipient address");
// Clear approval
_approve(address(0), tokenId);
_balances[from] -= 1;
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(from, to, tokenId);
}
// Approve
function approve(address approved, uint256 tokenId) public {
address owner = ownerOf(tokenId);
require(msg.sender == owner, "Only owner can approve");
require(approved != owner, "Cannot approve to owner");
_approve(approved, tokenId);
}
// Approve all
function setApprovalForAll(address operator, bool approved) public {
require(operator != msg.sender, "Cannot approve to self");
_operatorApprovals[msg.sender][operator] = approved;
emit ApprovalForAll(msg.sender, operator, approved);
}
// Query approval
function getApproved(uint256 tokenId) public view returns (address) {
require(_owners[tokenId] != address(0), "Token does not exist");
return _tokenApprovals[tokenId];
}
// Query approval for all
function isApprovedForAll(address owner, address operator) public view returns (bool) {
return _operatorApprovals[owner][operator];
}
// Internal functions
function _approve(address approved, uint256 tokenId) private {
_tokenApprovals[tokenId] = approved;
emit Approval(ownerOf(tokenId), approved, tokenId);
}
function _isApprovedOrOwner(address spender, uint256 tokenId) private view returns (bool) {
address owner = ownerOf(tokenId);
return (spender == owner ||
getApproved(tokenId) == spender ||
isApprovedForAll(owner, spender));
}
}
Core Operating Principles
1. NFT Transfer (transferFrom)
How to transfer NFTs. What actually happens when Alice sends an NFT to Bob:
- Check if the caller has permission to transfer the NFT (owner or approved address)
- Update the
_ownersmapping value in the contract - Update balances
- Emit
Transferevent
Important: NFTs don't move directly from account to account. The contract updates the ownership record.
// Alice transfers NFT #5 to Bob
nft.transferFrom(aliceAddress, bobAddress, 5);
// What happens inside the contract:
require(_isApprovedOrOwner(msg.sender, 5), "No transfer permission");
_balances[alice] -= 1; // Alice: 3 → 2
_balances[bob] += 1; // Bob: 1 → 2
_owners[5] = bob; // Change owner of NFT #5 to Bob
emit Transfer(alice, bob, 5);
2. NFT Approval (approve & setApprovalForAll)
Why is approval needed?
To sell NFTs on marketplaces (OpenSea, Blur, etc.), the marketplace needs permission to transfer your NFTs on your behalf.
Trading process:
- Alice lists NFT for sale on OpenSea
- Alice approves OpenSea contract (approve or setApprovalForAll)
- Bob purchases NFT on OpenSea
- OpenSea contract transfers Alice's NFT to Bob (calls transferFrom)
- Sale proceeds are sent to Alice
Without approval, marketplaces cannot move NFTs, so approval is an essential step in NFT trading.
Single Approval (approve)
Approves only one specific NFT to be transferred by another address.
// Alice grants OpenSea permission to transfer only NFT #5
nft.approve(openSeaAddress, 5);
Full Approval (setApprovalForAll)
Approves an operator to manage all of the owner's NFTs.
// Alice grants OpenSea permission to transfer all her NFTs
nft.setApprovalForAll(openSeaAddress, true);
// Alice approves OpenSea to transfer NFT #5
nft.approve(openSeaAddress, 5);
// Inside contract:
_tokenApprovals[5] = openSea;
emit Approval(alice, openSea, 5);
// Or approve all NFTs
nft.setApprovalForAll(openSeaAddress, true);
// Inside contract:
_operatorApprovals[alice][openSea] = true;
emit ApprovalForAll(alice, openSea, true);
Using setApprovalForAll() allows the operator to take all your NFTs. Never approve untrusted contracts.
3. Metadata and tokenURI
The visual information of NFTs (images, names, attributes) is not stored directly on the blockchain. Instead, the tokenURI() function provides the location of the metadata file.
function tokenURI(uint256 tokenId) public view returns (string) {
return string(abi.encodePacked(baseURI, tokenId.toString(), ".json"));
}
// Example:
// tokenURI(1) => "https://api.example.com/metadata/1.json"
// tokenURI(2) => "https://api.example.com/metadata/2.json"
How it works:
- OpenSea wants to display info for NFT #1
- Calls contract's
tokenURI(1) - Returns
"https://api.example.com/metadata/1.json" - OpenSea sends HTTP request to that URL
- Receives JSON file and displays image and attributes on screen
Only the URL is stored on-chain, while actual images and metadata are stored on off-chain servers.
Creating ERC-721 NFTs with OpenZeppelin
When writing ERC-721 contracts, it's best to use the verified implementation from OpenZeppelin ERC-721.
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract MyNFT is ERC721 {
constructor() ERC721("MyNFT", "MNFT") {}
// Returns baseURI (used for tokenURI generation)
// tokenURI(1) => "https://api.example.com/metadata/1"
// tokenURI(2) => "https://api.example.com/metadata/2"
function _baseURI() internal view override returns (string memory) {
return "https://api.example.com/metadata/";
}
}