Tutoriel·8 min de lecture·Par Solingo

Les erreurs personnalisees en Solidity : des reverts moins chers et plus clairs

Les erreurs personnalisees reduisent le gas, allegent le bytecode et rendent les echecs auto-documentes. Voici comment les definir, faire un revert avec des arguments, les decoder dans Foundry, et quand un simple require reste preferable.

# Les erreurs personnalisees en Solidity : des reverts moins chers et plus clairs

Chaque contrat que vous ecrivez finira par faire un revert. La vraie question, c'est comment. Pendant des annees, la reponse par defaut etait require(condition, "un message"), et cette chaine de caracteres vous coutait discretement du gas a chaque deploiement tout en ajoutant du bytecode dont vous n'aviez presque jamais besoin. Les erreurs personnalisees, disponibles depuis Solidity 0.8.4, offrent une maniere plus propre, moins chere et plus expressive de signaler un echec. Ce guide couvre leur definition, le revert avec arguments, les selecteurs d'erreur, leur decodage dans les tests Foundry, et les cas ou un simple require reste le bon choix.

Le probleme des messages dans require

Un message de revert est stocke dans le bytecode du contrat et encode en ABI au moment de l'execution. Prenons ce motif familier :

function withdraw(uint256 amount) external {

require(amount <= balances[msg.sender], "Insufficient balance");

// ...

}

Ce litteral "Insufficient balance" reste dans votre bytecode deploye pour toujours. Chaque chaine que vous ajoutez gonfle le cout de deploiement. A l'execution, lorsque le test echoue, l'EVM encode la chaine dans le selecteur Error(string), ce qui est plus couteux que d'encoder une erreur personnalisee compacte. Plus vos messages sont longs et nombreux, plus la facture grimpe a l'echelle d'un vrai codebase.

Definir et faire un revert avec des erreurs personnalisees

Une erreur personnalisee se declare avec le mot-cle error et se declenche avec l'instruction revert :

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.4;

contract Vault {

mapping(address => uint256) public balances;

error InsufficientBalance(uint256 requested, uint256 available);

error ZeroAmount();

function withdraw(uint256 amount) external {

if (amount == 0) {

revert ZeroAmount();

}

uint256 bal = balances[msg.sender];

if (amount > bal) {

revert InsufficientBalance(amount, bal);

}

balances[msg.sender] = bal - amount;

(bool ok, ) = msg.sender.call{value: amount}("");

require(ok, "transfer failed");

}

}

Observez ce qu'on gagne. InsufficientBalance(amount, bal) ne dit pas seulement que quelque chose a echoue : elle indique a l'appelant exactement combien a ete demande et combien etait disponible. Ces arguments sont encodes a l'execution et visibles par les outils off-chain, mais les noms de parametres et le texte descriptif ne sont pas figes dans le bytecode sous forme de chaine.

Les erreurs personnalisees peuvent etre declarees :

  • A l'interieur d'un contrat, avec une portee limitee a ce contrat.
  • Au niveau du fichier, pour que plusieurs contrats et bibliotheques les partagent.
  • Dans une interface ou une bibliotheque, puis referencees comme ILib.SomeError.

Les erreurs partagees au niveau du fichier sont un bon moyen de garder une semantique d'echec coherente dans tout un projet sans dupliquer les definitions.

Les selecteurs d'erreur : ce qui circule reellement

Quand vous faites un revert avec une erreur personnalisee, l'EVM renvoie des donnees encodees en ABI qui commencent par un selecteur de 4 octets, exactement comme un appel de fonction. Le selecteur est constitue des quatre premiers octets du hash keccak256 de la signature de l'erreur. Pour InsufficientBalance(uint256,uint256) :

bytes4 selector = bytes4(keccak256("InsufficientBalance(uint256,uint256)"));

// de maniere equivalente en Solidity moderne :

bytes4 selector2 = Vault.InsufficientBalance.selector;

Le payload complet du revert est le selecteur suivi des arguments encodes en ABI, ce que produirait abi.encodeWithSelector(Vault.InsufficientBalance.selector, amount, bal). C'est conceptuellement identique au fonctionnement interne des erreurs integrees Error(string) et Panic(uint256). Le prefixe de 4 octets explique precisement pourquoi l'empreinte on-chain est si petite : un message doit encoder tout le texte, alors qu'une erreur personnalisee encode un selecteur fixe plus uniquement les donnees que vous choisissez de passer.

Comme les selecteurs derivent de la signature, deux erreurs portant le meme nom mais des types de parametres differents sont des erreurs differentes. Renommer une erreur ou changer ses parametres change le selecteur, ce qui compte si des outils externes s'appuient dessus.

