Tutoriel·10 min de lecture·Par Solingo

NFT Smart Contract Tutorial — Build an ERC-721 Collection

Create a full NFT collection with minting, metadata, royalties, and reveal mechanism. Complete ERC-721 guide.

# 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

  • Deploy contract with not-revealed URI and royalty receiver
  • Add whitelist addresses (if using)
  • Upload placeholder metadata to IPFS
  • Enable whitelist minting (setWhitelistActive(true), setMintingEnabled(true))
  • Whitelist mint phase (24-48h)
  • Disable whitelist (setWhitelistActive(false)) for public mint
  • Upload final metadata to IPFS
  • Reveal (reveal(baseURI))
  • Withdraw funds periodically
  • Gas Optimization Tips

    • Use ERC721A for batch minting (saves ~60% gas when minting multiple)
    • Remove ERC721Enumerable if you don't need totalSupply() on-chain
    • Use _mint instead of _safeMint if 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!

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement