# Tutoriel Smart Contract NFT — Créer une Collection ERC-721
Les NFTs (Non-Fungible Tokens) ont révolutionné la propriété numérique. Dans ce tutoriel complet, nous allons construire une collection NFT ERC-721 prête pour la production, incluant le minting, la gestion des métadonnées, les royalties et un mécanisme de reveal.
Qu'est-ce que l'ERC-721 ?
L'ERC-721 est le standard pour les tokens non-fongibles sur Ethereum. Contrairement aux tokens ERC-20 où chaque token est identique, chaque token ERC-721 possède un identifiant unique (tokenId) et peut représenter des actifs uniques comme de l'art, des objets de collection ou des items de jeu.
Le standard définit ces fonctions principales :
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);
}
Construction du Contrat NFT
Nous utiliserons OpenZeppelin, la bibliothèque de référence pour les standards de tokens. Commençons par un contrat de base :
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
contract MyNFTCollection is ERC721, Ownable {
using Strings for uint256;
uint256 public constant MAX_SUPPLY = 10000;
uint256 public constant PRICE = 0.05 ether;
uint256 private _tokenIdCounter;
string private _baseTokenURI;
bool public saleActive = false;
constructor(string memory name, string memory symbol)
ERC721(name, symbol)
Ownable(msg.sender)
{}
function mint(uint256 quantity) external payable {
require(saleActive, "Sale not active");
require(quantity > 0 && quantity <= 10, "Invalid quantity");
require(_tokenIdCounter + quantity <= MAX_SUPPLY, "Max supply reached");
require(msg.value >= PRICE * quantity, "Insufficient payment");
for (uint256 i = 0; i < quantity; i++) {
_tokenIdCounter++;
_safeMint(msg.sender, _tokenIdCounter);
}
}
function tokenURI(uint256 tokenId) public view override returns (string memory) {
_requireOwned(tokenId);
return string(abi.encodePacked(_baseTokenURI, tokenId.toString(), ".json"));
}
function setBaseURI(string memory baseURI) external onlyOwner {
_baseTokenURI = baseURI;
}
function toggleSale() external onlyOwner {
saleActive = !saleActive;
}
function withdraw() external onlyOwner {
payable(owner()).transfer(address(this).balance);
}
}
Fonctionnalités Avancées
1. Mécanisme de Reveal
Un reveal permet de cacher les métadonnées jusqu'à ce que la collection soit vendue :
contract RevealableNFT is ERC721, Ownable {
bool public revealed = false;
string private _hiddenMetadataUri;
string private _baseTokenURI;
function tokenURI(uint256 tokenId) public view override returns (string memory) {
_requireOwned(tokenId);
if (!revealed) {
return _hiddenMetadataUri;
}
return string(abi.encodePacked(_baseTokenURI, tokenId.toString(), ".json"));
}
function reveal(string memory baseURI) external onlyOwner {
require(!revealed, "Already revealed");
_baseTokenURI = baseURI;
revealed = true;
}
function setHiddenMetadataUri(string memory hiddenMetadataUri) external onlyOwner {
_hiddenMetadataUri = hiddenMetadataUri;
}
}
2. Whitelist / Allowlist
Réserver le mint aux early supporters :
contract WhitelistNFT is ERC721, Ownable {
mapping(address => bool) public whitelist;
mapping(address => uint256) public mintedAmount;
uint256 public constant WHITELIST_MAX_PER_WALLET = 3;
bool public whitelistActive = true;
function addToWhitelist(address[] calldata addresses) external onlyOwner {
for (uint256 i = 0; i < addresses.length; i++) {
whitelist[addresses[i]] = true;
}
}
function whitelistMint(uint256 quantity) external payable {
require(whitelistActive, "Whitelist sale not active");
require(whitelist[msg.sender], "Not whitelisted");
require(
mintedAmount[msg.sender] + quantity <= WHITELIST_MAX_PER_WALLET,
"Exceeds whitelist allocation"
);
mintedAmount[msg.sender] += quantity;
// ... mint logic
}
}
3. Royalties ERC-2981
Permettre aux créateurs de recevoir des royalties sur les ventes secondaires :
import "@openzeppelin/contracts/token/common/ERC2981.sol";
contract RoyaltyNFT is ERC721, ERC2981, Ownable {
constructor() ERC721("MyNFT", "NFT") Ownable(msg.sender) {
// 5% de royalties vers le owner
_setDefaultRoyalty(owner(), 500); // 500 = 5%
}
function setRoyaltyInfo(address receiver, uint96 feeNumerator)
external
onlyOwner
{
_setDefaultRoyalty(receiver, feeNumerator);
}
// Nécessaire pour résoudre les conflits d'héritage multiple
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, ERC2981)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
4. Airdrop Optimisé
Distribuer des NFTs de manière gas-efficiente :
function airdrop(address[] calldata recipients) external onlyOwner {
uint256 count = recipients.length;
require(_tokenIdCounter + count <= MAX_SUPPLY, "Exceeds max supply");
for (uint256 i = 0; i < count; i++) {
_tokenIdCounter++;
_safeMint(recipients[i], _tokenIdCounter);
}
}
// Ou avec des quantités différentes par destinataire
function airdropBatch(
address[] calldata recipients,
uint256[] calldata quantities
) external onlyOwner {
require(recipients.length == quantities.length, "Length mismatch");
for (uint256 i = 0; i < recipients.length; i++) {
for (uint256 j = 0; j < quantities[i]; j++) {
_tokenIdCounter++;
_safeMint(recipients[i], _tokenIdCounter);
}
}
}
Contrat Complet avec Toutes les Fonctionnalités
Voici un contrat production-ready combinant toutes les fonctionnalités :
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/common/ERC2981.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Strings.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract PremiumNFTCollection is ERC721, ERC2981, Ownable, ReentrancyGuard {
using Strings for uint256;
// Configuration
uint256 public constant MAX_SUPPLY = 10000;
uint256 public constant PRICE = 0.05 ether;
uint256 public constant WHITELIST_PRICE = 0.04 ether;
uint256 public constant MAX_PER_TX = 10;
uint256 public constant WHITELIST_MAX_PER_WALLET = 3;
// État
uint256 private _tokenIdCounter;
bool public saleActive = false;
bool public whitelistActive = false;
bool public revealed = false;
// URIs
string private _baseTokenURI;
string private _hiddenMetadataUri;
// Whitelist
mapping(address => bool) public whitelist;
mapping(address => uint256) public whitelistMinted;
event Minted(address indexed to, uint256 quantity);
event Revealed(string baseURI);
constructor(
string memory name,
string memory symbol,
string memory hiddenMetadataUri
) ERC721(name, symbol) Ownable(msg.sender) {
_hiddenMetadataUri = hiddenMetadataUri;
_setDefaultRoyalty(msg.sender, 500); // 5% royalties
}
// Mint Functions
function mint(uint256 quantity) external payable nonReentrant {
require(saleActive, "Sale not active");
require(quantity > 0 && quantity <= MAX_PER_TX, "Invalid quantity");
require(_tokenIdCounter + quantity <= MAX_SUPPLY, "Max supply reached");
require(msg.value >= PRICE * quantity, "Insufficient payment");
_mintBatch(msg.sender, quantity);
}
function whitelistMint(uint256 quantity) external payable nonReentrant {
require(whitelistActive, "Whitelist sale not active");
require(whitelist[msg.sender], "Not whitelisted");
require(quantity > 0, "Invalid quantity");
require(
whitelistMinted[msg.sender] + quantity <= WHITELIST_MAX_PER_WALLET,
"Exceeds whitelist allocation"
);
require(_tokenIdCounter + quantity <= MAX_SUPPLY, "Max supply reached");
require(msg.value >= WHITELIST_PRICE * quantity, "Insufficient payment");
whitelistMinted[msg.sender] += quantity;
_mintBatch(msg.sender, quantity);
}
function _mintBatch(address to, uint256 quantity) private {
for (uint256 i = 0; i < quantity; i++) {
_tokenIdCounter++;
_safeMint(to, _tokenIdCounter);
}
emit Minted(to, quantity);
}
// Metadata
function tokenURI(uint256 tokenId) public view override returns (string memory) {
_requireOwned(tokenId);
if (!revealed) {
return _hiddenMetadataUri;
}
return string(abi.encodePacked(_baseTokenURI, tokenId.toString(), ".json"));
}
// Admin Functions
function reveal(string memory baseURI) external onlyOwner {
require(!revealed, "Already revealed");
_baseTokenURI = baseURI;
revealed = true;
emit Revealed(baseURI);
}
function setHiddenMetadataUri(string memory hiddenMetadataUri) external onlyOwner {
_hiddenMetadataUri = hiddenMetadataUri;
}
function toggleSale() external onlyOwner {
saleActive = !saleActive;
}
function toggleWhitelistSale() external onlyOwner {
whitelistActive = !whitelistActive;
}
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 airdrop(address[] calldata recipients) external onlyOwner {
uint256 count = recipients.length;
require(_tokenIdCounter + count <= MAX_SUPPLY, "Exceeds max supply");
for (uint256 i = 0; i < count; i++) {
_tokenIdCounter++;
_safeMint(recipients[i], _tokenIdCounter);
}
}
function setRoyaltyInfo(address receiver, uint96 feeNumerator) external onlyOwner {
_setDefaultRoyalty(receiver, feeNumerator);
}
function withdraw() external onlyOwner {
uint256 balance = address(this).balance;
require(balance > 0, "No funds to withdraw");
payable(owner()).transfer(balance);
}
// View Functions
function totalSupply() external view returns (uint256) {
return _tokenIdCounter;
}
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, ERC2981)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}
Structure des Métadonnées
Les métadonnées NFT suivent généralement ce format JSON :
{
"name": "My NFT #1",
"description": "This is my awesome NFT collection",
"image": "ipfs://QmXxx.../1.png",
"attributes": [
{
"trait_type": "Background",
"value": "Blue"
},
{
"trait_type": "Rarity",
"value": "Legendary"
},
{
"trait_type": "Power",
"value": 95,
"display_type": "number"
}
]
}
Hébergement sur IPFS
Pour une vraie décentralisation, hébergez vos métadonnées sur IPFS :
ipfs://QmXxx.../Exemple avec Pinata :
const pinataSDK = require('@pinata/sdk')
const pinata = new pinataSDK(apiKey, secretKey)
const result = await pinata.pinFromFS('./metadata')
const baseURI = ipfs://${result.IpfsHash}/
Tests Complets
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "forge-std/Test.sol";
import "../src/PremiumNFTCollection.sol";
contract PremiumNFTCollectionTest is Test {
PremiumNFTCollection nft;
address owner = address(1);
address user1 = address(2);
address user2 = address(3);
function setUp() public {
vm.prank(owner);
nft = new PremiumNFTCollection(
"Test NFT",
"TNFT",
"ipfs://hidden/"
);
}
function testMintWhenSaleNotActive() public {
vm.prank(user1);
vm.expectRevert("Sale not active");
nft.mint{value: 0.05 ether}(1);
}
function testMintSuccess() public {
vm.prank(owner);
nft.toggleSale();
vm.deal(user1, 1 ether);
vm.prank(user1);
nft.mint{value: 0.05 ether}(1);
assertEq(nft.ownerOf(1), user1);
assertEq(nft.totalSupply(), 1);
}
function testWhitelistMint() public {
address[] memory whitelist = new address[](1);
whitelist[0] = user1;
vm.prank(owner);
nft.addToWhitelist(whitelist);
vm.prank(owner);
nft.toggleWhitelistSale();
vm.deal(user1, 1 ether);
vm.prank(user1);
nft.whitelistMint{value: 0.04 ether}(1);
assertEq(nft.ownerOf(1), user1);
}
function testReveal() public {
vm.prank(owner);
nft.toggleSale();
vm.deal(user1, 1 ether);
vm.prank(user1);
nft.mint{value: 0.05 ether}(1);
// Avant reveal
assertEq(nft.tokenURI(1), "ipfs://hidden/");
// Reveal
vm.prank(owner);
nft.reveal("ipfs://revealed/");
// Après reveal
assertEq(nft.tokenURI(1), "ipfs://revealed/1.json");
}
function testRoyalties() public {
(address receiver, uint256 royaltyAmount) = nft.royaltyInfo(1, 1 ether);
assertEq(receiver, owner);
assertEq(royaltyAmount, 0.05 ether); // 5%
}
}
Script de Déploiement
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import "forge-std/Script.sol";
import "../src/PremiumNFTCollection.sol";
contract DeployPremiumNFT is Script {
function run() external {
uint256 deployerPrivateKey = vm.envUint("PRIVATE_KEY");
vm.startBroadcast(deployerPrivateKey);
PremiumNFTCollection nft = new PremiumNFTCollection(
"My NFT Collection",
"MNFT",
"ipfs://QmHiddenMetadata/"
);
console.log("NFT Contract deployed at:", address(nft));
vm.stopBroadcast();
}
}
Déployez avec :
forge script script/DeployPremiumNFT.s.sol:DeployPremiumNFT \
--rpc-url $RPC_URL \
--broadcast \
--verify
Optimisations Gas
1. Utiliser ++i au lieu de i++
// ❌ Moins efficace
for (uint256 i = 0; i < quantity; i++) { }
// ✅ Plus efficace
for (uint256 i = 0; i < quantity; ++i) { }
2. Éviter les Storage Reads Répétés
// ❌ Lit _tokenIdCounter depuis le storage à chaque itération
for (uint256 i = 0; i < quantity; i++) {
_tokenIdCounter++;
_safeMint(to, _tokenIdCounter);
}
// ✅ Lit une fois, incrémente en mémoire
uint256 tokenId = _tokenIdCounter;
for (uint256 i = 0; i < quantity; ++i) {
_safeMint(to, ++tokenId);
}
_tokenIdCounter = tokenId;
3. Utiliser Custom Errors
error SaleNotActive();
error InvalidQuantity();
error MaxSupplyReached();
function mint(uint256 quantity) external payable {
if (!saleActive) revert SaleNotActive();
if (quantity == 0 || quantity > MAX_PER_TX) revert InvalidQuantity();
if (_tokenIdCounter + quantity > MAX_SUPPLY) revert MaxSupplyReached();
// ...
}
Sécurité et Bonnes Pratiques
Ownable ou AccessControlChecklist Avant Déploiement
- [ ] Tests complets (mint, whitelist, reveal, royalties)
- [ ] Audit de sécurité (ou minimum review par un pair)
- [ ] Métadonnées uploadées sur IPFS
- [ ] Hidden metadata URI configuré
- [ ] Gas optimization review
- [ ] Testnet deployment & testing
- [ ] Vérification sur Etherscan
- [ ] Documentation pour la communauté
Conclusion
Vous savez maintenant créer une collection NFT ERC-721 professionnelle avec :
- ✅ Minting public et whitelist
- ✅ Mécanisme de reveal
- ✅ Royalties ERC-2981
- ✅ Optimisations gas
- ✅ Tests complets
- ✅ Sécurité renforcée
Prochaines étapes : Explorez les NFTs dynamiques (on-chain metadata), les NFTs composables (ERC-998), et les optimisations avancées comme ERC-721A.
Apprenez Solidity interactivement sur Solingo — du débutant au développeur NFT confirmé.