Fintech et banque

Smart contracts Ethereum : EVM, Solidity, standards et sécurité

Avancé11 min de lectureMis à jour

Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise

Un smart contract est un petit programme exécuté sur un ordinateur public, que n'importe qui peut appeler, qui détient une valeur réelle et que l'on peut rarement corriger. Ce guide couvre le modèle d'exécution, un contrat lu ligne par ligne, les standards de tokens, l'outillage, l'intégration côté backend et les classes de vulnérabilités à l'origine de la plupart des pertes.

Ce que vous allez apprendre

  • Expliquer la différence entre comptes externes et comptes de contrat, ainsi que les transactions, le gas et le coût du stockage
  • Lire un contrat Solidity et raisonner sur son comportement, y compris les events, les modifiers et la visibilité
  • Implémenter et consommer correctement les interfaces ERC-20 et ERC-721
  • Appeler des contrats depuis un service backend en gérant les réorganisations et les nonces
  • Reconnaître et atténuer les vulnérabilités de réentrance, de contrôle d'accès, d'oracle et d'ordonnancement

Écrire un smart contract n'a presque pas d'équivalent dans le développement logiciel. Le programme tourne sur une machine que vous n'administrez pas, son bytecode est public, n'importe qui sur la planète peut appeler n'importe quelle fonction que vous avez exposée, l'état qu'il détient vaut souvent de l'argent et, une fois déployé, vous ne pouvez généralement plus le corriger. Il en résulte une discipline plus proche de l'écriture d'un firmware d'avionique que d'un service web : du code concis, explicite, testé de façon exhaustive et méfiant devant le moindre appel externe.

Ce guide suppose acquis le fonctionnement du registre décrit dans les fondamentaux de la blockchain et passe à la couche programmable : comment Ethereum exécute le code, en quoi Solidity se compile réellement, quels standards implémenter pour être interopérable, comment un backend classique dialogue avec une chaîne, et les classes de vulnérabilités responsables de l'essentiel de l'argent perdu à ce jour.

Comptes, transactions et gas

Ethereum possède deux types de comptes qui partagent le même espace d'adresses. Un compte externe (externally owned account) est contrôlé par une clé privée, possède un solde et un nonce, et c'est la seule entité capable de lancer une transaction. Un compte de contrat contient du code et du stockage, et n'agit que lorsqu'on l'appelle. L'adresse d'un contrat déployé est dérivée de façon déterministe du déployeur et de son nonce, ce qui explique qu'on puisse prédire les adresses de déploiement.

Une transaction contient un émetteur, un destinataire, une valeur, des calldata, un nonce, une limite de gas et des paramètres de frais. Le nonce est un compteur strictement croissant par émetteur qui empêche le rejeu et impose l'ordre : une transaction de nonce 8 ne peut pas s'exécuter avant celle de nonce 7, et un nonce bloqué bloque tout ce qui le suit — ce que tout backend qui soumet des transactions finira par découvrir au pire moment.

L'exécution a lieu dans l'Ethereum Virtual Machine, une machine à pile avec des mots de 256 bits. Chaque opcode a un coût en gas, et l'émetteur paie ce qui s'exécute. Le gas a deux fonctions : il fixe le prix des ressources rares du réseau, et il borne l'exécution, si bien qu'une boucle infinie s'arrête simplement à court de gas au lieu de paralyser la chaîne. Les frais se composent d'un base fee fixé par le protocole, qui est brûlé, et d'un pourboire de priorité versé au proposeur du bloc, l'émetteur déclarant le maximum qu'il accepte de payer.

Approximate relative costs (EVM, order-of-magnitude guidance)

  arithmetic / comparison        ~3 gas        cheap, ignore it
  memory expansion               grows quadratically past a point
  keccak256 hashing              ~30 + 6 per word
  reading a cold storage slot    ~2,100 gas
  writing a new non-zero slot    ~20,000 gas   <- dominates everything
  updating an existing slot      ~5,000 gas
  emitting an event (per topic)  ~375 gas      <- cheap history
  external call                  ~2,600 gas + whatever it does

Rule of thumb: storage is permanent obligation for every node,
so it is priced accordingly. Compute is nearly free by comparison.

