Aller au contenu

Articles

Checklist de sécurité pour contrats de vesting ERC-20

Calcul du vesting, révocation, clés admin, transferts de tokens, tests d’invariants Foundry et déploiement : sécurisez vos contrats de vesting ERC-20.

Équipe d’ingénierie de sigmacode.io9 min de lecture

Sur cette page (14)
  1. 1. Le calcul du vesting
  2. 2. Bénéficiaires et révocation
  3. 3. Contrôle d’accès et clés d’administration
  4. 4. Gestion des transferts de tokens
  5. 5. Réentrance et ordre des appels
  6. 6. Timestamps
  7. 7. Événements et transparence
  8. 8. Upgradeabilité : les compromis
  9. 9. Dispositifs d’urgence
  10. 10. Tests
  11. 11. Analyse statique
  12. 12. Déploiement et vérification
  13. 13. Contrôles opérationnels
  14. Pour conclure

Les contrats de vesting ont l’air simples : on bloque des tokens, on les libère au fil du temps, et voilà. En pratique, ils détiennent pendant des années une part importante de l’offre d’un projet, ils sont manipulés par des fondateurs, des investisseurs, des salariés et des multisigs, et on y revient rarement une fois déployés. Une petite erreur dans le calcul ou dans le modèle de permissions reste en production pendant toute la période de vesting. Cette checklist rassemble les questions que nous nous posons lorsque nous concevons ou relisons un contrat de vesting ERC-20, de l’arithmétique jusqu’au déploiement et à l’exploitation au quotidien.

1. Le calcul du vesting#

Le cœur de tout contrat de vesting est une fonction qui répond à la question « combien de tokens sont acquis à l’instant t ? ». Presque tous les bugs de vesting sérieux se logent ici.

Calendrier linéaire et cliff#

  • Définissez le calendrier avec des paramètres explicites : start, cliff, duration, totalAllocation. Évitez les valeurs implicites dérivées de block.timestamp au moment du déploiement.
  • Décidez de ce que signifie le cliff. Les modèles courants sont « rien avant le cliff, puis rattrapage linéaire depuis start » et « rien avant le cliff, puis un versement en bloc, puis du linéaire ». Consignez le modèle retenu dans le NatSpec et dans les tests.
  • Validez à la création : duration supérieure à zéro, cliff pas au-delà de start + duration, totalAllocation supérieure à zéro, bénéficiaire différent de l’adresse zéro.
  • Après start + duration, le montant acquis doit être exactement égal à totalAllocation, et non « à peu près ».

Arrondis#

  • Multipliez avant de diviser. total * elapsed / duration est correct ; total / duration * elapsed perd silencieusement des tokens à chaque libération.
  • L’arrondi doit toujours être en faveur du contrat : arrondissez à l’inférieur ce que le bénéficiaire peut réclamer, jamais au supérieur. La dernière libération, en fin de calendrier, solde les poussières restantes.
  • Vérifiez les dépassements (overflow) sur les grosses allocations de tokens à 18 décimales. Solidity 0.8.x fait un revert en cas d’overflow, mais un revert dans vestedAmount peut bloquer toutes les réclamations. Utilisez Math.mulDiv d’OpenZeppelin si le produit peut devenir grand.

Début dans le passé ou dans le futur#

  • Un start dans le passé est légitime (attributions antidatées à des salariés), mais il rend un montant important réclamable immédiatement. Faites-en une décision explicite et relue, non la conséquence accidentelle d’un mauvais paramètre.
  • Un start très éloigné dans le futur peut être une faute de frappe (des millisecondes au lieu de secondes, un classique). Ajoutez des bornes de cohérence dans le constructeur ou la factory, ainsi que dans le script de déploiement.

Une implémentation de référence compacte du calendrier :

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

import {Math} from "@openzeppelin/contracts/utils/math/Math.sol";

function vestedAmount(
    uint256 total,
    uint64 start,
    uint64 cliff,
    uint64 duration,
    uint64 timestamp
) pure returns (uint256) {
    if (timestamp < cliff) return 0;
    if (timestamp >= start + duration) return total;
    // Multiply before divide; mulDiv avoids intermediate overflow.
    return Math.mulDiv(total, timestamp - start, duration);
}

