Checks-Effects-Interactions (CEI) en Solidity

Publié le 11/01/2026 par Frédéric dans la catégorie "Solidity"

Le pattern Checks-Effects-Interactions (CEI) est une bonne pratique de sécurité en Solidity qui consiste à ordonner les opérations d’une fonction dans un ordre strict pour éviter :

  • Les attaques par reentrancy (ex : exploit du DAO en 2016).
  • Les incohérences d’état (ex : double dépense, sols négatifs).
  • Les échecs silencieux (ex : transfer() qui échoue sans revert).

Problème sans CEI : Si un contrat modifie son état après un appel externe, un attaquant peut rappeler la fonction avant la mise à jour de l’état (via un fallback() malveillant), ce qui conduit à des boucles de retrait infinies ou des états corrompus.

Prompt pour l'IA
Analyse ce contrat Solidity et identifie toutes les violations du pattern **Checks-Effects-Interactions (CEI)**.
Pour chaque fonction, vérifie que :
1. **Checks** : Toutes les conditions/validations (require, if) sont **avant** toute modification d’état.
2. **Effects** : Toutes les mises à jour de l’état (variables, mappings, émettre des événements) sont **avant** les appels externes.
3. **Interactions** : Les appels externes (call, transfer, send, delegatecall) sont **toujours en dernier**.

**Exemples de violations à détecter** :
- Un `balance -= amount` *après* un `user.call{value: amount}()`.
- Un `emit Event()` *après* un appel externe.
- Un `require()` *après* une modification d’état.

**Format de sortie souhaité** :
```markdown
### Fonction vulnérable : `nomFonction`
- **Problème** : [Description de la violation CEI]
- **Ligne** : [Numéro de ligne]
- **Risque** : [Reentrancy / Incohérence d’état / Échec silencieux]
- **Correction** :
  ```solidity
  // Code corrigé avec CEI

**Contexte supplémentaire** :
- Version de Solidity : [insérer version]
- Fichier analysé : [nom du contrat]
- Dépendances : [ex : OpenZeppelin, bibliothèques personnalisées]

Explication détaillée

A. Mécanisme du pattern CEI

Le principe CEI divise une fonction en 3 phases strictement ordonnées :

Phase Description Exemples en Solidity
Checks Valider les conditions préalables (entrées, permissions, sols). require(balances[msg.sender] >= amount, "Insufficient balance");
Effects Modifier l’état du contrat (variables, mappings, événements). balances[msg.sender] -= amount; <br> emit Withdrawal(msg.sender, amount);
Interactions Interagir avec l’extérieur (appels à d’autres contrats, transferts). (bool success, ) = msg.sender.call{value: amount}("");

Pourquoi cet ordre ?

  • Éviter la reentrancy : Si un appel externe (call) est fait avant la mise à jour de l’état, un attaquant peut rappeler la fonction et exploiter l’ancien état.
  • Garantir la cohérence : L’état est toujours mis à jour avant qu’un échec externe ne se produise.
  • Clarté du code : Séparation logique des étapes.

B. Exemple de code vulnérable (sans CEI)

// ❌ Contrat vulnérable à la reentrancy
contract Bank {
    mapping(address => uint256) public balances;

    function withdraw(uint256 _amount) external {
        // Interaction AVANT l'effect → Risque de reentrancy !
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Transfer failed");

        // Effect (trop tard !)
        balances[msg.sender] -= _amount;
    }
}

Attaque possible :

  1. Un attaquant déploie un contrat malveillant avec un fallback() qui rappelle withdraw().
  2. Le solde n’est pas encore mis à jour quand le rappel se produit → retrait multiple des mêmes fonds.

C. Exemple de code corrigé (avec CEI)

// ✅ Contrat sécurisé avec CEI
contract Bank {
    mapping(address => uint256) public balances;

    function withdraw(uint256 _amount) external {
        // 1. Checks
        require(balances[msg.sender] >= _amount, "Insufficient balance");

        // 2. Effects
        balances[msg.sender] -= _amount;
        emit Withdrawal(msg.sender, _amount);

        // 3. Interactions (en dernier !)
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Transfer failed");
    }
}

Pourquoi c’est sécurisé ?

  • Le solde est mis à jour avant l’appel externe → impossible de rappeler withdraw() avec l’ancien solde.
  • Même si call échoue, l’état reste cohérent (pas de double dépense).

D. Cas avancés et pièges courants

1. Les événements (emit) doivent être dans "Effects"

// ❌ Mauvaise pratique : emit après un appel externe
function transfer(address _to, uint256 _amount) external {
    balances[msg.sender] -= _amount;
    (bool success, ) = _to.call{value: _amount}("");
    emit Transfer(msg.sender, _to, _amount); // Trop tard !
}

Risque : Si call échoue, l’événement ne sera jamais émis, ce qui fausse les traces (ex : explorateurs de blockchain).

Correction :

// ✅ emit dans "Effects"
balances[msg.sender] -= _amount;
emit Transfer(msg.sender, _to, _amount); // Avant l'appel externe
(bool success, ) = _to.call{value: _amount}("");

2. Les appels externes "cachés" (ex : bibliothèques)

Même les appels à des bibliothèques ou contrats internes peuvent violer CEI :

// ❌ Appel externe indirect
function updateState() external {
    balances[msg.sender] = 100; // Effect
    ExternalLibrary.doSomething(); // Interaction cachée ! (peut rappeler ce contrat)
}

Solution : Traiter tous les appels externes (même internes) comme des "Interactions".


3. Les boucles avec appels externes

// ❌ Boucle avec interactions → risque de reentrancy à chaque itération
function batchTransfer(address[] calldata _receivers, uint256 _amount) external {
    for (uint i = 0; i < _receivers.length; i++) {
        balances[_receivers[i]] += _amount;
        _receivers[i].transfer(_amount); // Interaction DANS la boucle !
    }
}

Correction :

// ✅ Mettre à jour tous les états AVANT les interactions
for (uint i = 0; i < _receivers.length; i++) {
    balances[_receivers[i]] += _amount;
}
for (uint i = 0; i < _receivers.length; i++) {
    (bool success, ) = _receivers[i].call{value: _amount}("");
    require(success, "Transfer failed");
}

E. Intégration avec d’autres bonnes pratiques

Pratique Comment l’appliquer avec CEI Exemple
ReentrancyGuard Ajouter nonReentrant pour doubler la protection. function withdraw() external nonReentrant { ... }
Pull > Push Préférer les modèles "pull" (ex : withdraw) plutôt que "push" (ex : sendToAll). Voir le pattern Pull-over-Push
Checks redondants Revalider l’état après les effets si nécessaire. require(balances[msg.sender] == 0, "Already withdrawn"); après mise à jour.

4. Outils pour vérifier le CEI

Outil Commande/Utilisation Ce qu’il détecte
Slither slither . --check-list Violations CEI, reentrancy, appels externes mal placés.
MythX Analyse statique via mythx.io Ordre incorrect des opérations, risques de reentrancy.
Solhint solhint 'contracts/**/*.sol' Règles de style + avertissements pour les appels externes dans les boucles.
Manual Review Rechercher les motifs : call/transfer avant une mise à jour d’état. Violations subtiles (ex : événements après call).

5. Exemple complet : Contrat de prêt sécurisé

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

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SecureLoan is ReentrancyGuard {
    mapping(address => uint256) public balances;
    mapping(address => uint256) public loans;

    // ✅ Respecte CEI + ReentrancyGuard
    function borrow(uint256 _amount) external nonReentrant {
        // 1. Checks
        require(balances[msg.sender] >= _amount, "Insufficient collateral");
        require(loans[msg.sender] == 0, "Already have a loan");

        // 2. Effects
        balances[msg.sender] -= _amount;
        loans[msg.sender] = _amount;
        emit LoanTaken(msg.sender, _amount);

        // 3. Interactions
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Transfer failed");
    }

    // ✅ CEI + Pull Pattern (l'utilisateur doit appeler withdraw)
    function repay() external nonReentrant {
        // 1. Checks
        uint256 loan = loans[msg.sender];
        require(loan > 0, "No loan");
        require(msg.value >= loan, "Insufficient ETH");

        // 2. Effects
        loans[msg.sender] = 0;
        balances[msg.sender] += loan;
        emit LoanRepaid(msg.sender, loan);

        // 3. Interactions (ici, pas de transfer car c'est un "pull")
        // L'ETH est déjà envoyé par l'utilisateur (msg.value)
    }
}

6. Quand peut-on "violer" CEI ?

Il existe de rares exceptions où l’ordre CEI peut être ajusté, mais uniquement si :

  1. L’appel externe est en lecture seule (ex : staticcall à un oracle).
    function getPrice() external {
       // Pas de modification d'état → pas de risque de reentrancy
       (bool success, bytes memory data) = oracle.staticcall(abi.encodeWithSignature("latestPrice()"));
       require(success, "Oracle failed");
       uint256 price = abi.decode(data, (uint256));
       // ... (pas d'Effects après)
    }
  2. L’appel externe est à un contrat trusted et vérifié (ex : contrat admin contrôlé).
    function adminWithdraw(address _to, uint256 _amount) external {
       require(msg.sender == admin, "Not admin");
       // Interaction avant Effect (mais safe car admin est trusted)
       (bool success, ) = _to.call{value: _amount}("");
       require(success, "Transfer failed");
       // Mise à jour de l'état après (acceptable ici)
       adminBalances[_to] -= _amount;
    }

    ⚠️ À éviter sauf cas très spécifiques (et documentés).


7. Résumé des bonnes pratiques CEI

À faire À éviter
Valider toutes les conditions en premier (require, if). Faire des require après des modifications d’état.
Mettre à jour tout l’état avant les appels externes. Laisser des variables non mises à jour avant un call.
Émettre les événements dans la phase "Effects". Placer des emit après des interactions.
Utiliser call plutôt que transfer/send. Utiliser transfer ou send (sauf pour des cas très simples).
Appliquer nonReentrant (OpenZeppelin) pour les fonctions critiques. Supposer que transfer() protège contre la reentrancy.
Séparer les boucles : d’abord les Effects, puis les Interactions. Faire des appels externes dans une boucle de mise à jour d’état.

8. Ressources pour aller plus loin

  1. Consensys Diligence : Checks-Effects-Interactions → Explication approfondie avec des exemples d’exploits réels.
  2. Solidity Docs : Security Considerations → Section officielle sur CEI.
  3. OpenZeppelin ReentrancyGuard → Implémentation du verrou contre la reentrancy.
  4. SWCREGISTRY : Reentrancy → Base de données des vulnérabilités Solidity (incluant les violations CEI).

À retenir :

"Checks-Effects-Interactions n’est pas une option, c’est une loi physique en Solidity. La violer, c’est comme construire un pont sans calculer les forces : ça finira par s’effondrer."Vitalik Buterin (paraphrasé, EthCC 2022)