Trois conséquences en découlent immédiatement. Gardez l'état persistant au strict minimum et regroupez les petites valeurs liées dans un même slot. Utilisez des events plutôt que des tableaux en stockage pour conserver un historique dont seuls les lecteurs hors chaîne ont besoin. Et considérez toute boucle non bornée sur un tableau qui grandit comme un futur déni de service : une fonction qui finit par dépasser la limite de gas du bloc devient définitivement impossible à appeler.

Si une transaction fait un revert, toutes les modifications d'état sont annulées, mais le gas consommé reste dû. L'atomicité est gratuite ; les frais, eux, ne le sont pas.

Un contrat, ligne par ligne

Solidity est un langage à typage statique, orienté contrats. Les extraits ci-dessous utilisent la coloration syntaxique JavaScript, puisque Solidity en partage la syntaxe à accolades.

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

/// A minimal escrow: a payer locks funds for a payee, released by an arbiter.
contract Escrow {
    address public immutable payer;
    address public immutable payee;
    address public immutable arbiter;
    uint256 public immutable deadline;
    bool    private released;

    event Funded(address indexed from, uint256 amount);
    event Released(address indexed to, uint256 amount);
    event Refunded(address indexed to, uint256 amount);

    error NotAuthorised();
    error AlreadySettled();
    error TooEarly(uint256 deadlineAt);

    modifier onlyArbiter() {
        if (msg.sender != arbiter) revert NotAuthorised();
        _;
    }

    constructor(address _payee, address _arbiter, uint256 lockSeconds) payable {
        payer    = msg.sender;
        payee    = _payee;
        arbiter  = _arbiter;
        deadline = block.timestamp + lockSeconds;
        emit Funded(msg.sender, msg.value);
    }

    function release() external onlyArbiter {
        if (released) revert AlreadySettled();
        released = true;                       // effects before interaction
        uint256 amount = address(this).balance;
        emit Released(payee, amount);
        (bool ok, ) = payee.call{value: amount}("");
        require(ok, "transfer failed");
    }

    function refund() external {
        if (msg.sender != payer) revert NotAuthorised();
        if (released) revert AlreadySettled();
        if (block.timestamp < deadline) revert TooEarly(deadline);
        released = true;
        uint256 amount = address(this).balance;
        emit Refunded(payer, amount);
        (bool ok, ) = payer.call{value: amount}("");
        require(ok, "transfer failed");
    }
}

Lisons-le dans l'ordre. Le pragma fixe une plage de versions du compilateur ; en production, mieux vaut épingler une version exacte qu'utiliser un caret. Les variables immutable sont figées à la construction et stockées dans le code plutôt que dans le stockage, ce qui rend leur lecture peu coûteuse. public sur une variable d'état génère un getter ; rien d'autre dans public n'implique une quelconque sécurité, car tout l'état de la chaîne est lisible quels que soient les mots-clés de visibilité — un contrat n'a aucun secret.

Les déclarations event définissent les entrées de log que consomment les indexeurs ; les paramètres indexed deviennent des topics interrogeables, dans la limite de trois. Les types error personnalisés coûtent moins cher que les messages de revert et sont bien plus faciles à traiter par programme. Le modifier factorise un contrôle d'autorisation, _; marquant l'endroit où s'exécute le corps de la fonction.

msg.sender est l'appelant immédiat et msg.value l'ether joint à l'appel. La ligne la plus importante du fichier est released = true;, placée avant l'appel sortant — c'est l'ordre checks-effects-interactions détaillé plus bas. Notez aussi que payee.call{value: amount}("") est la façon actuelle d'envoyer de l'ether, car les anciens helpers transmettaient une allocation de gas fixe qui échoue face à des destinataires qui sont des contrats, et que son résultat booléen doit être vérifié : un appel bas niveau qui échoue ne provoque pas de revert automatiquement.

Standards de tokens

Sur Ethereum, l'interopérabilité repose sur un petit ensemble d'interfaces applicatives publiées sous forme d'ERC. Deux d'entre elles dominent.

ERC-20 décrit les tokens fongibles : soldes, transferts et allowance de dépense déléguée.

interface IERC20 {
    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);

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