2. Bénéficiaires et révocation#

  • Qui peut réclamer ? Uniquement le bénéficiaire, ou n’importe qui pour son compte ? Laisser n’importe qui déclencher release() ne pose pas de problème tant que les tokens vont toujours au bénéficiaire ; cela facilite la récupération en cas de clé perdue et l’automatisation.
  • Le bénéficiaire peut-il changer ? Si oui, exigez que le bénéficiaire actuel en prenne l’initiative (idéalement un transfert en deux étapes avec acceptation) et émettez un événement. Un bénéficiaire qui est un contrat doit pouvoir recevoir et utiliser les tokens.
  • Modèle de révocation. Décidez, calendrier par calendrier, s’il est révocable. En cas de révocation, le montant déjà acquis mais non encore libéré doit rester réclamable par le bénéficiaire ; seul le reliquat non acquis retourne à la trésorerie. Révoquer des tokens acquis est un problème de confiance, pas seulement un problème de code.
  • La révocation doit être définitive. Un calendrier révoqué ne doit pas pouvoir être révoqué deux fois, ne doit pas continuer à acquérir des tokens et ne doit pas permettre à l’administrateur de retirer plus que le reliquat non acquis.
  • Plusieurs calendriers par bénéficiaire. Indexez les calendriers par un identifiant, et non par la seule adresse, afin qu’une seconde attribution n’écrase pas la première.

3. Contrôle d’accès et clés d’administration#

  • Listez toutes les fonctions privilégiées : création de calendriers, révocation, mise en pause, retrait du surplus, mise à niveau. Chacune est une surface d’attaque si la clé est compromise.
  • Utilisez Ownable2Step ou AccessControl avec des rôles distincts plutôt qu’un propriétaire unique et tout-puissant. Le rôle qui crée les calendriers n’a pas à être celui qui peut retirer des fonds.
  • Confiez les rôles d’administration à un multisig, et envisagez un timelock pour tout ce qui fait sortir des tokens du contrat.
  • L’administrateur ne doit jamais pouvoir retirer des tokens engagés dans des calendriers. Suivez totalCommitted et n’autorisez, pour le surplus, que le retrait de balance - totalCommitted.
  • Anticipez l’état final : peut-on renoncer aux droits d’administration une fois tous les calendriers créés ? Moins de clés actives, c’est moins de modes de défaillance.

4. Gestion des transferts de tokens#

  • Utilisez SafeERC20 d’OpenZeppelin pour chaque transfert. Certains tokens ne renvoient pas de booléen, d’autres renvoient false au lieu de faire un revert.
  • Tokens fee-on-transfer. Si le contrat est provisionné avec un token qui prélève des frais, il reçoit moins que le montant nominal. Mesurez le solde avant et après le provisionnement et enregistrez ce qui est réellement arrivé, ou rejetez explicitement ces tokens.
  • Tokens à rebasing. Des soldes qui changent d’eux-mêmes invalident l’hypothèse balance == committed + surplus. Documentez que les tokens à rebasing ne sont pas pris en charge, ou concevez la comptabilité en parts.
  • Fixez l’adresse du token en immutable si le contrat ne sert qu’un seul token. Accepter des adresses de token arbitraires pour chaque calendrier élargit considérablement la surface d’attaque.
  • Ne permettez jamais de « récupérer » le token en vesting via une fonction générique recoverERC20 sans soustraire les montants engagés.

5. Réentrance et ordre des appels#

  • Respectez checks-effects-interactions : mettez à jour released avant d’appeler safeTransfer.
  • Ajoutez nonReentrant à release, à revoke et à toute fonction de retrait. Des hooks de type ERC-777 ou un token malveillant peuvent rappeler le contrat.
  • Limitez les appels externes au strict minimum. Un contrat de vesting n’a aucune raison d’appeler des adresses arbitraires.
solidity
function release(uint256 scheduleId) external nonReentrant {
    Schedule storage s = schedules[scheduleId];
    uint256 amount = _releasable(s);
    require(amount > 0, "Vesting: nothing to release");

    s.released += amount;          // effects first
    totalCommitted -= amount;

    token.safeTransfer(s.beneficiary, amount); // interaction last
    emit TokensReleased(scheduleId, s.beneficiary, amount);
}

6. Timestamps#

  • Utilisez block.timestamp, pas les numéros de bloc. Les temps de bloc diffèrent d’une chaîne à l’autre et évoluent avec le temps ; le même contrat sera peut-être déployé plus tard sur un L2.
  • L’influence des validateurs sur les timestamps se limite à quelques secondes. C’est sans importance pour des calendriers qui se comptent en mois, mais ne construisez pas de logique qui dépende d’une précision à la seconde.
  • Stockez les timestamps en uint64. C’est suffisant pour tout calendrier réaliste et cela se range bien dans le storage.
  • Testez explicitement les bornes : une seconde avant le cliff, exactement au cliff, exactement à la fin, et longtemps après la fin.

7. Événements et transparence#

Chaque changement d’état doit émettre un événement : ScheduleCreated, TokensReleased, ScheduleRevoked, BeneficiaryChanged, changements de rôle et mises en pause. Les événements sont ce sur quoi s’appuient les indexeurs, les tableaux de bord et votre propre équipe de support. Incluez-y l’identifiant du calendrier et les montants, pas seulement des adresses. Investisseurs et salariés demanderont combien de tokens sont acquis, et les événements on-chain sont la réponse la plus crédible.

8. Upgradeabilité : les compromis#

OptionAvantageRisque
Contrat immuableGarantie la plus forte pour les bénéficiaires, audit plus simpleLes bugs ne peuvent pas être corrigés ; une migration exige un nouveau contrat et de nouveaux fonds
Proxy upgradeableLes bugs peuvent être corrigésLa clé d’upgrade peut changer n’importe quelle règle, erreurs de storage layout, périmètre d’audit plus large
Immuable avec factoryChaque ensemble de calendriers est isolé, nouvelles versions pour les nouvelles attributionsLes anciennes instances gardent leurs anciens bugs

Pour le vesting, l’immuabilité est souvent le meilleur choix par défaut : toute la raison d’être du contrat est que personne ne puisse modifier l’accord après coup. Si vous optez pour un proxy, placez le rôle d’upgrade derrière un multisig et un timelock, utilisez des storage gaps ou un namespaced storage, et exécutez dans la CI les contrôles de sécurité des upgrades d’OpenZeppelin.

9. Dispositifs d’urgence#

  • Une pause peut protéger contre un bug inconnu, mais une pause qui bloque release indéfiniment est aussi un moyen de geler les bénéficiaires. Envisagez de limiter la durée d’une pause, ou d’autoriser les libérations même lorsque la création de nouveaux calendriers est suspendue.
  • Documentez qui peut mettre en pause, dans quelles conditions, et comment la communauté sera informée.
  • Évitez les fonctions du type « tout retirer en urgence ». Si l’une d’elles est inévitable, elle doit se trouver derrière un timelock et figurer dans la documentation que lisent les investisseurs.

10. Tests#

Les tests unitaires sont le minimum. Pour les contrats de vesting, les tests fondés sur des propriétés apportent beaucoup, car le calcul doit tenir pour n’importe quel instant et n’importe quel montant.

  • Tests unitaires : chaque chemin de revert, chaque timestamp limite, révocation avant le cliff, révocation après acquisition complète, plusieurs calendriers pour un même bénéficiaire.
  • Tests de fuzzing : total, duration et timestamp aléatoires ; vérifiez que vestedAmount est monotone et ne dépasse jamais total.
  • Tests d’invariants : laissez Foundry appeler release, revoke, createSchedule et vm.warp dans un ordre aléatoire, puis vérifiez les propriétés globales.