Intercepter une erreur personnalisee dans un autre contrat

Vous pouvez reagir a un type d'erreur precis avec un catch type :

interface IVault {

error InsufficientBalance(uint256 requested, uint256 available);

function withdraw(uint256 amount) external;

}

contract Caller {

event Shortfall(uint256 requested, uint256 available);

function tryWithdraw(IVault vault, uint256 amount) external {

try vault.withdraw(amount) {

// chemin de succes

} catch (bytes memory reason) {

bytes4 sel = bytes4(reason);

if (sel == IVault.InsufficientBalance.selector) {

(uint256 requested, uint256 available) =

abi.decode(_slice(reason), (uint256, uint256));

emit Shortfall(requested, available);

} else {

revert("unknown failure");

}

}

}

}

En pratique, vous lisez les 4 premiers octets pour identifier l'erreur, puis vous decodez en ABI les octets restants (tout ce qui suit le selecteur) pour recuperer les arguments. L'aide _slice ci-dessus represente le fait de retirer le selecteur de tete avant abi.decode. Ce motif permet a un contrat appelant de prendre des decisions selon la raison de l'echec, pas seulement selon son existence.

Decoder les erreurs personnalisees dans les tests Foundry

Foundry prend en charge nativement les erreurs personnalisees, ce qui rend leur test agreable. Pour verifier qu'un appel echoue avec une erreur precise, utilisez vm.expectRevert avec le selecteur de l'erreur ou l'erreur entierement encodee :

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.13;

import "forge-std/Test.sol";

import "../src/Vault.sol";

contract VaultTest is Test {

Vault vault;

function setUp() public {

vault = new Vault();

}

function test_RevertWhen_AmountIsZero() public {

vm.expectRevert(Vault.ZeroAmount.selector);

vault.withdraw(0);

}

function test_RevertWhen_Insufficient() public {

// on attend l'erreur AVEC ses arguments exacts

vm.expectRevert(

abi.encodeWithSelector(

Vault.InsufficientBalance.selector,

100,

0

)

);

vault.withdraw(100);

}

}

Deux niveaux de rigueur sont possibles :

  • Passez seulement le selecteur (Vault.ZeroAmount.selector) pour ne verifier que le type d'erreur.
  • Passez l'erreur entierement encodee via abi.encodeWithSelector(...) pour verifier aussi les valeurs exactes des arguments.
  • Quand un test echoue a cause d'un revert inattendu, Foundry decode l'erreur personnalisee pour vous dans la trace, affichant le nom de l'erreur et les arguments decodes, tant que la definition de l'erreur est dans la portee. Vous pouvez aussi faire remonter les reverts decodes dans n'importe quelle execution avec des traces verbeuses :

    forge test -vvvv

    Cela imprime des traces d'appel completes avec les erreurs personnalisees decodees, si bien qu'un InsufficientBalance(100, 0) en echec se lit exactement comme cela plutot qu'en hexa brut.

    Quand utiliser quand meme require

    Les erreurs personnalisees sont le choix par defaut, mais require n'a pas disparu, et le Solidity moderne permet meme a require de prendre directement une erreur personnalisee. Quelques reperes :

    • Les gardes booleennes simples sans donnees peuvent rester en require(cond, "...") pour la lisibilite, surtout dans de petits contrats ou des scripts ou la taille du bytecode est sans importance.
    • Les resultats d'appels tiers ou bas niveau, comme le require(ok, "transfer failed") ci-dessus, sont un usage courant et tout a fait acceptable d'une courte chaine.
    • Dans les versions recentes du compilateur, vous pouvez ecrire require(cond, CustomError(arg)), ce qui donne l'ergonomie de require avec le profil de gas d'une erreur personnalisee.
    • Utilisez des erreurs personnalisees des que vous voulez transmettre du contexte (montants, adresses, identifiants), quand vous avez de nombreux modes d'echec distincts, ou quand des contrats et outils externes doivent brancher selon la raison de l'echec.

    L'instruction assert est un outil distinct : elle declenche un Panic(uint256) et sert aux invariants qui ne devraient jamais etre faux, pas a la validation des entrees.

    A vous de pratiquer sur Solingo

    Le moyen le plus rapide d'integrer tout cela, c'est d'ecrire des reverts, de les casser exprès et d'observer la sortie decodee. Sur app.solingo-blockchain.xyz, vous pouvez definir des erreurs personnalisees, faire des reverts avec arguments et lancer des assertions a la Foundry dans des exercices interactifs, avec les differences de gas et de bytecode affichees cote a cote face a l'ancienne approche par chaine dans require. Essayez de convertir un contrat rempli de messages require en erreurs personnalisees et comparez vous-meme le cout de deploiement.

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement