Tutoriel·10 min de lecture·Par Solingo

Tutoriel Smart Contract NFT — Créer une Collection ERC-721

Créez une collection NFT complète avec minting, métadonnées, royalties et mécanisme de reveal. Guide complet ERC-721.

# 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 :

  • Préparez vos fichiers : images + fichiers JSON
  • Uploadez sur IPFS via Pinata, NFT.Storage ou un nœud local
  • Récupérez le CID : ipfs://QmXxx.../
  • Configurez le baseURI dans votre contrat
  • 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

  • ReentrancyGuard : Toujours sur les fonctions payable
  • Access Control : Utilisez Ownable ou AccessControl
  • Integer Overflow : Protégé par défaut en Solidity 0.8+
  • Validation des Inputs : Vérifiez tous les paramètres
  • Events : Émettez des events pour toutes les actions importantes
  • Pausable : Ajoutez un circuit breaker si nécessaire
  • Checklist 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é.

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement