# Bonnes Pratiques de Sécurité Solidity — Déployez des Smart Contracts Sûrs
La sécurité des smart contracts n'est pas négociable. Contrairement aux logiciels traditionnels, les bugs dans les smart contracts déployés peuvent entraîner des pertes de fonds irréversibles. Ce guide couvre les 12 pratiques de sécurité essentielles que tout développeur Solidity doit suivre.
Plus de 3 milliards de dollars ont été perdus dans des hacks de smart contracts en 2025. Ne laissez pas votre contrat être le prochain. Construisons du code sécurisé dès le premier jour.
1. Utilisez la Dernière Version de Solidity
Règle : Utilisez toujours Solidity 0.8.0 ou supérieur.
Pourquoi : La version 0.8.0 a introduit des vérifications automatiques d'overflow/underflow, éliminant toute une classe de vulnérabilités.
// ❌ MAUVAIS - Vulnérable aux overflow
pragma solidity ^0.7.6;
contract VulnerableToken {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
balances[msg.sender] -= amount; // Peut underflow !
balances[to] += amount; // Peut overflow !
}
}
// ✅ BON - Protégé par défaut
pragma solidity ^0.8.26;
contract SafeToken {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
balances[msg.sender] -= amount; // Revert sur underflow
balances[to] += amount; // Revert sur overflow
}
}
Bonne pratique : Fixez une version spécifique pour la production (pragma solidity 0.8.26;) pour éviter tout comportement inattendu avec les mises à jour du compilateur.
2. Protégez-vous Contre la Reentrancy
La vulnérabilité : Un attaquant appelle récursivement votre contrat avant que l'état ne soit mis à jour.
Exemple fameux : The DAO hack (2016) — $60M volés.
// ❌ VULNÉRABLE
contract VulnerableBank {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
// Appel externe AVANT la mise à jour de l'état
(bool success,) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] = 0; // TROP TARD
}
}
// Attaque :
contract Attacker {
VulnerableBank bank;
receive() external payable {
if (address(bank).balance > 0) {
bank.withdraw(); // Appel récursif !
}
}
}
Solution 1 : Checks-Effects-Interactions Pattern
// ✅ SÉCURISÉ
function withdraw() external {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0; // Mise à jour d'état AVANT l'appel externe
(bool success,) = msg.sender.call{value: amount}("");
require(success);
}
Solution 2 : ReentrancyGuard (OpenZeppelin)
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw() external nonReentrant {
uint256 amount = balances[msg.sender];
(bool success,) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] = 0;
}
}
Règle d'or : Toujours mettre à jour l'état AVANT les appels externes.
3. Contrôle d'Accès Strict
Principe : Seules les adresses autorisées doivent pouvoir exécuter des fonctions sensibles.
// ❌ PAS DE PROTECTION
contract UnsafeVault {
function withdrawAll() external {
payable(msg.sender).transfer(address(this).balance); // N'importe qui peut appeler !
}
}
// ✅ OWNER UNIQUEMENT
contract SafeVault {
address public immutable owner;
constructor() {
owner = msg.sender;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
function withdrawAll() external onlyOwner {
payable(owner).transfer(address(this).balance);
}
}
Mieux : Utilisez OpenZeppelin Ownable
import "@openzeppelin/contracts/access/Ownable.sol";
contract ManagedVault is Ownable {
function withdrawAll() external onlyOwner {
payable(owner()).transfer(address(this).balance);
}
// Bonus : transfert de propriété sécurisé inclus
}
Contrôle d'accès avancé : Role-Based Access Control (RBAC)
import "@openzeppelin/contracts/access/AccessControl.sol";
contract AdvancedVault is AccessControl {
bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE");
bytes32 public constant TREASURER_ROLE = keccak256("TREASURER_ROLE");
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
}
function deposit() external payable onlyRole(MANAGER_ROLE) {}
function withdraw(uint amount) external onlyRole(TREASURER_ROLE) {}
}
4. Validez Tous les Inputs
Règle : Ne faites jamais confiance aux données externes.
// ❌ PAS DE VALIDATION
function setDiscount(uint256 discount) external {
// Et si discount = 100% ? Ou 1000% ?
userDiscount[msg.sender] = discount;
}
// ✅ VALIDATION STRICTE
function setDiscount(uint256 discount) external {
require(discount <= 50, "Discount too high"); // Max 50%
require(discount > 0, "Discount must be positive");
userDiscount[msg.sender] = discount;
}
Validation d'adresses :
// ❌ DANGEREUX
function setTreasury(address newTreasury) external onlyOwner {
treasury = newTreasury; // Et si newTreasury = address(0) ?
}
// ✅ SÉCURISÉ
function setTreasury(address newTreasury) external onlyOwner {
require(newTreasury != address(0), "Zero address");
treasury = newTreasury;
}
5. Gérez les Appels Externes avec Précaution
Problème : Les appels externes peuvent échouer ou être malveillants.
// ❌ UTILISE transfer() (peut échouer silencieusement sur certains cas)
function sendReward(address user, uint amount) external {
payable(user).transfer(amount); // Reverts si échec, mais 2300 gas seulement
}
// ✅ UTILISE call() avec vérification
function sendReward(address user, uint amount) external {
(bool success,) = payable(user).call{value: amount}("");
require(success, "Transfer failed");
}
Pourquoi call() > transfer() ?
transfer(): limite de 2300 gas (peut échouer avec des contrats receveurs complexes)
call(): forward tout le gas restant (mais nécessite une vérification explicite)
Pattern "Pull over Push" pour les paiements :
// Au lieu de pousser les paiements (risque si un destinataire reverts)
contract PushPayments {
function distributeRewards(address[] calldata users, uint[] calldata amounts) external {
for (uint i = 0; i < users.length; i++) {
(bool success,) = payable(users[i]).call{value: amounts[i]}("");
// Si un user revert, TOUS les suivants échouent !
}
}
}
// ✅ Laissez les users retirer (pull pattern)
contract PullPayments {
mapping(address => uint) public pendingRewards;
function claimReward() external {
uint amount = pendingRewards[msg.sender];
pendingRewards[msg.sender] = 0; // Checks-effects-interactions
(bool success,) = payable(msg.sender).call{value: amount}("");
require(success);
}
}
6. Utilisez des Custom Errors (Gas-Efficient)
Depuis Solidity 0.8.4 : Les custom errors économisent du gas vs require() avec strings.
// ❌ COÛTEUX (string stockée on-chain)
require(msg.sender == owner, "Caller is not the owner");
// ✅ GAS-EFFICIENT
error Unauthorized();
if (msg.sender != owner) revert Unauthorized();
Avec paramètres :
error InsufficientBalance(uint256 requested, uint256 available);
function withdraw(uint256 amount) external {
if (balances[msg.sender] < amount) {
revert InsufficientBalance(amount, balances[msg.sender]);
}
// ...
}
Économie de gas : ~50% sur les revert comparé à require avec strings.
7. Protégez les Fonctions Payable
Règle : Toute fonction recevant de l'ETH doit être payable.
// ❌ FONCTION PAYABLE SANS LOGIQUE
function updateSettings() external payable {
// Pourquoi payable si on n'utilise pas msg.value ?
}
// ✅ PAYABLE UNIQUEMENT SI NÉCESSAIRE
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function updateSettings() external {
// Pas payable = revert si ETH envoyé
}
Vérifiez msg.value :
function buyToken(uint amount) external payable {
uint cost = amount * TOKEN_PRICE;
require(msg.value == cost, "Incorrect payment"); // Pas >=, exactement
// ...
}
8. Évitez les Délégations Dangereuses
delegatecall : Exécute du code dans le contexte du contrat appelant (même storage).
// ❌ DANGEREUX - L'implémentation peut modifier notre storage
contract Proxy {
address public implementation;
fallback() external payable {
// Délègue TOUT à l'implémentation
(bool success,) = implementation.delegatecall(msg.data);
require(success);
}
}
// Un attaquant pourrait déployer une implémentation malveillante qui modifie implementation
✅ Sécurisé avec contrôle d'accès :
contract SafeProxy is Ownable {
address public implementation;
function setImplementation(address newImpl) external onlyOwner {
implementation = newImpl;
}
fallback() external payable {
(bool success,) = implementation.delegatecall(msg.data);
require(success);
}
}
Ou utilisez des proxies audités (OpenZeppelin UUPS, Transparent Proxy).
9. Randomness Sécurisée
Problème : block.timestamp, block.number, blockhash sont prévisibles.
// ❌ MAUVAISE RANDOM (prévisible par les mineurs)
function rollDice() external returns (uint) {
uint randomNumber = uint(keccak256(abi.encodePacked(block.timestamp, msg.sender))) % 6;
// Un mineur peut manipuler block.timestamp
return randomNumber;
}
✅ Utilisez Chainlink VRF (Verifiable Random Function) :
import "@chainlink/contracts/src/v0.8/VRFConsumerBase.sol";
contract FairDice is VRFConsumerBase {
bytes32 internal keyHash;
uint256 internal fee;
function rollDice() external returns (bytes32 requestId) {
require(LINK.balanceOf(address(this)) >= fee, "Not enough LINK");
return requestRandomness(keyHash, fee);
}
function fulfillRandomness(bytes32 requestId, uint256 randomness) internal override {
uint result = (randomness % 6) + 1;
// Utilisez result (provably random)
}
}
Alternative : Commit-reveal scheme pour des besoins simples.
10. Gérez les Tokens ERC-20 Proprement
Problème : Certains tokens ERC-20 non-standard peuvent causer des problèmes.
// ❌ SUPPOSE QUE transfer() RETOURNE bool
function depositToken(address token, uint amount) external {
IERC20(token).transfer(address(this), amount); // USDT ne retourne pas bool !
}
// ✅ UTILISEZ SafeERC20 (OpenZeppelin)
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
function depositToken(address token, uint amount) external {
SafeERC20.safeTransferFrom(IERC20(token), msg.sender, address(this), amount);
}
SafeERC20 gère :
- Tokens sans return value (USDT)
- Tokens retournant false au lieu de revert
- Approbations résiduelles
11. Audit et Tests Exhaustifs
Règle d'or : 1 ligne de Solidity = 3 lignes de tests minimum.
Tests à écrire :
// Hardhat test example
describe("SafeVault", function() {
it("Should revert if non-owner tries to withdraw", async function() {
const [owner, attacker] = await ethers.getSigners();
const vault = await SafeVault.deploy();
await expect(
vault.connect(attacker).withdrawAll()
).to.be.revertedWith("Not owner");
});
it("Should handle reentrancy attack", async function() {
// Déployer un contrat attaquant
const attacker = await ReentrancyAttacker.deploy(vault.address);
await expect(
attacker.attack()
).to.be.reverted; // Le guard doit bloquer
});
});
Outils d'analyse statique :
- Slither : détecte 70+ vulnérabilités
- Mythril : analyse symbolique
- Echidna : fuzzing
Audits professionnels :
- Audit avant tout déploiement mainnet de grande valeur
- Budget : $10k-$100k+ selon la complexité
- Auditeurs réputés : Trail of Bits, OpenZeppelin, Consensys Diligence
12. Utilisez des Bibliothèques Auditées
Ne réinventez pas la roue. Utilisez du code audité et testé en production.
OpenZeppelin Contracts :
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/security/Pausable.sol";
contract MyToken is ERC20, Ownable, Pausable {
constructor() ERC20("MyToken", "MTK") {
_mint(msg.sender, 1000000 * 10**18);
}
function pause() external onlyOwner {
_pause();
}
function unpause() external onlyOwner {
_unpause();
}
// Héritage de tout le code audité d'OZ
}
Autres bibliothèques :
- Solmate : versions gas-optimisées (mais moins de sécurité prouvée qu'OZ)
- PRBMath : mathématiques à précision fixe
Checklist de Sécurité Pré-Déploiement
Avant de déployer sur mainnet, vérifiez :
- [ ] Solidity 0.8.0+ utilisé
- [ ] Toutes les fonctions payable/external ont des guards
- [ ] Checks-effects-interactions pattern respecté partout
- [ ] Pas de
tx.origin(utilisezmsg.sender)
- [ ] Contrôle d'accès sur toutes les fonctions sensibles
- [ ] Custom errors utilisés (gas efficiency)
- [ ] Bibliothèques OpenZeppelin à jour
- [ ] Tests couvrant 100% des branches critiques
- [ ] Slither/Mythril passés sans warnings critiques
- [ ] Audit professionnel effectué (pour TVL > $1M)
- [ ] Testé sur testnet pendant 1+ semaines
- [ ] Plan de réponse aux incidents documenté
- [ ] Mécanisme de pause/upgrade si nécessaire
Cas Réels de Hacks et Leçons
The DAO (2016) - $60M
Vulnérabilité : Reentrancy
Leçon : Checks-effects-interactions toujours
Parity Wallet (2017) - $150M
Vulnérabilité : Fonction d'initialisation non protégée
Leçon : Protégez les constructeurs/initializers
Ronin Bridge (2022) - $625M
Vulnérabilité : Clés privées compromises
Leçon : Multi-sig, hardware wallets, key management
Nomad Bridge (2022) - $190M
Vulnérabilité : Validation de message défaillante
Leçon : Testez TOUS les edge cases
Conclusion : Sécurité = Responsabilité
Un smart contract sécurisé nécessite :
La sécurité n'est pas une feature, c'est un mindset.
Le parcours Sécurité Solidity de Solingo vous forme aux 50+ vulnérabilités connues, avec des labs pratiques de hacking éthique, des audits de code réels, et des exercices CTF (Capture The Flag). Construisez des contrats inviolables — commencez votre formation aujourd'hui.