Aller au contenu
Blockchain Association of Cameroon
BAC-L1-FOUND-1204 h

Contrats intelligents : fondamentaux et limites

Solidity comme point de départ, pas comme horizon : ce qu'un contrat sait faire, ce qu'il ne saura jamais faire, et pourquoi la suite du curriculum descend d'un cran.

  • Écrire, tester et déployer un contrat sur un réseau de test
  • Identifier réentrance, dépassement et contrôle d'accès défaillant
  • Énoncer ce qui relève du contrat et ce qui relève du protocole

Un contrat intelligent est un programme que tout le réseau exécute à l'identique et que personne ne peut corriger en cachette. Cet atelier vous apprend ce qu'un contrat sait faire, et surtout ce qu'il ne saura jamais faire : l'immutabilité, le déterminisme, le gaz, la réentrance, le problème de l'oracle. Soyons clairs d'emblée : ce cours enseigne d'abord les concepts et les limites, avec un pseudo-code neutre. La dernière leçon montre ensuite le même registre en Solidity, avec ses tests Foundry et les commandes de déploiement sur un réseau de test, que vous pratiquerez dans le laboratoire autonome (la documentation officielle reste la référence). Le navigateur ne peut ni compiler ni déployer : les exercices en ligne sont des lectures et des diagnostics. Vous réutilisez ce que vous savez des transactions, des frais, de la signature et des états d'interface.

Leçons 1/4 · 20 min

Ce qu'est un contrat : code, état, déterminisme, gaz

Un cas concret

Une coopérative de cacao à Bafoussam veut un registre des lots dont les règles ne puissent pas être modifiées en secret par une seule personne : qui peut enregistrer un lot, qu'un numéro de lot ne soit jamais utilisé deux fois. Un contrat intelligent (smart contract) est un programme, avec ses données, stocké à une adresse sur une chaîne dotée d'une machine virtuelle programmable (celles de type Ethereum, par exemple). Les utilisateurs l'appellent en envoyant des transactions ; chaque nœud exécute l'appel et doit arriver au même nouvel état.

Précision de départ : le langage le plus répandu pour ces chaînes est Solidity, mais ce cours n'en enseigne pas la syntaxe. Les exemples ci-dessous sont en pseudo-code, lisible sans connaître aucun langage.

contract LotRegistry:
  storage owner                     // fixé à la création
  storage lots: map lotId -> (holder, weightKg)

  function register(lotId, weightKg):
    require caller == owner         // contrôle d'accès
    require lots[lotId] does not exist
    lots[lotId] = (caller, weightKg)
    emit Registered(lotId)

require signifie : si la condition est fausse, tout l'appel est annulé et l'état reste comme avant. caller est l'adresse qui a signé la transaction (ou le contrat qui l'appelle). Vous avez vu au sujet des transactions que cette signature est vérifiée par le protocole ; le contrat, lui, décide ce qu'elle autorise.

Quatre propriétés qui définissent le métier

1. Déterminisme. Tous les nœuds doivent obtenir le même résultat à partir du même état et de la même transaction. Donc un contrat ne peut pas tirer un nombre au hasard « vraiment aléatoire », ni appeler une page web (deux nœuds interrogeraient à des instants différents et obtiendraient des réponses différentes). L'horodatage d'un bloc existe, mais c'est une valeur fixée par le producteur du bloc dans des bornes, pas une horloge de confiance.

2. Gaz. Chaque opération coûte un nombre d'unités de gaz, et l'exécution est mesurée : l'émetteur fixe une limite de gaz et un prix par unité, et les frais payés sont le gaz consommé × le prix (comme dans l'atelier sur les frais). Si la limite est atteinte avant la fin, l'exécution est annulée, mais le travail effectué reste payé. Cela empêche les boucles infinies de bloquer le réseau.

