Évitez les erreurs arithmétiques dans les contrats Solidity

Auteur de l’article

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 :

  1. Un coffre détient des actifs et parts existants, donc chaque part a un prix non unitaire.
  2. Le coffre reçoit des rendements, frais, dons ou une augmentation rebasée du solde.
  3. Le prix de la part augmente parce que le total des actifs a cru tandis que le total des parts est resté stable.
  4. Un attaquant dépose des actifs via une formule qui suppose qu’une part vaut toujours un actif.
  5. Le coffre frappe plus de parts que le dépôt ne devrait en acheter au prix courant.
  6. 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 1e18 pour 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 unchecked autour 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 :

  1. Listez chaque variable d’actif, part, prix, taux et frais avec son unité et ses décimales.
  2. Vérifiez que chaque dépôt utilise le taux de change pré-dépôt.
  3. Vérifiez que chaque retrait utilise un taux cohérent avec les parts brûlées.
  4. Cherchez les *, /, unchecked et constantes d’échelle codées en dur dans les chemins comptables.
  5. Testez des actifs avec précise décimale différente et dépôts proches des tailles minimales.
  6. Confirmez que l’arrondi ne peut pas frapper des parts non supportées ni libérer des actifs non supportés.
  7. Modélisez les variations de solde dues au rebasing, dons, frais et rendement avant chaque action utilisateur.
  8. 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.

Auteur de l’article

Questions fréquemment posées

Quelles sont les erreurs arithmétiques dans les smart contracts Solidity ?

Les erreurs arithmétiques dans Solidity sont des défauts de calcul affectant dépôts, retraits, soldes, prix, frais, liquidité, tokens ou parts. Elles peuvent provoquer des réclamations excessives, retraits trop élevés ou échecs d'invariants comptables dus à des formules, unités décimales, directions d'arrondi ou hypothèses numériques incorrectes.

Comment les erreurs arithmétiques laissent-elles retirer trop d'utilisateurs ?

Les erreurs arithmétiques en Solidity peuvent permettre à des utilisateurs de générer des réclamations excessives ou de retirer plus que leur dépôt si les calculs de tokens, parts ou prix utilisent une formule, une échelle décimale, une direction d'arrondi ou une hypothèse numérique erronée. Cela crée une réclamation économique non soutenue par les actifs.

Comment prévenir les erreurs arithmétiques en Solidity ?

Pour prévenir ces erreurs, définissez explicitement les unités de chaque calcul, utilisez une arithmétique bornée à pleine précision et testez les invariants économiques que la formule doit respecter. Ces mesures corrigent formules fausses, échelles décimales, directions d'arrondi et hypothèses numériques qui peuvent fausser les réclamations sur tokens, parts ou prix.

Les reverts d'overflow suffisent-ils à stopper les erreurs arithmétiques en Solidity ?

Non, les reverts d'overflow ne suffisent pas. La sécurité arithmétique nécessite aussi des formules correctes, des unités explicites, des échelles décimales appropriées, la bonne direction d'arrondi et des hypothèses numériques solides. Un calcul peut éviter un revert tout en attribuant une part ou un retrait injustifié.

Que nous apprend la perte de Cetus CLMM sur les erreurs arithmétiques ?

La perte de $223M sur Sui par Cetus CLMM, une erreur arithmétique en comptabilité des tokens et parts, montre que la sécurité arithmétique ne se limite pas à éviter les reverts d'overflow Solidity. Chaque part créée doit représenter une réclamation défendable sur les actifs.

Chat