Les erreurs arithmétiques dans les contrats intelligents Solidity surviennent lorsque les calculs de jetons, parts ou prix utilisent une formule incorrecte, une échelle décimale inappropriée, une direction d’arrondi erronée ou une hypothèse numérique fausse, ce qui permet aux utilisateurs de frapper des parts excessives, retirer plus de valeur qu’ils n’en ont déposée, ou provoquer des violations des invariants comptables. Le remède consiste à définir explicitement les unités, utiliser une arithmétique bornée à pleine précision, et tester les invariants économiques que la formule doit préserver.
Les conséquences sont significatives. Cetus CLMM a perdu 223,0 M$ sur Sui le 22-05-2025 dans un incident classé comme une erreur arithmétique impliquant la comptabilité des jetons et parts. La sécurité arithmétique ne concerne donc pas seulement la prévention des reverts d’overflow en Solidity. Il s’agit de garantir que chaque part frappée représente une créance défendable sur les actifs.
Qu’est-ce que les erreurs arithmétiques en Solidity et comment fonctionnent les attaques ?
Les erreurs arithmétiques en Solidity sont des défauts dans les calculs qui traduisent dépôts, retraits, soldes, prix, frais ou liquidités en créances économiques. Un attaquant exploite le décalage entre le modèle comptable prévu et la formule mise en œuvre, souvent en choisissant l’ordre des transactions, les montants d’actifs ou les états de pool qui rendent une conversion incorrecte favorable.
Dans Solidity moderne, l’overflow et l’underflow arithmétiques génèrent par défaut un revert. Cette protection est utile, mais ne garantit pas qu’une formule soit économiquement correcte. Un calcul peut rester dans les bornes uint256 tout en frappant trop de parts de coffre, facturant trop peu de garantie, ou arrondissant un retrait en faveur de l’utilisateur.
Une attaque typique de part excédentaire suit cette séquence :
- Un coffre détient des actifs et parts existants, donc chaque part a un prix non unitaire.
- Le coffre reçoit des rendements, frais, dons ou une augmentation rebasée du solde.
- Le prix de la part augmente parce que le total des actifs a cru tandis que le total des parts est resté stable.
- Un attaquant dépose des actifs via une formule qui suppose qu’une part vaut toujours un actif.
- Le coffre frappe plus de parts que le dépôt ne devrait en acheter au prix courant.
- L’attaquant échange ces parts contre une fraction disproportionnée des actifs regroupés.
L’invariant fondamental est simple : après un dépôt, la fraction de propriété du nouvel utilisateur doit correspondre à la valeur qu’il a apportée. Si un dépôt valant 1 % des actifs du pool génère plus d’environ 1 % des parts en circulation, les détenteurs existants sont dilués.
Quels modèles Solidity causent des erreurs de comptabilité de jetons et parts ?
Les erreurs de comptabilité de jetons et parts proviennent généralement du mélange d’unités, de la troncature de valeurs intermédiaires, ou du traitement d’un prix de part variable comme fixe. Les fonctions les plus à risque sont deposit, mint, withdraw, redeem, limites d’emprunt, règlement des frais, calculs de liquidité et distribution des récompenses.
Un motif dangereux est de frapper directement des parts à partir des unités d’actifs déposés :
shares = assets * 1e18 / assetScale;
Cette conversion peut être pertinente uniquement pour le premier dépôt, quand une part est délibérément définie comme une unité normalisée d’actif. Une fois qu’un coffre génère un rendement ou subit une perte, les dépôts doivent utiliser le taux de change courant :
shares = assets * totalShares / totalAssets;
Même cette expression nécessite une manipulation soigneuse. Multiplier d’abord peut provoquer un overflow si cela est placé dans un bloc unchecked, tandis que diviser d’abord peut faire perdre de la précision. La normalisation décimale crée une autre source d’erreur. Les actifs de type USDC utilisent souvent moins de décimales qu’un jeton part à 18 décimales, et un protocole doit distinguer de façon cohérente unités brutes de jetons et unités comptables normalisées.
Les causes courantes incluent :
- Codage en dur de
1e18pour un actif avec une précision décimale différente. - Frappage de parts à un taux fixe un pour un après que le prix de la part a changé.
- Division avant multiplication et perte silencieuse de précision significative.
- Arrondi des dépôts ou des retraits vers le haut, créant une fuite cumulative de valeur.
- Usage de
uncheckedautour de valeurs contrôlées par l’utilisateur ou facteurs d’échelle. - Supposer que les soldes de jetons ne peuvent changer qu’au travers des flux normaux de dépôt et retrait.
- Oublier que les jetons avec frais de transfert, rebasing, ou génération de rendement peuvent invalider la comptabilité mise en cache.
Comment corriger les erreurs arithmétiques dans le calcul des parts en Solidity ?
Pour corriger les erreurs arithmétiques dans Solidity, normalisez une fois les unités d’actifs, calculez les parts par rapport au ratio courant du pool, utilisez la multiplication et division à pleine précision, et choisissez un arrondi qui ne transfert pas la valeur des détenteurs existants vers un nouveau déposant. Le contrat doit aussi traiter son solde d’actifs et son offre de parts comme un état comptable lié.
Le coffre vulnérable ci-dessous accepte un jeton avec décimales configurables. Il frappe des parts normalisées à un taux fixe même après qu’une fonction reportYield a augmenté les actifs. Un déposant peut recevoir trop de parts après que du rendement s’est accumulé, puis échanger ces parts contre le pool agrandi.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function transfer(address to, uint256 amount) external returns (bool);
}
contract VulnerableShareVault {
IERC20 public immutable asset;
uint8 public immutable assetDecimals;
uint256 public totalAssets;
uint256 public totalShares;
mapping(address => uint256) public shares;
constructor(address asset_, uint8 decimals_) {
asset = IERC20(asset_);
assetDecimals = decimals_;
}
function deposit(uint256 assets) external {
require(asset.transferFrom(msg.sender, address(this), assets), "transfer failed");
uint256 scale = 10 ** assetDecimals;
// VULNÉRABLE : ignore le prix actuel des parts existantes.
uint256 minted = (assets * 1e18) / scale;
totalAssets += assets;
totalShares += minted;
shares[msg.sender] += minted;
}
function reportYield(uint256 assets) external {
require(asset.transferFrom(msg.sender, address(this), assets), "transfer failed");
totalAssets += assets;
}
function withdraw(uint256 shareAmount) external {
uint256 assets = (shareAmount * totalAssets) / totalShares;
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
totalAssets -= assets;
require(asset.transfer(msg.sender, assets), "transfer failed");
}
}
La version corrigée convertit les unités brutes d’actifs en unités comptables à 18 décimales et frappe des parts au taux de change courant. Elle utilise la fonction full-precision Math.mulDiv d’OpenZeppelin, arrondit à l’inférieur les dépôts et retraits, et borne les valeurs décimales acceptées.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";
interface IERC20 {
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function transfer(address to, uint256 amount) external returns (bool);
}
contract FixedShareVault {
IERC20 public immutable asset;
uint256 public immutable assetScale;
uint256 public totalAssets;
uint256 public totalShares;
mapping(address => uint256) public shares;
constructor(address asset_, uint8 decimals_) {
require(decimals_ <= 18, "unsupported decimals");
asset = IERC20(asset_);
assetScale = 10 ** decimals_;
}
function deposit(uint256 assets) external {
require(assets > 0, "zero assets");
require(asset.transferFrom(msg.sender, address(this), assets), "transfer failed");
uint256 normalizedAssets = Math.mulDiv(assets, 1e18, assetScale);
uint256 normalizedPool = Math.mulDiv(totalAssets, 1e18, assetScale);
// CORRECTION : valorisation des parts selon le ratio pré-dépôt du pool.
uint256 minted = totalShares == 0
? normalizedAssets
: Math.mulDiv(normalizedAssets, totalShares, normalizedPool);
require(minted > 0, "deposit too small");
totalAssets += assets;
totalShares += minted;
shares[msg.sender] += minted;
}
function withdraw(uint256 shareAmount) external {
uint256 assets = Math.mulDiv(shareAmount, totalAssets, totalShares);
shares[msg.sender] -= shareAmount;
totalShares -= shareAmount;
totalAssets -= assets;
require(asset.transfer(msg.sender, assets), "transfer failed");
}
}
Si votre protocole valorise dépôts, retraits, garanties, ou liquidités via une arithmétique personnalisée, un audit de contrat intelligent et une évaluation de sécurité peuvent tracer les conversions d’unités et les invariants économiques sur tout le cycle de vie de la transaction.
Comment les développeurs peuvent-ils cesser de faire des erreurs arithmétiques ?
Les développeurs évitent les erreurs arithmétiques en spécifiant les unités comptables avant d’implémenter les formules, en appliquant un arrondi conservateur, et en traitant chaque conversion comme une frontière de sécurité. Une formule doit indiquer ce que chaque entrée représente, sa précision décimale, sa plage permise, et qui bénéficie de l’arrondi.
| Technique | Ce qu’elle évite | Coût en gas ou complexité | Limitation |
|---|---|---|---|
| Normalisation explicite des unités | Mélange d’unités brutes de jetons avec unités de parts à 18 décimales | Multiplications ou divisions supplémentaires | Nécessite une politique décimale documentée |
Math.mulDiv |
Overflow et perte de précision dans a * b / c |
Plus de gas que des opérateurs simples | Ne valide pas la formule économique |
| Arrondir à l’inférieur dépôts et retraits | Sur-frappe et sur-rédemption dues à l’arrondi | Peut créer des soldes poussières | La poussière doit avoir une politique de récupération |
| Seuil minimum de frappe de part | Dépôts arrondis à zéro part | Une comparaison | Ne doit pas bloquer les petits utilisateurs légitimes |
| Bornes sur entrées et décimales | Exponentiation non sûre et entrées extrêmes utilisateur | Vérifications de validation | Les bornes doivent correspondre aux actifs supportés |
| Tests d’invariants | Transitions d’état violant la comptabilité du pool | Effort d’écriture de tests | Les invariants doivent couvrir des séquences adversariales |
Utilisez l’arrondi inférieur lorsque l’alternative serait d’émettre une valeur que le pool ne peut soutenir. Par exemple, un coffre arrondit normalement les parts déposées à l’inférieur et les actifs retirés à l’inférieur. Si un produit nécessite un arrondi favorable à l’utilisateur, il doit explicitement financer la subvention résultante plutôt que de l’imposer silencieusement aux autres déposants.
Perspective sécurité : Selon la méthodologie Soken, l’examen arithmétique commence par les unités et lois de conservation, pas par les opérateurs. Une multiplication vérifiée peut être exploitable si le résultat représente le mauvais actif, part, prix ou créance de propriété.
Quels incidents réels montrent l’impact des erreurs arithmétiques ?
Les incidents d’erreur arithmétique démontrent que la comptabilité de jetons et parts est une propriété de sécurité de protocole globale et non un simple problème de qualité locale de code. La logique affectée peut se trouver dans les réserves AMM, récompenses de prêt, parts de coffre, calculs de liquidité ou routines de conversion d’actifs.
| Protocole | Date | Montant perdu | Chaîne |
|---|---|---|---|
| Cetus CLMM | 2025-05-22 | 223,0 M$ | Sui |
| Compound V2 | 2021-09-29 | 147,0 M$ | Ethereum |
| Uranium Finance | 2021-04-28 | 57,2 M$ | BSC |
| ValueDefi | 2021-05-07 | 11,0 M$ | BSC |
| KiiChain | 2026-08-22 | 9,7 M$ | KiiChain |
| Truebit | 2026-01-08 | 26,4 M$ | Ethereum |
| Notional V2 | 2026-09-04 | 1,7 M$ | Ethereum |
| USM | 2026-08-09 | 136 K$ | Ethereum |
Ces incidents partagent une caractéristique critique : un calcul gouvernait la distribution ou l’évaluation de valeur on-chain. La défaillance n’était pas forcément une addition uint256 non contrôlée. En comptabilité de jetons et parts, la condition vulnérable est souvent une relation incorrecte entre réserves, offre, prix et créances utilisateurs.
DefiLlama recense 35 incidents d’erreur arithmétique pour contrats intelligents entre 2020 et 2026, avec des pertes cumulées de 561,0 M$. Pour les équipes de protocole, cet historique justifie de revoir les parcours comptables avec la même priorité que le contrôle d’accès et la gestion des appels externes.
Comment détecter les erreurs arithmétiques avant le déploiement ?
Les erreurs arithmétiques se détectent le mieux par la revue des transitions d’état et le test des propriétés économiques sur de nombreuses séquences, plutôt que par la vérification manuelle de formules isolées. L’analyse statique peut identifier des opérateurs risqués et blocs unchecked, tandis que le fuzzing et les tests d’invariants établissent si le modèle comptable tient sous dépôts, retraits, rendement, perte et valeurs décimales inhabituelles.
Utilisez cette liste de contrôle ciblée :
- Listez chaque variable d’actif, part, prix, taux et frais avec son unité et ses décimales.
- Vérifiez que chaque dépôt utilise le taux de change pré-dépôt.
- Vérifiez que chaque retrait utilise un taux cohérent avec les parts brûlées.
- Cherchez les
*,/,uncheckedet constantes d’échelle codées en dur dans les chemins comptables. - Testez des actifs avec précise décimale différente et dépôts proches des tailles minimales.
- Confirmez que l’arrondi ne peut pas frapper des parts non supportées ni libérer des actifs non supportés.
- Modélisez les variations de solde dues au rebasing, dons, frais et rendement avant chaque action utilisateur.
- Vérifiez que les créances totales utilisateur ne peuvent pas dépasser les actifs disponibles à la sortie.
Slither peut signaler des motifs arithmétiques dangereux, blocs non vérifiés et opérations suspectes de précision, mais ne peut déterminer si une formule de prix de part est économiquement saine. Le fuzzing Foundry est plus efficace combiné avec des assertions telles que totalAssets couvrant toutes les créances rachetées, et que le déposant ne profite pas d’un cycle dépôt-retrait immédiat en l’absence de rendement légitime.
Les tests d’invariants doivent générer des séquences multi-utilisateurs : dépôt, rapport de rendement, retrait, retrait partiel, don et dépôts petits répétés. La méthode de revue Soken compare aussi le modèle mathématique prévu avec chaque mise à jour d’état, car une formule correcte appliquée avant plutôt qu’après un changement d’état peut toujours mal valoriser les parts.
La sécurité arithmétique est la discipline qui préserve la vérité économique à travers chaque conversion. Un coffre peut utiliser l’arithmétique contrôlée de Solidity 0.8 et rester vulnérable s’il frappe des créances au mauvais prix, suppose de mauvaises décimales ou arrondit la valeur vers l’extérieur.
Commencez par écrire l’unité et l’invariant pour chaque variable comptable, puis encodez ces invariants dans des tests Foundry avant d’ajouter de nouveaux types d’actifs ou parcours de tarification. Pour approfondir la recherche sur les modèles de sécurité Solidity, visitez le Soken Hub.