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.
The Uniqueness Problem
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:
- Digital art with provable scarcity
- Game items with different attributes
- Event tickets with specific seat assignments
- Domain names
- Real estate deeds
- Identity credentials
We needed a standard that could represent uniqueness while maintaining the composability that made ERC-20 successful. Enter ERC-721.
The ERC-721 Standard
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.
Core Interface
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);
}
Key Differences from ERC-20
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.
Metadata: Connecting On-Chain to Off-Chain
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"
}
]
}
Storage Considerations
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.
Implementation: Building an NFT Collection
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;
}
}
Advanced Patterns
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.
ERC-1155: The Multi-Token Standard
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:
- Gas efficiency: Batch operations save gas
- Flexibility: Mix fungible and non-fungible in one contract
- Gaming: Perfect for items with multiple copies (potions) and uniques (legendary weapons)
Engineering Challenges
Gas Optimization
Minting is expensive. Strategies to reduce costs:
- Use
_mintinstead of_safeMintwhen the recipient is known to be an EOA - Batch minting for multiple tokens
- Optimize metadata storage (on-chain vs. off-chain trade-offs)
- Consider lazy minting (mint on first transfer, not on creation)
Marketplace Integration
NFT 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.
Metadata Mutability
Should metadata be immutable? Trade-offs:
Immutable (IPFS/Arweave):
- Pros: True ownership, verifiable scarcity
- Cons: Can't fix mistakes, no dynamic features
Mutable (centralized or updateable):
- Pros: Fix errors, add dynamic traits
- Cons: Trust issues, rug pull risk
Many projects use a hybrid: immutable images but updateable attributes.
Real-World Applications
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
Best Practices
- Use OpenZeppelin: Battle-tested, audited implementations
- Implement EIP-2981: Standard royalty support for marketplaces
- Consider metadata storage carefully: IPFS for decentralization, Arweave for permanence
- Add reveal mechanisms: Hide metadata until minting completes (prevents sniping rare traits)
- Test extensively: NFT bugs can result in lost or locked assets
- Plan for upgradeability: Use proxies if you need to fix bugs post-deployment