C'est le mécanisme d'allowance qui explique pourquoi chaque application décentralisée vous demande d'« approuver » avant de pouvoir déplacer vos tokens : le contrat appelle transferFrom dans la limite de l'allowance que vous avez accordée. Deux pièges de longue date se cachent ici. Une approbation illimitée signifie qu'un bug ultérieur dans le contrat approuvé peut vider le solde : préférez des montants exacts ou des approbations signées de type permit. Et decimals est une métadonnée, pas une règle du protocole — il existe des tokens à 6, 8 ou 18 décimales, et tout code qui suppose 18 en dur finira par se tromper d'échelle d'un facteur mille milliards.

ERC-721 décrit les tokens non fongibles, où chaque identifiant est unique et possédé individuellement, avec ownerOf, safeTransferFrom et un tokenURI pointant vers les métadonnées. Les variantes safe appellent un hook de réception sur les destinataires qui sont des contrats, afin de ne pas envoyer de tokens à un contrat incapable de les gérer — un hook qui est lui-même un appel externe, et donc une surface de réentrance. ERC-1155 combine les deux modèles pour les contrats multi-tokens.

Utilisez des implémentations de référence auditées pour ces interfaces plutôt que d'écrire les vôtres. Un contrat de token est un problème résolu, et la version résolue a été relue par plus de personnes que votre équipe ne pourra jamais en mobiliser.

Outillage et workflow

La boucle de développement moderne ressemble à ceci.

  1. Choisir un framework

    Foundry exécute des tests écrits en Solidity même, ce qui garde le test et le contrat dans un seul modèle mental et place le fuzzing et les tests d'invariants au premier plan. Hardhat repose sur JavaScript ou TypeScript et s'intègre naturellement à une base de code Node.js. Beaucoup d'équipes utilisent les deux.
  2. Écrire les tests avant le contrat

    Les tests basés sur les propriétés et les tests d'invariants conviennent particulièrement bien à ce domaine : affirmez que l'offre totale est toujours égale à la somme des soldes, qu'aucun chemin ne permet à un appelant de retirer plus qu'il n'a déposé, et laissez le fuzzer chercher le contre-exemple. La discipline décrite dans le développement piloté par les tests s'applique telle quelle.
  3. Lancer l'analyse statique et la couverture en CI

    Linters, analyseur de type Slither et snapshots de gas doivent tourner sur chaque pull request, exactement comme dans n'importe quel pipeline d'intégration continue. Faites échouer le build sur les nouveaux constats, pas sur le passif historique.
  4. Déployer sur un testnet

    Les réseaux de test reproduisent la mécanique du mainnet avec de l'ether sans valeur obtenu via un faucet. Déployez le vrai script, pas une version simplifiée, pour que le déploiement lui-même soit testé.
  5. Vérifier le code source

    Publier le code source et les paramètres du compilateur sur un explorateur de blocs permet à chacun de reproduire le bytecode. Un contrat non vérifié qui détient des fonds d'utilisateurs doit être considéré par défaut comme non digne de confiance.
  6. Auditer avant que la valeur n'arrive

    L'effort de revue externe est proportionnel à la valeur en jeu. Livrez le code, puis un bug bounty, puis l'argent — dans cet ordre.

Dialoguer avec une chaîne depuis un backend

La plupart des produits réels sont des services ordinaires qui lisent et écrivent sur une chaîne via JSON-RPC, à l'aide d'une bibliothèque comme ethers.js, viem ou web3j.

import { JsonRpcProvider, Contract, Wallet } from "ethers";

const provider = new JsonRpcProvider(process.env.RPC_URL);
const signer   = new Wallet(process.env.SIGNER_KEY, provider);   // use a KMS/HSM signer in production
const token    = new Contract(TOKEN_ADDRESS, IERC20_ABI, signer);

// Read: free, served by the node, no transaction.
const balance = await token.balanceOf(userAddress);

// Write: costs gas, returns once mined, may still be reorganised.
const tx = await token.transfer(recipient, 1_000_000n, { gasLimit: 120_000n });
const receipt = await tx.wait(5);            // wait for 5 confirmations
if (receipt.status !== 1) throw new Error("transaction reverted");

Exploiter cela en production impose de respecter quelques réalités. La gestion des nonces doit être centralisée : deux workers qui signent avec la même clé entreront en collision, alors sérialisez les soumissions par clé et implémentez le remplacement par frais plus élevés (replace-by-fee) pour les transactions bloquées. Ne gardez jamais une clé privée en mémoire applicative ni dans une variable d'environnement si vous pouvez l'éviter — utilisez un module matériel ou un service de signature managé, avec des plafonds de dépense et des clés distinctes par environnement. Estimez le gas, mais plafonnez-le, car une estimation faite sur un état périmé peut être complètement fausse. Considérez l'endpoint RPC comme peu fiable : faites tourner au moins deux fournisseurs et comparez les hauteurs de bloc. Enfin, indexez les events avec un curseur persisté, en attendant une marge de confirmations et en prenant le hash de transaction et l'index du log comme clé des handlers, pour que les rejeux restent sans effet.

Mise à jour des contrats

Le bytecode déployé ne peut pas changer : les systèmes évolutifs placent donc un proxy devant lui. Le proxy détient le stockage et l'adresse d'un contrat d'implémentation, et transmet chaque appel via delegatecall, qui exécute le code de l'implémentation sur le stockage du proxy. Remplacer l'adresse d'implémentation change le comportement tout en conservant l'état et l'adresse.

La discipline que cela exige ne pardonne rien. L'agencement du stockage doit rester purement additif d'une version à l'autre, car l'implémentation lit les slots par position ; insérer une variable au milieu corrompt silencieusement tout ce qui suit. Les constructeurs ne s'exécutent pas pour l'implémentation : l'initialisation passe donc dans une fonction initialize protégée contre un second appel. Les sélecteurs de fonction ne doivent pas entrer en collision entre le proxy et l'implémentation. Et la clé de mise à jour devient le secret le plus précieux du système, c'est pourquoi les projets sérieux la placent derrière un portefeuille multisignature et un timelock, afin que les utilisateurs voient venir un changement et puissent sortir avant.

La possibilité de mise à jour affaiblit aussi la promesse fondamentale : un contrat remplaçable n'est digne de confiance que dans la mesure où ses administrateurs le sont. Certains protocoles déploient donc des contrats immuables et gèrent le changement par migration — de nouveaux contrats, vers lesquels les utilisateurs basculent. C'est plus honnête, et nettement plus de travail.

Les classes de vulnérabilités qui comptent

La plupart des pertes se concentrent sur une poignée de schémas.

Réentrance. Un appel externe cède le contrôle à un autre contrat, qui rappelle le vôtre avant que la première invocation ait fini de mettre à jour l'état — une fonction de retrait qui envoie l'ether et remet ensuite le solde à zéro peut ainsi être vidée en boucle. La correction est structurelle : checks, effects, interactions — valider les entrées, mettre à jour votre état, et seulement ensuite appeler l'extérieur. Un reentrancy guard ajoute une défense en profondeur ; notez que les hooks de réception ERC-721 et les callbacks de transfert de tokens sont des appels externes, même quand ils n'en ont pas l'air.

Contrôle d'accès. Autorisation absente ou erronée sur des fonctions privilégiées, initialiseurs restés appelables, tx.origin utilisé pour l'authentification (il identifie l'émetteur d'origine de la transaction, si bien que n'importe quel contrat appelé par l'utilisateur peut se faire passer pour lui). Utilisez msg.sender avec un contrôle de rôle explicite, et recensez chaque point d'entrée privilégié lors de la revue.

Arithmétique et conversions. Depuis Solidity 0.8, un dépassement de capacité provoque un revert par défaut, mais les blocs unchecked explicites et les downcasts comme uint256 vers uint128 tronquent toujours silencieusement. La division entière tronque systématiquement, donc l'ordre compte : multipliez avant de diviser, et choisissez délibérément de quel côté de l'erreur d'arrondi se place le protocole.

Manipulation d'oracle. Un contrat qui lit un prix dans un pool on-chain peut être attaqué en faisant bouger ce pool au sein d'une seule transaction, souvent financée par un flash loan. Utilisez des moyennes pondérées dans le temps ou un réseau d'oracles décentralisé avec plusieurs rapporteurs indépendants, et ne faites jamais confiance au prix spot d'un pool peu liquide.

Ordonnancement des transactions et front-running. Les transactions en attente sont publiques avant leur inclusion, et les proposeurs les ordonnent pour en tirer profit. Une opération sans limite de slippage peut subir une attaque sandwich ; un engagement révélé trop tôt peut être copié. Parmi les parades : les schémas commit-reveal, des paramètres explicites de slippage et d'échéance, et des canaux de soumission privés.

Déni de service par dépendance externe. Pousser les fonds vers une liste de destinataires échoue intégralement si un seul d'entre eux fait un revert. Préférez un schéma de pull payments, où chaque partie retire elle-même son solde.

Gestion des signatures. Rejeu d'une chaîne ou d'un contrat à l'autre quand le domain separator est omis, signatures malléables, nonces manquants. Suivez le standard de données structurées typées et incluez un chain id, une adresse de contrat et un nonce dans tout ce que vous signez.

La réflexion générale sur la sécurité présentée dans l'architecture de sécurité applicative s'applique toujours — moindre privilège, défense en profondeur, frontières de confiance explicites — à ceci près qu'ici l'attaquant est financé, automatisé, et lit votre code source avant même que vous ayez fini de le déployer.

Points clés à retenir

  • Seuls les comptes contrôlés par une clé lancent des transactions ; les contrats réagissent, et chaque opération est mesurée en gas, le stockage coûtant bien plus cher que le calcul.
  • Lisez Solidity en gardant la frontière de confiance à l'esprit : tout l'état est public, les appels externes cèdent le contrôle, et les appels bas niveau ne font pas de revert en cas d'échec.
  • Implémentez ERC-20 et ERC-721 à partir de références auditées, et ne supposez jamais 18 décimales ni n'accordez d'allowance illimitée par défaut.
  • Testez avec du fuzzing et des invariants, faites de l'analyse statique en CI, vérifiez le code source publié et faites auditer avant que le contrat ne détienne une valeur réelle.
  • Les backends ont besoin d'une gestion centralisée des nonces, d'une signature adossée à du matériel, de fournisseurs RPC redondants et d'une indexation idempotente des events avec une marge de confirmations.
  • La mise à jour par proxy exige un agencement de stockage extensible uniquement par ajout et reporte la confiance sur le détenteur de la clé de mise à jour — un timelock et une multisignature sont le strict minimum.
  • La réentrance, les défauts de contrôle d'accès, les conversions silencieuses, les oracles manipulables et l'ordonnancement des transactions expliquent la plupart des exploits ; checks-effects-interactions et les pull payments en évitent une large part.

Sources et lectures complémentaires

Questions fréquentes

Peut-on modifier un contrat déjà déployé ?
Pas directement : le bytecode déployé est immuable. Les équipes qui ont besoin de pouvoir le faire évoluer déploient un proxy qui détient l'état et délègue l'exécution à une adresse d'implémentation remplaçable. On gagne ainsi la possibilité de mettre à jour, au prix d'un mécanisme de gouvernance, d'une discipline stricte sur l'agencement du stockage et d'une hypothèse de confiance supplémentaire, puisque celui qui contrôle la mise à jour contrôle de fait les fonds.
Pourquoi le stockage coûte-t-il si cher par rapport au calcul ?
Chaque slot de stockage doit être conservé par chaque nœud pour toujours : son coût reflète donc une obligation permanente imposée à tout le réseau, et non un calcul ponctuel. Écrire un nouveau slot non nul coûte bien plus que l'arithmétique ou la mémoire, libérer un slot donne droit à un remboursement, et les optimisations classiques consistent à regrouper les variables, à utiliser des events pour l'historique et à garder les données volumineuses hors chaîne.
SafeMath est-il encore nécessaire avec un Solidity récent ?
Non. Depuis la version 0.8.0, l'arithmétique provoque un revert par défaut en cas de dépassement de capacité (overflow ou underflow), ce qui rend la bibliothèque superflue. Lorsque vous avez prouvé une borne et voulez économiser du gas, un bloc unchecked explicite documente ce choix. Les conversions entre largeurs d'entiers tronquent toujours silencieusement : les downcasts restent donc un point à examiner de près.
Comment un service backend peut-il suivre les events on-chain de façon fiable ?
Interrogez ou abonnez-vous aux logs, mais persistez un curseur du dernier bloc traité et attendez une marge de sécurité en confirmations avant d'agir, car les blocs récents peuvent être réorganisés. Rendez les handlers idempotents en utilisant le hash de transaction et l'index du log comme clé, et réconciliez régulièrement en relisant une fenêtre de blocs plutôt que de vous fier uniquement à un flux en temps réel.