Checks-Effects-Interactions (CEI) en 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 :
- Un attaquant déploie un contrat malveillant avec un
fallback()qui rappellewithdraw(). - 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 :
- 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) } - 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
- Consensys Diligence : Checks-Effects-Interactions → Explication approfondie avec des exemples d’exploits réels.
- Solidity Docs : Security Considerations → Section officielle sur CEI.
- OpenZeppelin ReentrancyGuard → Implémentation du verrou contre la reentrancy.
- 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)