A technical deep dive into Non-Fungible Tokens, the ERC-721 standard, and how NFTs enable verifiable digital ownership. Learn the implementation details, metadata standards, and architectural patterns behind the NFT ecosystem.
While ERC-20 revolutionized fungible tokens on Ethereum, NFTs (Non-Fungible Tokens) solved an entirely different problem: how to represent unique, one-of-a-kind digital assets on a blockchain.
In this article, I'll break down the technical architecture behind NFTs, explain the ERC-721 standard, and explore the engineering challenges of building NFT systems at scale.
Traditional ERC-20 tokens are fungible: every token is identical and interchangeable. This works perfectly for currencies, but fails for assets that need unique properties:
We needed a standard that could represent uniqueness while maintaining the composability that made ERC-20 successful. Enter ERC-721.
ERC-721 introduces the concept of a tokenId, a unique identifier within a contract. While ERC-20 tracks balances, ERC-721 tracks ownership of individual tokens.
interface IERC721 {
// Returns the owner of a specific token
function ownerOf(uint256 tokenId) external view returns (address);
// Returns the number of tokens owned by an address
function balanceOf(address owner) external view returns (uint256);
// Transfers ownership of a token
function transferFrom(address from, address to, uint256 tokenId) external;
// Safe transfer with data callback
function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external;
// Approve another address to transfer a specific token
function approve(address to, uint256 tokenId) external;
// Approve an operator for all tokens
function setApprovalForAll(address operator, bool approved) external;
// Get the approved address for a token
function getApproved(uint256 tokenId) external view returns (address);
// Check if an operator is approved for all tokens
function isApprovedForAll(address owner, address operator) external view returns (bool);
// 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);
}
Token Identity: Each NFT has a unique tokenId; no two tokens in the same contract can share an ID.
Safe Transfers: The safeTransferFrom function prevents tokens from being permanently lost by checking if the recipient can handle NFTs.
Operator Approval: Unlike ERC-20's per-token approval, ERC-721 allows approving an operator for ALL your NFTs at once, which is crucial for marketplaces.
The tokenURI function is where NFTs get interesting. It returns a URL pointing to off-chain metadata (usually JSON) describing the token:
function tokenURI(uint256 tokenId) external view returns (string memory);
A typical metadata JSON looks like:
{
"name": "Cool NFT #123",
"description": "A unique digital collectible",
"image": "ipfs://QmXx.../image.png",
"attributes": [
{
"trait_type": "Background",
"value": "Blue"
},
{
"trait_type": "Rarity",
"value": "Legendary"
}
]
}
Where you store this data matters:
IPFS: Decentralized, content-addressed storage. Immutable but requires pinning services. Arweave: Permanent storage with one-time payment. Good for long-term preservation. Centralized servers: Cheapest but mutable; the owner could change the metadata or image.
Many projects use a hybrid: store the image on IPFS/Arweave and reference it in on-chain or IPFS-hosted metadata.
Here's a production-ready NFT contract with common features:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract MyNFTCollection is ERC721URIStorage, Ownable {
uint256 public constant MAX_SUPPLY = 10000;
uint256 public constant MINT_PRICE = 0.05 ether;
uint256 private _nextTokenId;
string private _baseTokenURI;
constructor(string memory baseURI) ERC721("My NFT Collection", "MNFT") Ownable(msg.sender) {
_baseTokenURI = baseURI;
}
function mint() external payable returns (uint256) {
require(_nextTokenId < MAX_SUPPLY, "Max supply reached");
require(msg.value >= MINT_PRICE, "Insufficient payment");
uint256 newTokenId = ++_nextTokenId;
_safeMint(msg.sender, newTokenId);
return newTokenId;
}
function _baseURI() internal view override returns (string memory) {
return _baseTokenURI;
}
function withdraw() external onlyOwner {
payable(owner()).transfer(address(this).balance);
}
function totalSupply() external view returns (uint256) {
return _nextTokenId;
}
}
Enumeration: ERC721Enumerable adds functions to iterate over tokens, useful but gas-expensive.
Royalties: EIP-2981 standardizes royalty payments to creators on secondary sales.
Soul-Bound Tokens: Non-transferable NFTs for credentials and achievements.
Dynamic NFTs: Metadata that changes based on on-chain or off-chain events.
For games and applications needing both fungible and non-fungible tokens, ERC-1155 provides a unified interface:
interface IERC1155 {
function balanceOf(address account, uint256 id) external view returns (uint256);
function balanceOfBatch(address[] calldata accounts, uint256[] calldata ids) external view returns (uint256[] memory);
function setApprovalForAll(address operator, bool approved) external;
function isApprovedForAll(address account, address operator) external view returns (bool);
function safeTransferFrom(address from, address to, uint256 id, uint256 amount, bytes calldata data) external;
function safeBatchTransferFrom(address from, address to, uint256[] calldata ids, uint256[] calldata amounts, bytes calldata data) external;
event TransferSingle(address indexed operator, address indexed from, address indexed to, uint256 id, uint256 value);
event TransferBatch(address indexed operator, address indexed from, address indexed to, uint256[] ids, uint256[] values);
event ApprovalForAll(address indexed account, address indexed operator, bool approved);
event URI(string value, uint256 indexed id);
}
Key advantages:
Minting is expensive. Strategies to reduce costs:
_mint instead of _safeMint when the recipient is known to be an EOANFT marketplaces need approval to transfer tokens on your behalf. This introduces complexity:
// User approves marketplace
nftContract.setApprovalForAll(marketplaceAddress, true);
// Marketplace can now transfer any of user's NFTs
nftContract.safeTransferFrom(user, buyer, tokenId);
Security consideration: Malicious marketplaces could steal all approved NFTs. Always verify contracts before approval.
Should metadata be immutable? Trade-offs:
Immutable (IPFS/Arweave):
Mutable (centralized or updateable):
Many projects use a hybrid: immutable images but updateable attributes.
PFP Projects: CryptoPunks, Bored Apes use ERC-721 for 10k collections Gaming: Axie Infinity, Gods Unchained use NFTs for in-game assets DeFi: Uniswap V3 positions are NFTs, each representing unique liquidity ranges Identity: ENS domains are ERC-721 tokens Ticketing: Event tickets with verifiable authenticity and anti-scalping mechanisms