Exemple avec un barème fictif : écrire une nouvelle case de stockage coûte 20 000 unités, lire une case 200, et chaque transaction a un coût de base de 21 000. L'appel register lit deux cases (owner et l'existence du lot) et écrit une case : 21 000 + 200 + 200 + 20 000 = 41 400 unités (dans ce barème fictif, le couple (holder, weightKg) tient dans une seule case et l'événement emit est ignoré ; avec deux écritures séparées, le total serait de 61 400). Avec une limite de 40 000, l'appel manque de gaz : tout est annulé, et les 40 000 unités sont payées quand même. Avec une limite de 45 000, il passe et seul le gaz réellement consommé (41 400) est facturé. Les vrais barèmes diffèrent ; l'idée reste.

Conséquence de conception : une fonction qui parcourt une liste qui grandit avec les utilisateurs finit par dépasser la limite de gaz d'un bloc et devient inutilisable. On évite les boucles non bornées.

3. Publicité. L'état d'un contrat est lisible par tous. Les mots-clés « privé » d'un langage empêchent seulement les autres contrats d'y accéder simplement ; quiconque lit le stockage voit les valeurs. Pas de secret dans un contrat.

4. Immutabilité. Une fois déployé, le code d'un contrat ne se modifie pas dans le schéma courant. Un bogue y est permanent. On peut prévoir un contrat « mandataire » (proxy) qui redirige vers une nouvelle version, mais alors celui qui détient le droit de changer de version détient un pouvoir énorme, et vos utilisateurs doivent lui faire confiance. Immutable est un avantage (les règles ne bougent pas) et un risque (les erreurs aussi).

Ce que cela ne garantit PAS

  • Le contrat garantit que ses règles sont appliquées, pas que les faits sont vrais : si le propriétaire enregistre « 500 kg » pour un lot qui en pèse 50, la chaîne conserve fidèlement un mensonge.
  • Un contrat ne s'exécute jamais seul : il faut qu'une transaction l'appelle. Il ne peut pas « agir à minuit ».
  • Déployé ne veut pas dire correct.

À retenir : code et état à une adresse, résultat déterministe, exécution mesurée en gaz, état public, code figé.

Leçons 2/4 · 25 min

Trois failles classiques : accès, dépassement, réentrance

Pourquoi des failles précises

Comme le code est public, immuable et manipule de la valeur, un défaut est trouvé et exploité par des inconnus, souvent en quelques heures, et sans correctif possible après coup. Trois familles reviennent sans cesse (voir aussi le Top 10 OWASP des contrats intelligents).

1. Contrôle d'accès défaillant

Chaque fonction qui modifie l'état doit répondre à la question : qui a le droit de l'appeler ? Exemple fautif :

function setOwner(newOwner):
  owner = newOwner          // aucune vérification de caller

N'importe qui devient propriétaire. La correction tient en une ligne (require caller == owner), mais il faut penser à la mettre. Règle : refuser par défaut, autoriser explicitement, et tester chaque fonction sensible depuis un compte non autorisé.

2. Dépassement d'entiers

Les entiers d'un contrat ont une taille fixe. Illustration sur 8 bits (valeurs de 0 à 255) :

  • 250 + 10 = 260, qui ne tient pas : le résultat « boucle » à 260 − 256 = 4.
  • 3 − 5 = −2, impossible sans signe : le résultat devient 256 − 2 = 254.

Un solde de 3 qui dépense 5 et se retrouve à 254 est une catastrophe. Dans les contrats réels, les entiers sont bien plus grands (256 bits sur les machines de type Ethereum), mais le principe est le même. En Solidity, depuis la version 0.8, l'arithmétique annule l'appel en cas de dépassement par défaut ; avant, elle bouclait en silence, et on peut aujourd'hui désactiver la vérification dans un bloc marqué unchecked. Le dépassement reste donc possible dans du code ancien ou volontairement non vérifié.

3. Réentrance

C'est la faille la plus célèbre, à comprendre comme un concept. Un contrat qui envoie de la valeur à une adresse qui est elle-même un contrat lui passe la main : le code de l'adresse destinataire s'exécute avant que la fonction de départ ait fini. Si ce code rappelle la même fonction, il la rentre.

Un coffre fautif en pseudo-code :

function withdraw():
  amount = balances[caller]
  require amount > 0
  send(caller, amount)       // (1) rend la main au code de l'appelant
  balances[caller] = 0       // (2) trop tard

Exemple chiffré. Le coffre contient 10 pièces : 9 appartiennent à d'autres utilisateurs, 1 à l'attaquant, dont le solde enregistré est 1. L'attaquant appelle withdraw.

NiveauSolde luEnvoiPièces restant au coffre
1119
2 (rappel par l'attaquant)1, car (2) n'a pas eu lieu18
…11…
10110

L'attaquant a reçu 10 pièces pour une déposée : 9 pièces volées aux autres. Dans la réalité, le nombre de rappels est borné par le gaz et la profondeur d'appels, mais l'idée tient. L'incident de The DAO, en 2016, est l'exemple le plus cité de ce schéma.

Deux remèdes corrigent la faille, souvent combinés, et un troisième geste est utile sans la corriger à lui seul :

  1. Vérifications, effets, interactions : mettre à jour l'état avant l'appel externe. Ici, mettre balances[caller] = 0 avant send (en pseudo-code, si l'envoi échoue, l'appel entier est annulé ; en Solidity, un envoi par call renvoie un booléen et il faut écrire require(ok) pour obtenir cette annulation).
  2. Verrou : un drapeau mis à vrai à l'entrée de la fonction et refusé s'il est déjà vrai.
  3. Retrait par le bénéficiaire (pull) : le contrat enregistre ce qui est dû, chacun vient le retirer, et on n'envoie jamais en bloc. Ce n'est pas un remède à la réentrance : le coffre fautif ci-dessus est déjà un retrait par le bénéficiaire. Le modèle pull évite surtout qu'un destinataire défaillant bloque tous les autres ; la fonction de retrait doit elle-même mettre l'état à jour avant l'envoi (remède 1) ou être verrouillée (remède 2).
function withdraw():
  amount = balances[caller]
  require amount > 0
  balances[caller] = 0       // effet d'abord
  send(caller, amount)       // interaction ensuite

La réentrance dépend de la machine virtuelle : certaines plateformes la limitent par conception. Le principe général (un appel externe peut rendre la main avant que votre état soit cohérent) reste un bon réflexe.

Ce que cela ne garantit PAS

  • Connaître ces trois failles n'en fait pas un auditeur : il y en a beaucoup d'autres.
  • Un test qui passe ne prouve pas l'absence de faille ; seul un test écrit contre l'attaque la démontre ou la réfute.
  • Un correctif appliqué au mauvais endroit peut sembler fonctionner : par exemple limiter le gaz donné à l'appelant n'est pas une protection fiable, les coûts changeant d'un protocole à l'autre.

À retenir : qui peut appeler ? ; la taille des entiers est finie ; état d'abord, appel externe ensuite.

Leçons 3/4 · 20 min

Contrat, protocole, et le problème de l'oracle

Qui garantit quoi ?

Quand quelque chose tourne mal, la première question est : est-ce du ressort du contrat, du protocole, ou d'aucun des deux ? Les confondre mène à des promesses fausses.

CoucheCe qu'elle garantitExemples
Protocole (règles de la chaîne, appliquées par chaque nœud)validité des blocs, vérification des signatures, ordre des nonces, comptage du gaz, consensus, finalité, définition de la machine virtuelleune transaction mal signée est rejetée avant d'atteindre un contrat
Contrat (code que vous déployez)les règles de son étatqui peut enregistrer un lot, quand libérer un paiement
Ni l'un ni l'autreles faits du monde réel, la vie privée, l'identité des personnes, la valeur juridiquele poids réel d'un lot de cacao, le fait que « Mme X » soit bien Mme X

Erreurs d'attente fréquentes :

  • Attendre d'un contrat qu'il annule une transaction déjà finalisée : c'est une décision de protocole (ou de gouvernance), pas de code.
  • Attendre qu'il se déclenche seul à une date : il faut une transaction (par un humain ou un service dit « keeper »).
  • Attendre qu'il lise une autre chaîne ou une page web : il n'en a pas le moyen sans un intermédiaire.
  • Attendre qu'il garde un secret : l'état est public.
  • Attendre un ordre équitable des transactions : le producteur du bloc choisit l'ordre, d'où le risque que quelqu'un qui voit une transaction en attente passe devant (ce que l'on regroupe souvent sous le sigle MEV, pour maximal extractable value). C'est une propriété du protocole et du réseau.

Le problème de l'oracle

Un contrat ne voit que la chaîne. Pour réagir à un fait extérieur (le prix du cacao, le poids relevé par une balance, une météo), quelqu'un doit l'écrire sur la chaîne : cet intermédiaire est un oracle. Le problème : la chaîne vérifie que la donnée a été écrite par la bonne adresse, pas qu'elle est vraie. Le contrat n'est donc pas plus fiable que son oracle.

Exemple chiffré avec trois rapporteurs indépendants du prix d'un kilo (unités fictives) : 100, 102 et 160. La moyenne vaut (100 + 102 + 160) / 3 ≈ 120,67 : un rapporteur défaillant ou corrompu tire le résultat. La médiane (la valeur du milieu une fois triées) vaut 102 : une valeur aberrante n'a aucune influence. Avec n rapporteurs, la médiane résiste à moins de la moitié de rapporteurs défaillants : 1 sur 3, 2 sur 5. Si 2 des 3 sont corrompus, la médiane est elle aussi fausse.

Autres atténuations : exiger plusieurs sources indépendantes, borner les variations acceptées par heure, introduire un délai avant d'agir, exiger une mise que le rapporteur perd en cas de faute. Elles réduisent le risque ; aucune ne supprime la confiance dans la donnée d'origine.

Application à notre registre

Le registre de lots prouve qu'un enregistrement n'a pas été modifié après coup et qu'il a été fait par une adresse autorisée. Il ne prouve pas que le sac contient bien 500 kg. Si un capteur ou un agent saisit le poids, c'est lui l'oracle, et il faut des contrôles hors chaîne : pesées contradictoires, plusieurs agents, audit. Une traçabilité honnête dit : « la chaîne fixe ce qui a été déclaré, et par qui ».

Ce que cela ne garantit PAS

  • Un oracle décentralisé n'est pas automatiquement honnête : plusieurs rapporteurs peuvent s'entendre ou lire la même source fautive.
  • Le prix d'un marché très mince peut être manipulé quelques instants ; le prendre comme vérité est un classique d'attaque.
  • Séparer les couches n'est pas une excuse : c'est ce qui permet de promettre seulement ce qu'on peut tenir.

À retenir : le protocole garantit la mécanique, le contrat garantit ses règles, personne ne garantit les faits extérieurs sans confiance dans un oracle.

Leçons 4/4 · 20 min

Écrire, tester, déployer sur un réseau de test

Ce que le navigateur ne fait pas, et ce que vous ferez ailleurs

Parmi les objectifs de cet atelier figurent écrire, tester et déployer un contrat sur un réseau de test. Ces trois gestes demandent un compilateur et un nœud, qui ne tournent pas dans cette page. Cette leçon vous donne la méthode ; le laboratoire autonome vous fait pratiquer avec Solidity (documentation officielle) et Foundry (Foundry Book). Notez les versions de vos outils dans le README : elles changent, ce cours n'en fixe aucune. Pour que la méthode soit concrète, la leçon montre le même registre en Solidity, un fichier de tests Foundry et des commandes de déploiement. Ce sont des schémas écrits de mémoire et non exécutés ici : confrontez-les à la documentation officielle et à la version installée avant de vous y fier.

Écrire petit

Un contrat court est plus facile à relire, à tester et à auditer. Principes : peu d'état, peu de fonctions, une règle par fonction, des noms clairs, des événements (emit) pour tout changement important afin que les applications puissent les suivre. Réutiliser du code éprouvé vaut mieux que réinventer, à condition de savoir ce qu'on importe.

Le même registre en Solidity

Voici le pseudo-code de la leçon 1 traduit en Solidity (schéma). Les correspondances : require devient require(condition, "message"), caller devient msg.sender, et le propriétaire est fixé dans le constructeur, code exécuté une seule fois, au déploiement.

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

contract LotRegistry {
    address public owner;
    mapping(uint256 => address) public holder;
    mapping(uint256 => uint256) public weightKg;

    event Registered(uint256 indexed lotId, uint256 weight);

    constructor() {
        owner = msg.sender;           // l'adresse qui déploie devient propriétaire
    }

    function register(uint256 lotId, uint256 weight) external {
        require(msg.sender == owner, "not owner");
        require(weight > 0, "weight is zero");
        require(holder[lotId] == address(0), "lot exists");
        holder[lotId] = msg.sender;
        weightKg[lotId] = weight;
        emit Registered(lotId, weight);
    }
}

Deux détails à retenir. D'abord, owner = msg.sender dans le constructeur signifie que le propriétaire est le compte qui envoie la transaction de déploiement : se tromper de clé au déploiement, c'est se tromper de propriétaire, définitivement. Ensuite, le pseudo-code stockait un couple en une case ; ici, deux tables séparées rendent le code plus simple à lire, au prix d'une écriture de plus (ce qui change le coût en gaz, voir la leçon 1).

Tester d'abord ce qui doit échouer

Un test est un petit programme qui appelle votre contrat et vérifie le résultat. Pour le registre de lots, la liste minimale :

CasRésultat attendu
le propriétaire enregistre le lot 7 (500 kg)réussit, l'événement est émis, le lot est lisible
un compte non propriétaire enregistre le lot 8échoue (annulation)
le propriétaire enregistre deux fois le lot 7la seconde fois échoue
poids 0 ou énormerefusé ou accepté selon la règle écrite, mais décidé
lecture d'un lot absentréponse prévue, pas d'état inventé

Ajoutez des tests de propriétés : par exemple « quel que soit l'appel, un lot enregistré ne change jamais de poids ». Les outils de fuzzing génèrent beaucoup d'entrées aléatoires pour chercher une violation. Pour une faille connue (réentrance), écrivez d'abord un test qui joue l'attaque : il doit échouer sur le code fautif et passer sur le code corrigé. C'est la preuve de la correction.

Avec Foundry, les tests s'écrivent en Solidity, dans un contrat qui hérite de Test. Schéma pour les trois premières lignes du tableau :

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

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

contract LotRegistryTest is Test {
    LotRegistry registry;
    address other = address(0xBEEF);

    function setUp() public {
        registry = new LotRegistry();     // le test déploie, donc il est propriétaire
    }

    function test_OwnerRegisters() public {
        registry.register(7, 500);
        assertEq(registry.weightKg(7), 500);
    }

    function test_NonOwnerReverts() public {
        vm.prank(other);                  // le prochain appel vient de "other"
        vm.expectRevert("not owner");     // on attend cette annulation précise
        registry.register(8, 100);
    }

    function test_DuplicateReverts() public {
        registry.register(7, 500);
        vm.expectRevert("lot exists");
        registry.register(7, 600);
    }
}

vm.prank et vm.expectRevert sont des « cheatcodes » de Foundry : le premier fait envoyer le prochain appel par une autre adresse, le second exige que cet appel soit annulé avec le message donné. On lance les tests avec forge test. Un test d'accès qui vérifie le refus vaut autant que celui qui vérifie le succès.

Déployer sur un réseau de test

Un réseau de test (testnet) est une chaîne qui imite la vraie, dont les jetons n'ont aucune valeur et s'obtiennent gratuitement auprès d'un robinet (faucet). Sur les chaînes de type Ethereum, déployer est une transaction particulière : sans destinataire, contenant le code compilé ; la chaîne attribue alors une adresse au contrat. Elle coûte du gaz, croissant avec la taille du code. L'adresse du nouveau contrat figure dans le reçu de la transaction de déploiement.

Avec Foundry, le déploiement se fait par une commande du genre suivant (schéma : les options changent selon la version, vérifiez avec forge create --help) :

cast wallet import testdeployer --interactive      # enregistre une clé de TEST chiffrée (keystore)
forge create src/LotRegistry.sol:LotRegistry \
  --rpc-url <URL-DU-RESEAU-DE-TEST> --account testdeployer --broadcast

Le keystore chiffré garde la clé hors de l'historique du terminal et des variables d'environnement, ce qui est préférable à la passer en clair sur la ligne de commande. Pour un déploiement reproductible, on écrit plutôt un script versionné (forge script) qui fait le même travail. Après le déploiement, deux contrôles simples : lire owner() du contrat et vérifier que c'est bien l'adresse prévue ; appeler register depuis ce compte.

Habitudes à prendre :

  1. Répéter le déploiement sur testnet avant tout autre réseau, avec un script versionné et reproductible.
  2. Utiliser une clé de déploiement réservée au test, jamais dans le dépôt (voir l'atelier sur Git) ni dans l'image (voir l'atelier Docker).
  3. Vérifier les paramètres du constructeur (notamment l'adresse du propriétaire) avant d'envoyer.
  4. Publier le code source sur l'explorateur du réseau pour que tout le monde puisse comparer.
  5. Pour un contrat qui porte de la valeur réelle : relecture par des pairs, audit externe, détention des droits d'administration par un mécanisme à plusieurs signataires, surveillance, plan d'incident.

Les limites de l'assurance

  • Les tests montrent la présence de défauts, jamais leur absence.
  • Un audit est un échantillonnage fait par des humains dans un temps limité.
  • La vérification formelle prouve des propriétés par rapport à une spécification ; si la spécification est fausse, la preuve ne protège pas.
  • Une fonction de pause ou une clé d'urgence peut sauver des fonds, mais elle donne à son détenteur un pouvoir que vos utilisateurs doivent accepter. Décidez et dites-le.

Pourquoi la suite du cursus descend d'un cran

Ce que vous venez de voir (gaz, déterminisme, immutabilité, dépendance aux oracles) explique pourquoi certains projets choisissent de construire leur propre chaîne ou module de protocole plutôt qu'un contrat : plus de contrôle sur les règles, au prix de plus de responsabilité. Vous retrouverez ces choix dans les ateliers suivants.

Ce que cela ne garantit PAS

  • Un déploiement réussi sur testnet n'implique pas qu'il le sera sur le réseau principal : les paramètres, les frais et les adresses diffèrent.
  • Un contrat vérifié sur l'explorateur est lisible, pas pour autant sûr.
  • La liste de cas ci-dessus est un minimum, pas une garantie de couverture.

À retenir : petit, testé contre l'échec, répété sur testnet, avec une clé de test, et conscient des limites de l'assurance.

Laboratoire guidé

Le navigateur ne compile ni ne déploie de contrat : ces étapes entraînent donc la lecture et le raisonnement sur du pseudo-code, qui est le geste premier d'un auditeur. Chaque extrait est court. L'écriture, les tests et le déploiement sur un réseau de test avec Foundry (schémas de la dernière leçon) se font dans le laboratoire autonome. Ces extraits restent en pseudo-code : ne cherchez aucune syntaxe de Solidity dans les énoncés.

Le navigateur ne remplace pas un vrai terminal : ce laboratoire entraîne la lecture et le raisonnement ; la pratique réelle est dans le laboratoire autonome.

Un compte certifié est nécessaire

Les activités notées enregistrent des résultats à votre nom et servent de base à un titre. Elles exigent donc un dossier certifiant complet et un consentement en vigueur. Remplir le dossier ne vérifie pas votre identité ; le Secrétariat peut ensuite la vérifier (badge « Vérifié »).

  1. 1.Repérer le contrôle d'accès défaillant

    Voici un petit contrat de caisse commune (pseudo-code).

    contract Pot:
      storage owner
      storage price
    
      function setPrice(newPrice):
        price = newPrice
    
      function withdrawAll():
        require caller == owner
        send(owner, balanceOfContract)
    
      function setOwner(newOwner):
        require caller == owner
        owner = newOwner

    Répondez à la question de la grille : quelle fonction est exposée à un appel par n'importe qui, et pourquoi est-ce grave ?

  2. 2.Prédire un dépassement

    Un contrat ancien stocke les soldes sur 8 bits (valeurs de 0 à 255) et ne vérifie pas les dépassements.

    balances[alice] = 3
    function spend(amount):
      balances[caller] = balances[caller] - amount     // aucune vérification
    
    alice appelle spend(5)

    Répondez à la question de la grille : quel est le solde d'Alice après l'appel ? (Un nombre.)

  3. 3.Suivre une réentrance

    Reprenez le coffre de la leçon 2, dans sa version fautive (envoi AVANT la mise à zéro du solde). Le coffre contient 10 pièces : 9 appartiennent à d'autres utilisateurs, 1 à l'attaquant (solde enregistré : 1). Le code de l'attaquant, exécuté à chaque réception de pièces, rappelle withdraw tant que le coffre n'est pas vide. On suppose que le gaz et la profondeur d'appels ne limitent pas la boucle.

    Répondez à la question de la grille : combien de pièces appartenant aux autres utilisateurs l'attaquant a-t-il prises ? (Un nombre.)

  4. 4.Contrat, protocole ou ni l'un ni l'autre

    La coopérative propose quatre promesses pour son registre de lots. Une seule relève réellement du contrat que vous écrivez, sans dépendre ni d'une décision du protocole ni d'un fait extérieur.

    Lisez la grille et choisissez la promesse qu'un contrat peut tenir seul. Pensez à ce que le contrat voit (son propre état et les transactions qu'on lui envoie), à ce que le protocole décide pour tous les nœuds, et à ce que personne ne peut vérifier depuis la chaîne.

Exercice de sécurité — Exploiter puis corriger une réentrance

Le laboratoire autonome vous fait exploiter une réentrance dans un vrai contrat vulnérable sur votre machine. Ici, vous raisonnez sur le même schéma, en pseudo-code, avant de coder.

contract Vault:
  storage balances: map address -> amount

  function deposit():
    balances[caller] += amountSent

  function withdraw():
    amount = balances[caller]
    require amount > 0
    send(caller, amount)         // (1) le code de l'appelant s'exécute ici
    balances[caller] = 0         // (2)

Un attaquant dépose 1 pièce puis appelle withdraw depuis un contrat dont le code, à chaque réception de pièces, rappelle withdraw. Le coffre contient 10 pièces au total.

Quatre correctifs sont proposés en revue :

  • A. Échanger (1) et (2) : mettre le solde à zéro, puis envoyer ; si l'envoi échoue, l'appel entier est annulé.
  • B. Ajouter, avant l'envoi, une vérification que le montant vaut au moins 1.
  • C. Ajouter un verrou : refuser l'appel si un drapeau est déjà levé, lever le drapeau en entrant dans withdraw, le baisser en sortant.
  • D. Donner très peu de gaz au code de l'appelant, pour qu'il n'ait pas le temps de rappeler withdraw.

Un correctif n'est valide que si une attaque rejouée dans un test échoue après la correction.

Question de la grille : quels correctifs corrigent réellement la faille de façon fiable ?

Questionnaire de l'atelier

Huit questions tirées d'une réserve plus large, seuil de réussite 70 %. Il s'ouvre quand tous les points de contrôle du laboratoire sont réussis. Vous pouvez le repasser : le tirage change.

Un compte certifié est nécessaire

Les activités notées enregistrent des résultats à votre nom et servent de base à un titre. Elles exigent donc un dossier certifiant complet et un consentement en vigueur. Remplir le dossier ne vérifie pas votre identité ; le Secrétariat peut ensuite la vérifier (badge « Vérifié »).

Laboratoire autonome (hors du site)

Non noté en ligne : à réaliser sur votre machine et à publier sur votre dépôt Git.

À faire sur votre machine, hors du site. Non noté en ligne ; l'évaluation finale est un contrat testé plus un rapport d'audit croisé, noté sur la reproductibilité des failles décrites.

  1. Installez Solidity et Foundry en suivant leur documentation officielle (Solidity, Foundry Book). Notez dans votre README les versions réellement installées.
  2. Écrivez un registre de traçabilité simple (propriétaire, enregistrement d'un lot, lecture, événement) comme celui du cours, avec une suite de tests couvrant : succès, appel par un non-propriétaire, doublon, valeurs limites, événement émis.
  3. Utilisez uniquement un compte de test et des jetons obtenus sur un robinet de réseau de test. Ne mettez jamais de clé dans le dépôt : de préférence un keystore chiffré (cast wallet import), sinon une variable d'environnement pour une clé jetable.
  4. Déployez sur un réseau de test avec un script reproductible, publiez le code sur l'explorateur du réseau, puis appelez la fonction d'enregistrement par une transaction. Notez l'adresse du contrat et le hachage de la transaction dans le README.
  5. Exploitation, sur votre propre déploiement local ou de test uniquement, jamais sur le contrat ou le déploiement de quelqu'un d'autre : prenez le coffre vulnérable ci-dessous (volontairement fautif, ne jamais le déployer sur un réseau principal), écrivez un contrat attaquant et un test qui exploitent la réentrance, puis corrigez le coffre et montrez que le même test échoue après correction.
contract VulnerableVault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "nothing to withdraw");
        (bool ok, ) = msg.sender.call{value: amount}("");   // (1) rend la main à l'appelant
        require(ok, "send failed");
        balances[msg.sender] = 0;                           // (2) trop tard
    }
}

Indice : l'attaquant est un contrat dont la fonction receive() rappelle withdraw() tant que le coffre contient des fonds. Le formateur peut fournir sa propre version.

  1. Audit : échangez votre contrat avec un pair. Pour chaque faille que vous croyez trouver, écrivez un test qui la reproduit. Un rapport sans test reproductible n'est pas accepté.
  2. Dans votre rapport, séparez ce que le contrat garantit, ce que le protocole garantit, et ce qui dépend d'un oracle ou d'une personne.

À retenir

  • Un contrat est du code et de l'état à une adresse, exécuté de façon déterministe par chaque nœud, mesuré en gaz, public et, en principe, immuable.
  • Le contrat garantit ses règles, pas les faits : la chaîne fixe fidèlement ce qui a été déclaré, vrai ou faux.
  • Trois failles à savoir reconnaître : accès non contrôlé, dépassement d'entiers, réentrance (mettre à jour l'état avant l'appel externe).
  • Le protocole garantit la mécanique (signatures, consensus, finalité) ; un contrat ne peut ni se déclencher seul, ni lire le monde extérieur, ni garder un secret.
  • Tester d'abord ce qui doit échouer, répéter sur un réseau de test avec une clé de test, et se souvenir que tests et audits ne prouvent jamais l'absence de faille.

Idées fausses courantes

  • Idée reçue : Un contrat déployé est « sûr par construction » puisque le code est public et que personne ne peut le modifier.

    En réalité : Public et immuable veut dire qu'un défaut est visible de tous et définitif. La sécurité vient des tests, de la relecture et de la conception, pas du seul fait de déployer.

  • Idée reçue : Un contrat peut appeler une API météo ou de prix quand il en a besoin.

    En réalité : Les nœuds obtiendraient des réponses différentes et divergeraient. Un tiers (un oracle) doit écrire la donnée sur la chaîne, et le contrat dépend alors de lui.

  • Idée reçue : La réentrance se corrige en limitant le gaz donné à l'appelant.

    En réalité : Les coûts changent d'un protocole à l'autre. Le correctif fiable est de mettre à jour l'état avant l'appel externe, ou d'utiliser un verrou, et de le prouver par un test d'attaque.

Glossaire

Contrat intelligent
Programme et ses données, stockés à une adresse d'une chaîne programmable, exécuté par chaque nœud quand une transaction l'appelle.
Gaz
Unité qui mesure le travail d'exécution ; les frais payés sont le gaz consommé multiplié par le prix par unité.
Réentrance
Faille où un appel externe rend la main à un code qui rappelle la fonction avant que l'état ait été mis à jour.
Oracle
Intermédiaire qui écrit sur la chaîne une donnée du monde extérieur ; le contrat n'est pas plus fiable que lui.
Réseau de test (testnet)
Chaîne qui imite le réseau principal, dont les jetons sans valeur s'obtiennent gratuitement ; elle sert à répéter un déploiement.