Des invariants utiles :

  • La somme des montants libérés d’un calendrier ne dépasse jamais son allocation.
  • Le solde en tokens du contrat est toujours au moins égal à totalCommitted.
  • Un calendrier révoqué n’acquiert plus jamais de montant par la suite.
solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {Test} from "forge-std/Test.sol";

contract VestingInvariants is Test {
    VestingHandler handler;

    function setUp() public {
        handler = new VestingHandler(); // deploys token + vesting, exposes bounded actions
        targetContract(address(handler));
    }

    function invariant_releasedNeverExceedsAllocation() public view {
        uint256 n = handler.vesting().scheduleCount();
        for (uint256 i; i < n; i++) {
            (, uint256 total, uint256 released) = handler.vesting().scheduleInfo(i);
            assertLe(released, total);
        }
    }

    function invariant_balanceCoversCommitments() public view {
        assertGe(
            handler.token().balanceOf(address(handler.vesting())),
            handler.vesting().totalCommitted()
        );
    }
}

11. Analyse statique#

Exécutez Slither à chaque modification et traitez sa sortie comme une file de points à relire, non comme un signal réussite/échec. Pour les contrats de vesting, soyez attentif aux alertes de réentrance, aux transferts non vérifiés, aux égalités strictes dangereuses sur les soldes et aux événements manquants.

bash
slither . --filter-paths "lib|test" --exclude-dependencies
forge test --fuzz-runs 10000
forge coverage --report summary

Une pré-revue assistée par IA, comme notre relecteur IA de smart contracts, constitue une autre première passe rapide, qui met en évidence les motifs suspects avant qu’un humain ne regarde le code.

12. Déploiement et vérification#

  • Scriptez le déploiement avec des scripts Foundry, pas avec des transactions manuelles. Les paramètres vivent dans des fichiers de configuration versionnés et sont relus comme du code.
  • Déployez d’abord sur un testnet avec exactement le même script et les mêmes paramètres, puis faites un essai à blanc sur un fork du mainnet.
  • Vérifiez le code source sur l’explorateur de blocs aussitôt après le déploiement, avec la même version du compilateur et les mêmes réglages de l’optimiseur.
  • Revérifiez les décimales : une allocation de 1 000 000 de tokens à 18 décimales s’écrit 1_000_000e18, pas 1_000_000.
  • Transférez la propriété au multisig dans le même script, et confirmez que la clé du déployeur ne détient plus aucun rôle.

13. Contrôles opérationnels#

  • Rapprochez régulièrement les chiffres : la somme des allocations des calendriers, diminuée des libérations, doit correspondre au montant engagé et au solde du contrat.
  • Surveillez les événements et déclenchez des alertes en cas de révocation, de changement de rôle ou de pause inattendus.
  • Tenez à jour une vue d’ensemble des calendriers, publique ou destinée aux investisseurs, pour que l’on puisse répondre aux questions à partir des données on-chain.
  • Répétez les procédures clés : la rotation des signataires du multisig, ce qui se passe si un bénéficiaire perd l’accès à son wallet, et la manière dont une pause serait communiquée.

Pour conclure#

Une checklist et une revue automatisée détectent tôt de nombreux problèmes, mais elles ne remplacent pas un audit de sécurité indépendant. Avant qu’un contrat de vesting ne détienne de la valeur réelle, faites-le relire par des personnes qui ne l’ont pas écrit.

Si vous voulez voir comment nous abordons le sujet en pratique, consultez notre projet vitrine Token Suite, qui comprend un contrat de vesting avec ses tests, ou découvrez nos services blockchain. Notre équipe est dirigée par un tech lead avec plus de 20 ans d’expérience, et nous relisons volontiers votre tokenomics ou votre conception du vesting – il suffit de nous contacter.

Vous avez un projet en tête ?

Décrivez votre projet en quelques lignes. Un ingénieur senior vous répond sous 24 heures les jours ouvrés ; suivent un avis honnête, un périmètre clair et une proposition à prix fixe ou par jalons, en général en quelques jours ouvrés.

Vous préférez d’abord écrire ? Écrivez-nous

Vous échangez directement avec Ing. Ismet Mesic, Tech lead.