ERC-20 : Écrivez votre premier token avec Solidity et Foundry

1. Vue d’ensemble

ERC-20 est la norme de token la plus populaire sur Ethereum. USDC, DAI, LINK et des milliers d’autres tokens la respectent. Dans cet article, vous écrirez vous-même un token ERC-20 minimal pour comprendre comment fonctionne la norme, puis vous écrirez des tests avec Foundry. Ensuite, vous passerez à l’implémentation d’OpenZeppelin pour un environnement de production et déploierez le token sur une blockchain locale.

2. Qu’est-ce qu’ERC-20 ?

ERC-20 est une interface, c’est-à-dire un ensemble de fonctions et d’événements que chaque token doit avoir. Grâce à cette interface commune, les portefeuilles, les échanges et les contrats DeFi peuvent interagir avec n’importe quel token de la même manière sans avoir besoin de savoir comment le token est écrit en interne.

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

interface IERC20 {
    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    function totalSupply() external view returns (uint256);
    function balanceOf(address account) external view returns (uint256);
    function transfer(address to, uint256 value) external returns (bool);
    function allowance(address owner, address spender) external view returns (uint256);
    function approve(address spender, uint256 value) external returns (bool);
    function transferFrom(address from, address to, uint256 value) external returns (bool);
}

On peut diviser cette interface en deux groupes :

  • Transfert direct : transfer transfère les tokens de l’appelant (msg.sender) à to.
  • Délégation : approve autorise un spender à dépenser jusqu’à value tokens de votre compte. Ensuite, le spender appelle transferFrom pour retirer. C’est le mécanisme qui permet à un DEX de “tirer” des tokens de votre portefeuille lors d’un swap.

Les trois fonctions name(), symbol() et decimals() font partie de l’extension metadata. Techniquement, elles ne sont pas obligatoires, mais presque tous les tokens les ont.

3. Préparer l’environnement avec Foundry

Cet article utilise Foundry : vous écrivez les contrats et les tests en Solidity, et les tests s’exécutent très rapidement. Après avoir installé Foundry, créez un projet :

forge init cafe-token
cd cafe-token
forge install OpenZeppelin/openzeppelin-contracts

La commande forge install télécharge la bibliothèque OpenZeppelin dans le répertoire lib/. Vous devez déclarer un remapping pour des imports plus concis. Ajoutez la ligne suivante au fichier remappings.txt dans le répertoire racine :

@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/

La structure du projet se compose de src/ pour les contrats, test/ pour les tests et script/ pour les scripts de déploiement. Vous pouvez supprimer les fichiers Counter d’exemple créés par forge init.

4. Écrire vous-même un ERC-20 minimal

L’objectif de cette section est de comprendre la norme ERC-20, pas pour l’utiliser en production. Créez le fichier src/SimpleToken.sol :

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

contract SimpleToken {
    string public name = "Cafe Token";
    string public symbol = "CAFE";
    uint8 public constant decimals = 18;
    uint256 public totalSupply;

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    constructor(uint256 initialSupply) {
        totalSupply = initialSupply;
        balanceOf[msg.sender] = initialSupply;
        emit Transfer(address(0), msg.sender, initialSupply);
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        _transfer(msg.sender, to, amount);
        return true;
    }

    function approve(address spender, uint256 amount) external returns (bool) {
        allowance[msg.sender][spender] = amount;
        emit Approval(msg.sender, spender, amount);
        return true;
    }

    function transferFrom(address from, address to, uint256 amount) external returns (bool) {
        uint256 allowed = allowance[from][msg.sender];
        require(allowed >= amount, "allocation insuffisante");
        allowance[from][msg.sender] = allowed - amount;
        _transfer(from, to, amount);
        return true;
    }

    function _transfer(address from, address to, uint256 amount) internal {
        require(to != address(0), "adresse zéro");
        require(balanceOf[from] >= amount, "solde insuffisant");
        balanceOf[from] -= amount;
        balanceOf[to] += amount;
        emit Transfer(from, to, amount);
    }
}

Points à noter :

  • Les variables d’état publiques génèrent automatiquement un getter. Déclarer mapping(...) public balanceOf génère la fonction balanceOf(address), correspondant à la signature dans l’interface. allowance fonctionne de la même façon.
  • decimals = 18. La blockchain n’a pas de nombres décimaux, donc les tokens stockent tous les soldes sous forme de nombres entiers. Quand un portefeuille affiche “1.5 CAFE”, la valeur réelle dans le contrat est 1.5 * 10^18.
  • Le constructeur émet l’événement Transfer depuis address(0). C’est une convention pour signaler que le token est créé (mint). Les explorateurs de blocs utilisent cet événement pour afficher l’historique des transactions.
  • transferFrom soustrait l’allowance avant le transfert. Si vous ne le faites pas, le spender peut retirer un nombre illimité de fois.
  • Pas besoin de SafeMath. À partir de Solidity 0.8, l’opération -= se revient automatiquement en cas de dépassement inférieur. Néanmoins, require avec un message clair aide toujours à déboguer plus facilement.

5. Écrire des tests avec Foundry

Les tests dans Foundry sont des contrats qui héritent de Test. Chaque fonction commençant par test est un cas de test. Créez le fichier test/SimpleToken.t.sol :

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

import {Test} from "forge-std/Test.sol";
import {SimpleToken} from "../src/SimpleToken.sol";

contract SimpleTokenTest is Test {
    event Transfer(address indexed from, address indexed to, uint256 value);

    SimpleToken token;
    address alice = makeAddr("alice");
    address bob = makeAddr("bob");

    function setUp() public {
        token = new SimpleToken(1_000 ether);
    }

    function test_transfer_movesBalance() public {
        token.transfer(alice, 100 ether);

        assertEq(token.balanceOf(alice), 100 ether);
        assertEq(token.balanceOf(address(this)), 900 ether);
    }

    function test_transfer_emitsEvent() public {
        vm.expectEmit(true, true, false, true);
        emit Transfer(address(this), alice, 1 ether);

        token.transfer(alice, 1 ether);
    }

    function test_transferFrom_requiresAllowance() public {
        token.transfer(alice, 100 ether);

        vm.prank(alice);
        token.approve(bob, 30 ether);

        vm.prank(bob);
        token.transferFrom(alice, bob, 30 ether);

        assertEq(token.balanceOf(bob), 30 ether);
        assertEq(token.allowance(alice, bob), 0);

        vm.prank(bob);
        vm.expectRevert(bytes("allocation insuffisante"));
        token.transferFrom(alice, bob, 1);
    }
}

Explication de chaque partie :

  • setUp() s’exécute avant chaque test. Le contrat de test est celui qui déploie le token, donc address(this) détient tous les 1000 tokens.
  • 1_000 ether est simplement une façon raccourcie d’écrire 1000 * 10^18. Le mot-clé ether ici multiplie simplement par 1e18, sans rapport avec l’ETH réel.
  • makeAddr("alice") crée une adresse étiquetée, afin que les logs lors d’un échec de test soient plus faciles à lire.
  • vm.prank(alice) rend l’appel suivant avec msg.sender défini à alice.
  • vm.expectEmit(true, true, false, true) vérifie le topic 1 (from), le topic 2 (to) et la partie data (value) de l’événement émis juste après.
  • vm.expectRevert affirme que l’appel suivant doit revert avec exactement le message indiqué.

Exécutez les tests :

forge test -vv
Ran 3 tests for test/SimpleToken.t.sol:SimpleTokenTest
[PASS] test_transferFrom_requiresAllowance() (gas: 79120)
[PASS] test_transfer_emitsEvent() (gas: 41871)
[PASS] test_transfer_movesBalance() (gas: 43552)
Suite result: ok. 3 passed; 0 failed; 0 skipped

La valeur du gas sur votre machine peut légèrement différer selon la version du compilateur.

6. Utiliser OpenZeppelin pour un token réel

L’implémentation que vous avez écrite ci-dessus fonctionne, mais n’a pas été auditée. Pour un token réel, vous devriez hériter de ERC20 d’OpenZeppelin. Cette bibliothèque a été vérifiée sur des milliards de dollars de valeur en circulation. Créez le fichier src/CafeToken.sol :

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

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract CafeToken is ERC20, Ownable {
    constructor(address initialOwner)
        ERC20("Cafe Token", "CAFE")
        Ownable(initialOwner)
    {
        _mint(initialOwner, 1_000_000 * 10 ** decimals());
    }

    function mint(address to, uint256 amount) external onlyOwner {
        _mint(to, amount);
    }
}

Avec seulement une dizaine de lignes, vous avez un token complet :

  • ERC20("Cafe Token", "CAFE") définit name et symbol. Par défaut, decimals() retourne 18.
  • _mint augmente totalSupply, ajoute le solde au destinataire et émet l’événement Transfer depuis address(0).
  • Ownable(initialOwner) (OpenZeppelin v5) nécessite de passer explicitement le owner. Le modificateur onlyOwner empêche les autres d’appeler mint.

OpenZeppelin v5 utilise les erreurs personnalisées au lieu des chaînes de revert. Par conséquent, les tests doivent également vérifier selon le sélecteur d’erreur. Créez le fichier test/CafeToken.t.sol :

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

import {Test} from "forge-std/Test.sol";
import {CafeToken} from "../src/CafeToken.sol";
import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract CafeTokenTest is Test {
    CafeToken token;
    address owner = makeAddr("owner");
    address alice = makeAddr("alice");

    function setUp() public {
        token = new CafeToken(owner);
    }

    function test_initialSupply_goesToOwner() public view {
        assertEq(token.totalSupply(), 1_000_000e18);
        assertEq(token.balanceOf(owner), 1_000_000e18);
    }

    function test_transfer_revertsWhenBalanceTooLow() public {
        vm.prank(alice);
        vm.expectRevert(
            abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, alice, 0, 1)
        );
        token.transfer(owner, 1);
    }

    function test_mint_onlyOwner() public {
        vm.prank(alice);
        vm.expectRevert(
            abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, alice)
        );
        token.mint(alice, 1e18);
    }
}

Lorsque transfer revient, ERC20InsufficientBalance(sender, balance, needed) vous indique exactement qui manque et de combien. Cette information est beaucoup plus utile qu’une chaîne générique.

7. Déployer sur un réseau local et interagir

Foundry est livré avec anvil, un nœud Ethereum s’exécutant localement avec 10 comptes pré-remplis d’ETH. Ouvrez un terminal séparé et exécutez :

anvil

Dans un autre terminal, déployez le token en utilisant le premier compte d’anvil. Cette clé privée est publique, utilisée uniquement sur le réseau local :

export PK=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
export OWNER=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266

forge create src/CafeToken.sol:CafeToken \
  --rpc-url http://127.0.0.1:8545 \
  --private-key $PK \
  --broadcast \
  --constructor-args $OWNER
Deployer: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Transaction hash: 0x...

Utilisez cast pour lire le solde et transférer le token :

export TOKEN=0x5FbDB2315678afecb367f032d93F642f64180aa3

cast call $TOKEN "balanceOf(address)(uint256)" $OWNER --rpc-url http://127.0.0.1:8545

cast send $TOKEN "transfer(address,uint256)" \
  0x70997970C51812dc3A010C7d01b50e0d17dc79C8 50ether \
  --private-key $PK --rpc-url http://127.0.0.1:8545
1000000000000000000000000 [1e24]

cast call ne lit que les données, il n’y a donc pas de coût en gas. cast send crée une transaction réelle sur anvil. L’argument 50ether est automatiquement converti par cast en 50 * 10^18.

8. Erreurs courantes et points d’attention

Attention : N’écrivez pas vous-même d’ERC-20 pour la production. L’implémentation à la section 4 est uniquement à titre éducatif. Pour un token réel, héritez d’OpenZeppelin et ajoutez uniquement votre logique métier.

  • Confondre les decimals. Appeler transfer(to, 100) ne transfère que 100 * 10^-18 tokens, ce qui est pratiquement zéro. Multipliez toujours par 10 ** decimals(). D’autre part, USDC utilise 6 décimales et non 18, donc ne codez pas en dur 1e18 quand vous travaillez avec les tokens d’autres personnes.
  • Condition de course sur approve. Supposons que vous changiez l’allowance de 100 à 50. Un dépensier malveillant peut surveiller le mempool, retirer 100 avant que la nouvelle commande soit confirmée, puis retirer 50 supplémentaires. Le moyen sûr est de d’abord définir l’allowance à 0, ou utiliser des tokens qui supportent permit (ERC-2612).
  • Tokens non-conformes. Certains anciens tokens comme USDT ne retournent pas de bool depuis transfer. Lorsque votre contrat appelle un token externe, utilisez la bibliothèque SafeERC20 (using SafeERC20 for IERC20; puis appelez safeTransfer au lieu d’appeler transfer directement).
  • Les tokens envoyés au contrat restent bloqués. ERC-20 n’a pas de mécanisme de notification au destinataire. Si quelqu’un envoie des tokens à un contrat qui n’a pas de fonction de retrait, ces tokens resteront bloqués pour toujours.
  • Tokens avec frais et tokens rebasing. Il existe des tokens où la quantité reçue est inférieure à la quantité envoyée, ou où le solde change automatiquement au fil du temps. Si votre contrat reçoit des tokens arbitraires, mesurez le solde avant et après le transfert, au lieu de faire confiance au paramètre amount.
  • mint sans limite. Une fonction mint avec onlyOwner signifie que le propriétaire peut créer un nombre illimité de tokens. Si vous voulez un approvisionnement total fixe, supprimez cette fonction ou imposez un plafond, par exemple en utilisant ERC20Capped.

9. Conclusion

Dans cet article, vous avez étudié l’interface ERC-20 et implémenté vous-même un token minimal pour voir clairement comment balanceOf, allowance, transfer et transferFrom fonctionnent ensemble. Vous avez également écrit des tests avec Foundry avec vm.prank, vm.expectEmit et vm.expectRevert. Ensuite, vous avez basculé vers CafeToken basé sur OpenZeppelin, déployé sur anvil et interagi via cast.

L’étape suivante logique est d’essayer des extensions comme ERC20Permit pour approuver par signature, ERC20Burnable pour brûler les tokens, ou ERC20Capped pour limiter l’approvisionnement total. Vous devriez également vous entraîner à écrire des contrats qui reçoivent les tokens de manière sécurisée avec SafeERC20.

solidityerc-20ethereumfoundrysmart-contract