ITSkillsCenter
Développement Web

Créer une collection NFT ERC-721 avec OpenZeppelin

13 min de lecture
Guide principal : Développer des smart contracts sur Ethereum : Solidity, outils et web3.

Là où un jeton ERC-20 produit des unités interchangeables, la norme ERC-721 produit des jetons uniques : chacun porte un identifiant distinct et peut renvoyer vers ses propres métadonnées. C’est le mécanisme idéal pour représenter une chose dont chaque exemplaire compte individuellement — un titre, un justificatif, une carte nominative. On parle souvent de « NFT » (jeton non fongible), mais derrière l’acronyme, il s’agit simplement d’un registre de propriété d’objets uniques.

Pour rester concret, nous émettons des cartes de membre numériques pour le programme de fidélité construit dans cette série. Chaque carte est unique, attribuée à un membre, et décrit un niveau d’adhésion via ses métadonnées. C’est un certificat infalsifiable et vérifiable, pas un objet de collection.

🎯 Ce que vous allez apprendre

  • Distinguer un jeton non fongible (ERC-721) d’un jeton fongible (ERC-20).
  • Créer un contrat ERC-721 avec stockage d’URI de métadonnées via OpenZeppelin.
  • Gérer un identifiant incrémental sans la bibliothèque Counters (retirée en version 5).
  • Résoudre les overrides imposés par l’héritage multiple.
  • Structurer un fichier de métadonnées et le rattacher à un jeton.

🛠️ Ce que vous allez construire

Un contrat CarteMembre (symbole CARTE) où le propriétaire émet des cartes nominatives, chacune avec un identifiant unique et une URL de métadonnées décrivant le niveau d’adhésion. Une carte appartient à une adresse, elle est traçable et son authenticité est garantie par la chaîne.

Prérequis

  • Un projet Foundry avec OpenZeppelin installé (voir le tutoriel sur le jeton ERC-20).
  • Le remapping @openzeppelin/contracts/ configuré.
  • ⏱️ Temps estimé : 55 à 75 minutes.

Comprendre l’unicité d’un ERC-721

Dans un ERC-20, on demande « combien d’unités possède cette adresse ? ». Dans un ERC-721, la question devient « qui possède le jeton numéro 42 ? ». Chaque jeton a un tokenId unique et un propriétaire unique, consultable via ownerOf(tokenId). À ce tokenId on associe une tokenURI : une URL pointant vers un fichier de métadonnées (au format JSON) qui décrit le jeton — son nom, sa description, éventuellement une image et des attributs. C’est cette indirection qui permet à deux cartes du même contrat de porter des informations différentes.

Étape 1 — Écrire le contrat CarteMembre

On combine trois briques d’OpenZeppelin : ERC721 pour la norme, ERC721URIStorage pour associer une URI à chaque jeton, et Ownable pour réserver l’émission. L’héritage de deux contrats qui définissent tokenURI et supportsInterface impose de lever l’ambiguïté avec des overrides explicites — c’est une exigence du compilateur, pas une option.

Créez src/CarteMembre.sol :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {ERC721URIStorage} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract CarteMembre is ERC721, ERC721URIStorage, Ownable {
    uint256 private _prochainId;

    constructor(address proprietaireInitial)
        ERC721("Carte Membre", "CARTE")
        Ownable(proprietaireInitial)
    {}

    function emettre(address membre, string memory uri)
        external
        onlyOwner
        returns (uint256)
    {
        uint256 tokenId = _prochainId++;
        _safeMint(membre, tokenId);
        _setTokenURI(tokenId, uri);
        return tokenId;
    }

    // Overrides imposes par l'heritage multiple
    function tokenURI(uint256 tokenId)
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

Détaillons les points sensibles. Le compteur _prochainId remplace l’ancienne bibliothèque Counters, retirée en version 5 d’OpenZeppelin : un simple uint256 incrémenté suffit, et _prochainId++ renvoie la valeur courante avant de l’augmenter, ce qui donne des identifiants démarrant à zéro. _safeMint crée le jeton et vérifie que le destinataire peut le recevoir (utile si c’est un contrat). _setTokenURI enregistre l’URL des métadonnées. Enfin, les deux fonctions surchargées appellent super : elles ne font que dire au compilateur quelle implémentation utiliser quand deux parents en proposent une.

Étape 2 — Compiler

forge build

Si vous oubliez l’un des deux overrides, le compilateur refuse net avec un message du type Derived contract must override function en nommant la fonction et les deux bases en conflit. C’est l’erreur la plus fréquente sur ce contrat : le message indique exactement quoi ajouter. Une fois les deux overrides en place, la compilation réussit.

Étape 3 — Préparer les métadonnées

La tokenURI pointe vers un fichier JSON décrivant la carte. Le format suit une convention largement adoptée : un nom, une description, une image optionnelle et une liste d’attributs. Pour une carte de membre, on décrit le niveau d’adhésion et l’année :

{
  "name": "Carte Membre #0",
  "description": "Carte de membre du programme de fidelite, niveau Argent.",
  "image": "ipfs://CID_DU_BADGE/badge.svg",
  "attributes": [
    { "trait_type": "Niveau", "value": "Argent" },
    { "trait_type": "Annee", "value": 2026 }
  ]
}

Le champ image est facultatif ; s’il est présent, on y met un visuel neutre — par exemple un badge géométrique au format SVG, pas un portrait. L’essentiel d’une carte de membre tient dans ses attributs textuels, qui décrivent un droit, pas dans une illustration. Ce fichier doit être hébergé de façon stable : on le dépose généralement sur IPFS, un système de stockage adressé par contenu, et on obtient un identifiant (CID) qu’on insère dans l’URI sous la forme ipfs://CID/0.json.

Étape 4 — Pourquoi IPFS pour les métadonnées

Stocker les métadonnées dans le contrat coûterait très cher en gaz, car écrire des données sur la chaîne se paie à l’octet. On préfère donc une URL pointant vers un stockage externe. Mais une URL HTTP classique peut disparaître ou changer de contenu, ce qui rendrait la carte trompeuse. IPFS résout ce problème : l’adresse d’un fichier y est dérivée de son contenu, si bien qu’un CID donné renvoie toujours le même fichier. Pour que ce fichier reste disponible, on l’« épingle » via un service de pinning. Le résultat est une URI stable et vérifiable : quiconque consulte la carte peut récupérer exactement les métadonnées émises au départ.

Étape 5 — Écrire les tests

On vérifie qu’une émission attribue bien la carte au membre, que l’URI est enregistrée, que les identifiants s’incrémentent, et qu’une adresse non propriétaire ne peut pas émettre. Créez test/CarteMembre.t.sol :

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {Test} from "forge-std/Test.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {CarteMembre} from "../src/CarteMembre.sol";

contract CarteMembreTest is Test {
    CarteMembre private cartes;
    address private membre = address(0xBEEF);
    string private uri = "ipfs://exemple/0.json";

    function setUp() public {
        cartes = new CarteMembre(address(this));
    }

    function test_EmissionAttribueLaCarte() public {
        uint256 id = cartes.emettre(membre, uri);
        assertEq(cartes.ownerOf(id), membre);
        assertEq(cartes.balanceOf(membre), 1);
        assertEq(cartes.tokenURI(id), uri);
    }

    function test_IdsIncrementaux() public {
        uint256 id0 = cartes.emettre(membre, uri);
        uint256 id1 = cartes.emettre(membre, uri);
        assertEq(id0, 0);
        assertEq(id1, 1);
    }

    function test_RevertSi_NonProprietaire() public {
        vm.prank(membre);
        vm.expectRevert(
            abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, membre)
        );
        cartes.emettre(membre, uri);
    }
}

Le test test_IdsIncrementaux confirme que notre compteur manuel produit bien des identifiants successifs 0 puis 1. Le test d’accès réutilise l’erreur typée OwnableUnauthorizedAccount vue précédemment. Lancez la suite :

forge test -vvv

Trois tests au vert valident la mécanique d’émission, le stockage d’URI et le contrôle d’accès.

Point d’étape — Votre contrat compile (les deux overrides sont en place) et passe trois tests. Vous pouvez émettre une carte, lire son propriétaire et son URI, et vous savez que seul le propriétaire du contrat peut émettre.

Étape 6 — Déployer et émettre une carte

Le déploiement reprend la procédure connue, avec l’adresse propriétaire en argument de constructeur :

forge create src/CarteMembre.sol:CarteMembre   --rpc-url $SEPOLIA_RPC_URL   --account deployeur   --broadcast   --constructor-args $(cast wallet address --account deployeur)

Émettez ensuite une carte vers une adresse de membre, en passant l’URI de métadonnées préparée :

cast send 0xCONTRAT "emettre(address,string)" 0xMEMBRE "ipfs://exemple/0.json"   --account deployeur   --rpc-url $SEPOLIA_RPC_URL

Après confirmation, vérifiez la propriété avec une lecture : cast call 0xCONTRAT "ownerOf(uint256)(address)" 0 --rpc-url $SEPOLIA_RPC_URL doit renvoyer l’adresse du membre. Sur un explorateur, les portefeuilles compatibles afficheront la carte à partir de ses métadonnées.

À quoi servent réellement les jetons uniques

L’image populaire du NFT le réduit souvent à un objet de collection, mais la technologie répond à un besoin bien plus large : prouver, de façon vérifiable et infalsifiable, qu’une adresse donnée détient un droit ou un statut. Une carte de membre, un certificat de formation, un billet d’accès nominatif, un justificatif de participation, un titre de propriété numérique : tous partagent la même structure — un identifiant unique, un propriétaire, et des métadonnées qui décrivent ce que représente le jeton. La norme ERC-721 fournit exactement cette structure.

L’intérêt par rapport à une base de données classique tient en trois points. La propriété est publiquement vérifiable : n’importe qui peut confirmer qui détient la carte numéro 7 sans demander la permission à un serveur central. Le registre est résistant à la falsification : on ne peut pas inventer une carte sans passer par la fonction d’émission du contrat, elle-même protégée. Et le droit est portable : il vit dans le portefeuille du membre, indépendamment d’une plateforme particulière. Pour un programme de fidélité, cela signifie qu’une carte reste valable et lisible même si l’application qui l’affiche change ou disparaît.

Transfert, propriété et sécurité d’émission

Comme tout ERC-721, nos cartes sont transférables par leur détenteur via transferFrom ou safeTransferFrom. Selon l’usage, c’est souhaitable ou non : une carte de membre nominative gagnerait peut-être à être non transférable, alors qu’un billet revendable doit pouvoir changer de main. La norme laisse ce choix au concepteur ; le comportement par défaut autorise le transfert. Savoir cela évite une surprise : par défaut, le destinataire d’une carte peut la donner à quelqu’un d’autre.

Un mot sur _safeMint, que nous avons préféré à _mint. Les deux créent un jeton, mais _safeMint ajoute une vérification : si le destinataire est un contrat, il s’assure que ce contrat sait recevoir des ERC-721 (sinon la transaction échoue). Cela évite qu’un jeton se retrouve « coincé » dans un contrat incapable de le manipuler, ce qui équivaudrait à le perdre. Pour une adresse de personne ordinaire, la différence est invisible ; mais prendre l’habitude de _safeMint protège contre des erreurs coûteuses et irréversibles. C’est un exemple du principe qui guide tout ce travail : sur une chaîne où chaque action est définitive, on choisit systématiquement l’option qui ferme la porte aux erreurs silencieuses.

La convention de métadonnées et les extensions utiles

Le format JSON que nous avons utilisé n’est pas arbitraire : c’est une convention que les portefeuilles et les explorateurs savent lire. Les champs name, description et image sont reconnus universellement, et le tableau attributes (avec ses paires trait_type / value) permet d’exposer des propriétés structurées — ici, le niveau d’adhésion et l’année. Respecter cette convention, c’est s’assurer que votre carte s’affiche correctement partout, sans code supplémentaire de votre côté. À l’inverse, un JSON aux champs fantaisistes restera lisible par votre seule application.

OpenZeppelin propose d’autres extensions selon le besoin. ERC721Enumerable permet de parcourir tous les jetons et ceux d’un propriétaire donné, au prix d’un surcoût en gaz à l’émission. ERC721Burnable ajoute la destruction d’un jeton par son détenteur. Pour une logique de non-transférabilité (cartes nominatives liées à un membre), on surcharge les fonctions de transfert pour les bloquer. Le principe reste le même qu’avec l’ERC-20 : on assemble des briques auditées plutôt que de réécrire la norme, et chaque extension ajoutée doit être justifiée par un besoin réel, car elle augmente la surface de code et le coût d’exécution. Commencer minimal, comme nous l’avons fait, puis ajouter une extension quand le besoin se présente, est une démarche saine.

🐞 Pièges fréquents

Symptôme / erreur Cause probable Correctif
Derived contract must override function "tokenURI" Override manquant en héritage multiple Ajouter l’override override(ERC721, ERC721URIStorage)
Identifier not found: Counters Bibliothèque retirée en version 5 Utiliser un simple uint256 incrémenté
ERC721NonexistentToken Lecture d’un tokenId jamais émis Émettre d’abord, vérifier l’identifiant
Métadonnées introuvables dans le portefeuille URI HTTP instable ou CID erroné Héberger sur IPFS et épingler le fichier
Émission refusée Appel par une adresse non propriétaire Émettre depuis le propriétaire du contrat

✅ Récapitulatif

Vous avez créé un contrat ERC-721 qui émet des cartes uniques avec leurs métadonnées, géré un compteur d’identifiants sans la bibliothèque retirée, résolu les overrides de l’héritage multiple, et testé l’émission, le stockage d’URI et le contrôle d’accès. Vous comprenez le rôle de la tokenURI et pourquoi IPFS offre un hébergement stable et vérifiable. C’est tout ce qu’il faut pour représenter des objets uniques sur la chaîne.

🧾 Aide-mémoire

Élément Rôle
ERC721("Nom", "SYM") Constructeur de la norme
ERC721URIStorage Associer une URI par jeton
_safeMint(to, id) Émettre un jeton en vérifiant le destinataire
_setTokenURI(id, uri) Enregistrer l’URL des métadonnées
ownerOf(id) Lire le propriétaire d’un jeton
override(ERC721, ERC721URIStorage) Lever l’ambiguïté d’héritage

💪 À vous de jouer

Ajoutez une fonction totalEmises() qui renvoie le nombre de cartes déjà émises, puis testez qu’elle augmente à chaque émission. Indice : la valeur de _prochainId correspond exactement à ce total.

Voir une solution
function totalEmises() external view returns (uint256) {
    return _prochainId;
}

// Test
function test_TotalEmises() public {
    assertEq(cartes.totalEmises(), 0);
    cartes.emettre(membre, uri);
    cartes.emettre(membre, uri);
    assertEq(cartes.totalEmises(), 2);
}

Tutoriels frères

Pour aller plus loin

FAQ

Quelle différence entre ERC-20 et ERC-721 ?
Un ERC-20 gère des unités interchangeables (on compte des quantités) ; un ERC-721 gère des jetons uniques (on identifie chaque exemplaire par son numéro et son propriétaire).

Faut-il obligatoirement une image ?
Non. Le champ image est optionnel. Pour un certificat ou une carte de membre, les attributs textuels suffisent à décrire le droit représenté.

Pourquoi ne pas stocker les métadonnées dans le contrat ?
Parce qu’écrire des données sur la chaîne se paie cher au gaz. Une URI vers IPFS est économique tout en restant stable et vérifiable grâce à l’adressage par contenu.

Une carte de membre peut-elle être rendue non transférable ?
Oui. Le comportement par défaut autorise le transfert, mais on peut surcharger les fonctions de transfert pour les bloquer et obtenir une carte strictement liée à son membre. C’est un choix de conception à faire selon l’usage visé.

Partager
Service ITSkillsCenter

Application mobile Android et iOS

Création d'application mobile Android et iOS. À partir de 350 000 FCFA.

Démarrer mon projet
Publicité