Un jeton ERC-20 est simplement un contrat qui tient un registre de soldes transférables selon une interface standard. Cette norme est ce qui permet à n’importe quel outil — portefeuille, application, autre contrat — de comprendre votre jeton sans connaître son code. Réécrire cette mécanique à la main serait long et risqué ; c’est pourquoi on s’appuie sur OpenZeppelin, la bibliothèque de contrats la plus auditée de l’écosystème. En quelques lignes, on obtient un jeton conforme et solide.
Dans la continuité du système de fidélité construit jusqu’ici, nous transformons les points en un jeton utilitaire : JetonFidelite, de symbole FID. Chaque point devient une unité transférable que la boutique émet pour ses clients. Aucune notion de valeur marchande ici : c’est une brique technique pour apprendre la norme ERC-20.
🎯 Ce que vous allez apprendre
- Installer OpenZeppelin dans un projet Foundry et configurer le remapping.
- Créer un jeton ERC-20 conforme en héritant des contrats audités.
- Restreindre l’émission au propriétaire avec
Ownable(version 5). - Personnaliser les décimales pour des unités entières.
- Tester métadonnées, émission, transfert et contrôle d’accès.
🛠️ Ce que vous allez construire
Un contrat JetonFidelite (symbole FID) dont seul le propriétaire peut émettre des unités, que les détenteurs peuvent ensuite transférer librement, et qui s’affiche correctement dans n’importe quel portefeuille compatible parce qu’il respecte la norme.
Prérequis
- Un projet Foundry fonctionnel (voir les tutoriels précédents).
- Les bases de l’écriture et du test d’un contrat avec
forge. - ⏱️ Temps estimé : 50 à 70 minutes.
Étape 1 — Installer OpenZeppelin
OpenZeppelin se récupère comme dépendance du projet. La commande clone la bibliothèque dans le dossier lib/. On épingle une version publiée plutôt que la branche de développement, qui n’est pas faite pour la production.
forge install OpenZeppelin/openzeppelin-contracts@v5.6.1
La bibliothèque atterrit dans lib/openzeppelin-contracts. Pour que le compilateur résolve les imports courts du type @openzeppelin/contracts/..., il faut un remapping. Créez (ou complétez) un fichier remappings.txt à la racine du projet :
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
Vérifiez que Foundry prend bien le remapping en compte :
forge remappings
La ligne que vous venez d’ajouter doit apparaître dans la liste. Si l’import échoue plus tard avec File not found, c’est presque toujours ce remapping qui manque ou qui pointe vers un mauvais chemin.
Étape 2 — Écrire le jeton
On hérite de deux contrats OpenZeppelin : ERC20 pour toute la logique de la norme (soldes, transferts, autorisations) et Ownable pour réserver l’émission au propriétaire. La grande nouveauté de la version 5 d’OpenZeppelin est que Ownable exige désormais une adresse propriétaire explicite en argument de constructeur — fini le propriétaire implicite égal au déployeur.
Créez src/JetonFidelite.sol :
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract JetonFidelite is ERC20, Ownable {
constructor(address proprietaireInitial)
ERC20("Jeton Fidelite", "FID")
Ownable(proprietaireInitial)
{}
function decimals() public pure override returns (uint8) {
return 0;
}
function crediter(address client, uint256 points) external onlyOwner {
_mint(client, points);
}
}
Le constructeur transmet le nom et le symbole à ERC20, et l’adresse propriétaire à Ownable. On surcharge decimals() pour renvoyer 0 : par défaut un ERC-20 utilise 18 décimales (utile pour une monnaie divisible), mais des points de fidélité se comptent en unités entières, donc une unité FID vaut un point. La fonction crediter appelle _mint, une fonction interne d’OpenZeppelin qui crée de nouvelles unités et les attribue à une adresse ; le modificateur onlyOwner, hérité de Ownable, bloque tout appel qui ne vient pas du propriétaire.
Étape 3 — Compiler
Compilons pour valider la syntaxe et la résolution des imports.
forge build
Un Compiler run successful confirme que le remapping fonctionne et que l’héritage est correct. Si vous obtenez une erreur de type Identifier not found sur ERC20 ou Ownable, vérifiez l’orthographe des chemins d’import et la présence du remapping. Une erreur sur le constructeur de Ownable signale presque toujours qu’on a oublié de lui passer l’adresse propriétaire — l’erreur la plus fréquente en migrant depuis la version 4.
Étape 4 — Écrire les tests
On veut vérifier quatre choses : les métadonnées (nom, symbole, décimales), l’émission qui augmente le solde et l’offre totale, le transfert entre détenteurs, et le refus d’émission par une adresse non propriétaire. Pour ce dernier cas, la version 5 d’OpenZeppelin lève une erreur typée OwnableUnauthorizedAccount qui inclut l’adresse fautive — on la teste avec son selector et l’argument attendu.
Créez test/JetonFidelite.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 {JetonFidelite} from "../src/JetonFidelite.sol";
contract JetonFideliteTest is Test {
JetonFidelite private jeton;
address private client = address(0xBEEF);
function setUp() public {
jeton = new JetonFidelite(address(this));
}
function test_Metadonnees() public {
assertEq(jeton.name(), "Jeton Fidelite");
assertEq(jeton.symbol(), "FID");
assertEq(jeton.decimals(), 0);
}
function test_CrediteAugmenteSoldeEtOffre() public {
jeton.crediter(client, 100);
assertEq(jeton.balanceOf(client), 100);
assertEq(jeton.totalSupply(), 100);
}
function test_Transfert() public {
jeton.crediter(client, 100);
vm.prank(client);
jeton.transfer(address(0xCAFE), 30);
assertEq(jeton.balanceOf(client), 70);
assertEq(jeton.balanceOf(address(0xCAFE)), 30);
}
function test_RevertSi_NonProprietaire() public {
vm.prank(client);
vm.expectRevert(
abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, client)
);
jeton.crediter(client, 100);
}
}
Comme le contrat de test déploie le jeton en se passant address(this) comme propriétaire, il peut créditer directement. Le test de transfert utilise vm.prank(client) pour que le transfert parte bien du client. Le dernier test confirme qu’une émission par une autre adresse échoue avec l’erreur typée d’OpenZeppelin et l’adresse exacte de l’appelant.
Étape 5 — Exécuter les tests
forge test -vvv
Vous devez obtenir quatre tests au vert. Le détail -vvv est précieux ici : si le test de contrôle d’accès échoue, la trace montre l’erreur réellement levée, ce qui permet de vérifier qu’il s’agit bien de OwnableUnauthorizedAccount et non d’une autre annulation.
✅ Point d’étape — Votre jeton compile et passe quatre tests : métadonnées correctes, émission contrôlée, transfert fonctionnel, accès restreint. C’est un ERC-20 conforme, prêt à être déployé exactement comme le contrat du tutoriel de déploiement.
Étape 6 — Déployer le jeton
Le déploiement suit la procédure vue pour Sepolia, à une différence près : le constructeur attend un argument, l’adresse propriétaire. On la passe avec --constructor-args, en réutilisant l’adresse de la clé de déploiement.
forge create src/JetonFidelite.sol:JetonFidelite --rpc-url $SEPOLIA_RPC_URL --account deployeur --broadcast --constructor-args $(cast wallet address --account deployeur)
Une fois déployé, créditez un client puis lisez son solde avec cast, comme pour le registre. Le jeton apparaîtra dans un portefeuille compatible si vous y ajoutez son adresse de contrat — la preuve que respecter la norme ERC-20 suffit à être reconnu par tout l’écosystème.
Comprendre la norme ERC-20
Pourquoi tant d’insistance sur le mot « norme » ? Parce qu’un ERC-20 n’est rien d’autre qu’un contrat qui expose un ensemble convenu de fonctions et d’événements : balanceOf, transfer, approve, transferFrom, totalSupply, et les journaux Transfer et Approval. Tout outil qui parle ERC-20 sait dialoguer avec n’importe quel jeton respectant ce contrat d’interface, sans en connaître l’implémentation. C’est exactement l’idée d’une prise électrique normalisée : la forme est convenue, l’appareil derrière peut varier.
Le couple approve / transferFrom mérite une mention. Il permet à un détenteur d’autoriser un tiers — souvent un autre contrat — à dépenser un montant en son nom. C’est ce mécanisme qui rend les jetons composables : un contrat peut accepter des FID après que l’utilisateur a accordé une autorisation. OpenZeppelin implémente toute cette plomberie, y compris les vérifications de débordement et les cas limites, ce qui vous évite des bugs subtils. Votre travail se réduit à définir la règle d’émission et, ici, le nombre de décimales.
Fongibilité, offre et émission
Un jeton ERC-20 est dit fongible : chaque unité est strictement interchangeable avec une autre, exactement comme deux pièces de même valeur. Cette propriété distingue l’ERC-20 des jetons uniques que nous verrons avec la norme NFT. Pour un programme de fidélité, la fongibilité est précisément ce qu’on veut : un point en vaut un autre, et l’on raisonne en quantités.
L’offre totale (totalSupply) est la somme de toutes les unités en circulation. Dans notre contrat, elle augmente à chaque appel de _mint et diminue à chaque _burn. Comprendre cela évite une confusion classique : il n’existe pas de « réserve cachée » de jetons quelque part. L’offre est exactement ce qui a été émis, moins ce qui a été détruit, et tout le monde peut la lire à tout instant. Cette transparence est l’une des forces du modèle : la règle d’émission est inscrite dans le code, visible et vérifiable, et personne ne peut créer d’unités hors des fonctions prévues. C’est aussi pourquoi la fonction d’émission doit être protégée : si crediter n’avait pas onlyOwner, n’importe qui pourrait s’attribuer des points, et le registre perdrait tout sens.
Lire l’activité du jeton via les événements
Chaque transfert et chaque émission émettent un événement Transfer standardisé (l’émission est modélisée comme un transfert depuis l’adresse nulle). Ces journaux sont la mémoire consultable du jeton : une interface web, un explorateur ou un service d’analyse les lisent pour reconstituer l’historique sans interroger chaque solde un par un. Quand vous afficherez les opérations d’un client dans une application, ce sont ces événements que vous écouterez. C’est aussi grâce à eux qu’un explorateur comme Etherscan peut dresser la liste des détenteurs et des mouvements d’un jeton vérifié, alors même que le contrat ne stocke qu’une simple table de soldes. Concevoir des événements clairs et bien indexés est donc une vraie décision de conception, pas un détail : ils déterminent ce que vos applications pourront afficher efficacement plus tard.
Ce que change la version 5 d’OpenZeppelin
Beaucoup de tutoriels en ligne datent de la version 4 et induisent en erreur. Trois différences méritent d’être retenues. D’abord, Ownable exige une adresse propriétaire au constructeur : c’est plus sûr, car cela force à décider explicitement qui contrôle le contrat, notamment lors d’un déploiement par un autre contrat. Ensuite, les annulations ne renvoient plus des chaînes de caractères mais des erreurs typées comme OwnableUnauthorizedAccount : elles sont moins coûteuses en gaz et plus précises à tester, mais elles cassent les anciens tests qui comparaient des messages texte. Enfin, la version 5 a retiré ou réorganisé plusieurs utilitaires hérités, si bien qu’un copier-coller d’un exemple ancien échoue souvent à la compilation.
La leçon pratique est simple : quand vous cherchez un exemple, vérifiez qu’il cible bien la version 5, et préférez toujours la documentation officielle à un article tiers non daté. C’est valable pour OpenZeppelin comme pour Solidity et Foundry, dont les évolutions sont fréquentes. Prendre l’habitude d’épingler des versions précises dans votre projet — un tag pour la bibliothèque, un pragma clair pour le compilateur — vous évite de voir un projet qui compilait hier refuser de compiler demain.
🐞 Pièges fréquents
| Symptôme / erreur | Cause probable | Correctif |
|---|---|---|
File @openzeppelin/... not found |
Remapping absent ou erroné | Ajouter la ligne dans remappings.txt, vérifier avec forge remappings |
No arguments passed to the base constructor |
Constructeur Ownable sans adresse (version 5) |
Passer Ownable(proprietaireInitial) |
| Solde affiché énorme dans le portefeuille | Décimales par défaut (18) non surchargées | Surcharger decimals() ou raisonner en unités complètes |
| Test d’accès qui échoue à la comparaison d’erreur | Mauvais selector ou argument | Utiliser OwnableUnauthorizedAccount.selector + adresse de l’appelant |
Installation sur master |
Branche de développement non stable | Épingler un tag publié, par exemple @v5.6.1 |
✅ Récapitulatif
Vous avez installé OpenZeppelin, configuré le remapping, et écrit un jeton ERC-20 conforme en héritant des contrats audités. Vous maîtrisez la nouveauté du constructeur Ownable de la version 5, la surcharge des décimales, l’émission contrôlée par onlyOwner et le test de l’erreur d’accès typée. Votre jeton se déploie comme n’importe quel contrat et est reconnu par tout outil compatible ERC-20.
🧾 Aide-mémoire
| Élément | Rôle |
|---|---|
forge install OpenZeppelin/openzeppelin-contracts@vX |
Installer la bibliothèque |
remappings.txt |
Résoudre les imports @openzeppelin/... |
ERC20("Nom", "SYM") |
Constructeur de la norme |
Ownable(adresse) |
Définir le propriétaire (version 5) |
_mint(to, montant) |
Émettre des unités |
onlyOwner |
Restreindre au propriétaire |
--constructor-args |
Passer les arguments au déploiement |
💪 À vous de jouer
Ajoutez une fonction bruler(uint256 points) qui permet à un détenteur de détruire ses propres unités (utile pour « dépenser » des points), puis testez qu’elle réduit bien le solde et l’offre totale. Indice : OpenZeppelin fournit une fonction interne _burn.
Voir une solution
function bruler(uint256 points) external {
_burn(msg.sender, points);
}
// Test
function test_BrulerReduitOffre() public {
jeton.crediter(client, 100);
vm.prank(client);
jeton.bruler(40);
assertEq(jeton.balanceOf(client), 60);
assertEq(jeton.totalSupply(), 60);
}
Tutoriels frères
- Créer une collection NFT ERC-721 avec OpenZeppelin — l’autre grande norme de jetons.
- Déployer un smart contract sur le testnet Sepolia — mettre ce jeton en ligne.
Pour aller plus loin
- 🔝 Retour au guide principal : Développer des smart contracts sur Ethereum
- Documentation ERC-20 OpenZeppelin : docs.openzeppelin.com/contracts/5.x/erc20
- Norme EIP-20 (officielle) : eips.ethereum.org/EIPS/eip-20
FAQ
Pourquoi hériter d’OpenZeppelin plutôt que tout écrire ?
Parce que leur code est audité, testé et utilisé par des milliers de projets. Réécrire la logique ERC-20 à la main expose à des erreurs subtiles sur les autorisations ou les soldes, là où l’héritage donne une base éprouvée.
À quoi servent les décimales ?
Elles indiquent en combien de sous-unités une unité se divise. 18 décimales conviennent à une monnaie divisible ; 0 décimale convient à des points entiers indivisibles, comme ici.
Le propriétaire peut-il émettre sans limite ?
Dans cet exemple, oui : crediter n’a pas de plafond. Pour une vraie application, on ajoute souvent une offre maximale ou une logique d’émission encadrée — un bon exercice d’extension.