# NFT Smart Contract Tutorial — Build an ERC-721 Collection
NFTs (Non-Fungible Tokens) have revolutionized digital ownership. In this comprehensive tutorial, we'll build a production-ready ERC-721 NFT collection from scratch, including minting, metadata management, royalties, and a reveal mechanism.
What is ERC-721?
ERC-721 is the standard for non-fungible tokens on Ethereum. Unlike ERC-20 tokens where every token is identical, each ERC-721 token has a unique identifier (tokenId) and can represent unique assets like art, collectibles, or game items.
The standard defines these core functions:
interface IERC721 {
function balanceOf(address owner) external view returns (uint256);
function ownerOf(uint256 tokenId) external view returns (address);
function transferFrom(address from, address to, uint256 tokenId) external;
function approve(address to, uint256 tokenId) external;
function getApproved(uint256 tokenId) external view returns (address);
function setApprovalForAll(address operator, bool approved) external;
function isApprovedForAll(address owner, address operator) external view returns (bool);
}
Building the NFT Contract
We'll use OpenZeppelin's battle-tested implementation as our foundation. Here's our complete NFT collection:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/interfaces/IERC2981.sol";
contract MyNFTCollection is ERC721, ERC721Enumerable, Ownable, IERC2981 {
using Strings for uint256;
// Collection constants
uint256 public constant MAX_SUPPLY = 10000;
uint256 public constant MINT_PRICE = 0.05 ether;
uint256 public constant MAX_PER_WALLET = 5;
// State variables
uint256 private _tokenIdCounter;
string private _baseTokenURI;
string private _notRevealedURI;
bool public revealed = false;
bool public mintingEnabled = false;
// Whitelist mapping
mapping(address => bool) public whitelist;
bool public whitelistActive = true;
// Royalties (EIP-2981)
address public royaltyReceiver;
uint96 public royaltyBasisPoints = 500; // 5%
constructor(
string memory notRevealedURI,
address _royaltyReceiver
) ERC721("My NFT Collection", "MNFT") Ownable(msg.sender) {
_notRevealedURI = notRevealedURI;
royaltyReceiver = _royaltyReceiver;
}
// Minting function
function mint(uint256 quantity) external payable {
require(mintingEnabled, "Minting not enabled");
require(quantity > 0 && quantity <= MAX_PER_WALLET, "Invalid quantity");
require(_tokenIdCounter + quantity <= MAX_SUPPLY, "Max supply reached");
require(msg.value >= MINT_PRICE * quantity, "Insufficient payment");
require(balanceOf(msg.sender) + quantity <= MAX_PER_WALLET, "Max per wallet exceeded");
// Whitelist check
if (whitelistActive) {
require(whitelist[msg.sender], "Not whitelisted");
}
for (uint256 i = 0; i < quantity; i++) {
uint256 tokenId = _tokenIdCounter;
_tokenIdCounter++;
_safeMint(msg.sender, tokenId);
}
}
// Owner mint (for team/giveaways)
function ownerMint(address to, uint256 quantity) external onlyOwner {
require(_tokenIdCounter + quantity <= MAX_SUPPLY, "Max supply reached");
for (uint256 i = 0; i < quantity; i++) {
uint256 tokenId = _tokenIdCounter;
_tokenIdCounter++;
_safeMint(to, tokenId);
}
}
// Metadata management
function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
require(ownerOf(tokenId) != address(0), "Token does not exist");
if (!revealed) {
return _notRevealedURI;
}
return bytes(_baseTokenURI).length > 0
? string(abi.encodePacked(_baseTokenURI, tokenId.toString(), ".json"))
: "";
}
function reveal(string memory baseTokenURI) external onlyOwner {
require(!revealed, "Already revealed");
_baseTokenURI = baseTokenURI;
revealed = true;
}
// Whitelist management
function addToWhitelist(address[] calldata addresses) external onlyOwner {
for (uint256 i = 0; i < addresses.length; i++) {
whitelist[addresses[i]] = true;
}
}
function removeFromWhitelist(address[] calldata addresses) external onlyOwner {
for (uint256 i = 0; i < addresses.length; i++) {
whitelist[addresses[i]] = false;
}
}
function setWhitelistActive(bool active) external onlyOwner {
whitelistActive = active;
}
// Admin functions
function setMintingEnabled(bool enabled) external onlyOwner {
mintingEnabled = enabled;
}
function setNotRevealedURI(string memory notRevealedURI) external onlyOwner {
_notRevealedURI = notRevealedURI;
}
function withdraw() external onlyOwner {
uint256 balance = address(this).balance;
require(balance > 0, "No funds to withdraw");
payable(owner()).transfer(balance);
}
// EIP-2981 Royalty support
function royaltyInfo(
uint256 tokenId,
uint256 salePrice
) external view override returns (address receiver, uint256 royaltyAmount) {
require(ownerOf(tokenId) != address(0), "Token does not exist");
return (royaltyReceiver, (salePrice * royaltyBasisPoints) / 10000);
}
function setRoyaltyInfo(address receiver, uint96 basisPoints) external onlyOwner {
require(basisPoints <= 1000, "Royalty too high"); // Max 10%
royaltyReceiver = receiver;
royaltyBasisPoints = basisPoints;
}
// Required overrides
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, ERC721Enumerable, IERC165)
returns (bool)
{
return interfaceId == type(IERC2981).interfaceId || super.supportsInterface(interfaceId);
}
}
Key Features Explained
1. Max Supply and Pricing
uint256 public constant MAX_SUPPLY = 10000;
uint256 public constant MINT_PRICE = 0.05 ether;
uint256 public constant MAX_PER_WALLET = 5;
These constants define collection economics. Using constant saves gas since values are embedded in bytecode.
2. Reveal Mechanism
The reveal mechanism shows placeholder metadata initially, then reveals true metadata later:
function tokenURI(uint256 tokenId) public view virtual override returns (string memory) {
if (!revealed) {
return _notRevealedURI; // Same for all tokens
}
return string(abi.encodePacked(_baseTokenURI, tokenId.toString(), ".json"));
}
This prevents rarity sniping during mint.
3. Whitelist System
mapping(address => bool) public whitelist;
function mint(uint256 quantity) external payable {
if (whitelistActive) {
require(whitelist[msg.sender], "Not whitelisted");
}
// ... minting logic
}
Allows early access for select addresses before public mint.
4. EIP-2981 Royalties
function royaltyInfo(uint256 tokenId, uint256 salePrice)
external view returns (address receiver, uint256 royaltyAmount) {
return (royaltyReceiver, (salePrice * royaltyBasisPoints) / 10000);
}
Standardized royalties recognized by OpenSea, Blur, and other marketplaces. 500 basis points = 5%.
Metadata Structure
NFT metadata follows this JSON format:
{
"name": "My NFT #1",
"description": "A unique digital collectible",
"image": "ipfs://QmXyz.../1.png",
"attributes": [
{
"trait_type": "Background",
"value": "Blue"
},
{
"trait_type": "Rarity",
"value": "Legendary"
}
]
}
Host on IPFS for decentralization. Use services like Pinata or NFT.Storage.
Deployment Checklist
setWhitelistActive(true), setMintingEnabled(true))setWhitelistActive(false)) for public mintreveal(baseURI))Gas Optimization Tips
- Use
ERC721Afor batch minting (saves ~60% gas when minting multiple)
- Remove
ERC721Enumerableif you don't needtotalSupply()on-chain
- Use
_mintinstead of_safeMintif minting to EOAs only
- Consider merkle tree for whitelist (saves storage costs)
Testing Example
// Test in Foundry
function testMint() public {
vm.deal(user, 1 ether);
nft.setMintingEnabled(true);
nft.setWhitelistActive(false);
vm.prank(user);
nft.mint{value: 0.05 ether}(1);
assertEq(nft.balanceOf(user), 1);
assertEq(nft.ownerOf(0), user);
}
Verification on Etherscan
forge verify-contract \
--chain-id 1 \
--constructor-args $(cast abi-encode "constructor(string,address)" "ipfs://..." "0x...") \
--compiler-version v0.8.20 \
<CONTRACT_ADDRESS> \
src/MyNFTCollection.sol:MyNFTCollection
Conclusion
You now have a production-ready NFT collection with:
- ✅ Secure minting with price and supply limits
- ✅ Whitelist for early access
- ✅ Reveal mechanism to prevent sniping
- ✅ EIP-2981 royalties for marketplaces
- ✅ Owner functions for management
Remember to audit your contract before mainnet deployment. Consider using auditing services like OpenZeppelin Defender or Certora for high-value collections.
Next steps: Implement a minting dApp frontend, set up metadata generation pipeline, and plan your marketing strategy!