Comprendre les transactions Ethereum (gas, reverts)
Duree estimee : 30 minutes
0. Connexion a la blockchain locale
Tous les contrats de ce notebook sont compiles et deployes reellement sur anvil. Lancez anvil dans un terminal avant d’executer les cellules.
# Connection a anvil (blockchain locale Foundry)# Prerequis: anvil en cours d'execution dans un terminalfrom web3 import Web3import solcxSOLC_VERSION ="0.8.28"ANVIL_URL ="http://127.0.0.1:8545"# Connexionw3 = Web3(Web3.HTTPProvider(ANVIL_URL))assert w3.is_connected(), f"Impossible de se connecter a {ANVIL_URL}. Lancez 'anvil' dans un terminal."# Installer solc si necessaireinstalled = [str(v) for v in solcx.get_installed_solc_versions()]if SOLC_VERSION notin installed: solcx.install_solc(SOLC_VERSION)solcx.set_solc_version(SOLC_VERSION)deployer = w3.eth.accounts[0]print(f"Connecte a anvil (chain {w3.eth.chain_id}), deployer: {deployer[:10]}...")def compile_and_deploy(w3, source_code, deployer, *constructor_args):"""Compiler et deployer un contrat Solidity (source a un seul contrat).""" compiled = solcx.compile_source( source_code, output_values=["abi", "bin"], solc_version=SOLC_VERSION ) contract_id, contract_interface = compiled.popitem() Contract = w3.eth.contract( abi=contract_interface["abi"], bytecode=contract_interface["bin"] ) tx_hash = Contract.constructor(*constructor_args).transact({"from": deployer}) receipt = w3.eth.wait_for_transaction_receipt(tx_hash) instance = w3.eth.contract( address=receipt.contractAddress, abi=contract_interface["abi"] )print(f"Deploye: {contract_id.split(':')[-1]} a {receipt.contractAddress}")return instance, receiptdef deploy_named(w3, source_code, contract_name, deployer, *constructor_args):"""Compiler une source multi-contrats et deployer un contrat specifique par nom.""" compiled = solcx.compile_source( source_code, output_values=["abi", "bin"], solc_version=SOLC_VERSION )for contract_id, contract_interface in compiled.items():if contract_id.split(':')[-1] == contract_name: Contract = w3.eth.contract( abi=contract_interface["abi"], bytecode=contract_interface["bin"] ) tx_hash = Contract.constructor(*constructor_args).transact({"from": deployer}) receipt = w3.eth.wait_for_transaction_receipt(tx_hash) instance = w3.eth.contract( address=receipt.contractAddress, abi=contract_interface["abi"] )print(f"Deploye: {contract_name} a {receipt.contractAddress}")return instance, receiptraiseValueError(f"Contrat '{contract_name}' introuvable dans la source compilee")
Connecte a anvil (chain 31337), deployer: 0xf39Fd6e5...
Lecture du résultat : blockchain locale anvil connectée
Connecte a anvil (chain 31337) — anvil est la blockchain locale de développement de Foundry (chain ID 31337 par convention). Contrairement à un simulateur en mémoire, anvil est une vraie EVM : chaque transaction est minée, le gas est consommé, les reverts annulent l’état. Les helpers compile_and_deploy / deploy_named compilent le source Solidity via solcx (solc 0.8.28), déploient le bytecode et retournent une instance web3.contract prête à interroger. Tout le notebook interagit avec des contrats réellement déployés — les reverts, events et soldes ci-dessous sont le comportement on-chain authentique, pas une simulation.
1. Gestion des erreurs
Fonction
Usage
Gas remboursé
require
Conditions d’entree
Oui
revert
Erreurs complexes
Oui
assert
Invariants internes
Non
# Gestion des erreursERROR_HANDLING ='''// SPDX-License-Identifier: MITpragma solidity ^0.8.28;contract ErrorHandling { address public owner; uint256 public value; constructor() { owner = msg.sender; } function setValue(uint256 _value) public { require(msg.sender == owner, "Not owner"); require(_value > 0, "Value must be positive"); value = _value; } function complexCheck(uint256 a, uint256 b) public pure { if (a == 0) { revert("A cannot be zero"); } if (b == 0) { revert("B cannot be zero"); } if (a + b > 100) { revert("Sum too large"); } } function invariant() public view { assert(owner != address(0)); }}'''print("--- require / revert / assert ---")eh, _ = compile_and_deploy(w3, ERROR_HANDLING, deployer)print(f" Deploye : {eh.address}")# require : not ownerother = w3.eth.accounts[1]try: eh.functions.setValue(42).transact({'from': other})exceptException:print(" setValue(42) par other → revert 'Not owner' (attendu)")# require : value must be positivetry: eh.functions.setValue(0).transact({'from': deployer})exceptException:print(" setValue(0) → revert 'Value must be positive' (attendu)")# require : successeh.functions.setValue(42).transact({'from': deployer})print(f" setValue(42) par owner → value = {eh.functions.value().call()}")# revert : sum too largetry: eh.functions.complexCheck(60, 50).transact({'from': deployer})exceptException:print(" complexCheck(60, 50) → revert 'Sum too large' (attendu)")# assert : invariant holdseh.functions.invariant().call()print(" invariant() → passe (owner != address(0))")
--- require / revert / assert ---
Deploye: ErrorHandling a 0x4A679253410272dd5232B3Ff7cF5dbB88f295319
Deploye : 0x4A679253410272dd5232B3Ff7cF5dbB88f295319
setValue(42) par other → revert 'Not owner' (attendu)
setValue(0) → revert 'Value must be positive' (attendu)
setValue(42) par owner → value = 42
complexCheck(60, 50) → revert 'Sum too large' (attendu)
invariant() → passe (owner != address(0))
Lecture du résultat : require / revert / assert, le triptyque des erreurs
Les trois appels illustrent la sémantique gas des erreurs Solidity :
require (conditions d’entrée) : setValue(42) par other → revert « Not owner ». Gas remboursé — le state change est annulé, l’appelant récupère le gaz non consommé.
revert (erreurs complexes, multi-conditions) : complexCheck(60, 50) → « Sum too large ». Gas remboursé. Préféré quand la condition tient sur plusieurs lignes.
assert (invariants internes) : invariant() passe (owner ≠ address(0)). Si un assert échoue, le gas n’est PAS remboursé et toute la transaction consomme son gaz — sanction maximale, réservée aux bugs (un invariant violé = logique cassée, pas une erreur utilisateur légitime).
2. Custom Errors (Solidity 0.8.4+)
Les custom errors sont plus économiques en gas que les strings. Mécanisme : au lieu de require(cond, "Message long et couteux"), on déclare error InsufficientBalance(uint256 available, uint256 required) puis revert InsufficientBalance(...).
À l’exécution, seuls le selector (4 octets = hash tronqué de la signature) et les paramètres encodés sont stockés dans les données de revert — pas de string. Comme une string coûte ~50 gas par byte en calldata, l’économie devient massive sur des milliers d’appels. C’est pourquoi les protocoles de production (Uniswap v3+, OpenZeppelin) n’utilisent plus que des custom errors. Bonus : le client (web3.py, ethers.js) peut décoder les paramètres pour afficher un message riche (« balance 0.5 ETH < 1 ETH requis ») sans que la string n’ait jamais vécu on-chain.
--- Custom errors avec parametres ---
Deploye: CustomErrors a 0x09635F643e140090A9A8Dcd712eD6285858ceBef
Apres deposit(0.5 ETH) : balance = 0.5 ETH
withdraw(1 ETH) → revert InsufficientBalance (attendu)
transfer(0) → revert InvalidAmount (attendu)
Apres transfer(0.1 ETH) : receiver balance = 0.1 ETH
Lecture du résultat : custom errors, l’optimisation gas post-0.8.4
InsufficientBalance(available, required) est une custom error (Solidity ≥ 0.8.4). Avantage sur les strings require(cond, "msg") : à l’exécution, seuls le selector (4 octets) + les paramètres encodés transitent dans les données de revert — pas de string (qui coûte ~50 gas/byte en calldata). Sur des milliers de transactions, l’économie est massive — c’est pourquoi les protocoles de production (Uniswap, OpenZeppelin) n’utilisent plus que des custom errors. Le revert InsufficientBalance(...) déclenche le revert en transmettant available et required, que le client décode pour un message riche sans le coût on-chain d’une string.
3. Events
Les events permettent de logger des données sur la blockchain. Techniquement, un emit produit un log stocké dans la receipt de la transaction — pas dans le storage du contrat (donc peu coûteux en gas), mais parsable par tout observateur.
C’est le seul canal de sortie d’un contrat vers l’extérieur : le storage est lisible mais ne dit rien de l’historique des actions. Les events comblent ce vide — les dApps, Etherscan et les indexeurs (The Graph) les écoutent pour suivre l’activité on-chain en temps réel (transferts de tokens, changement de propriétaire, votes). Un event déclaré event Deposit(address indexed from, uint256 amount) génère, à chaque emit, un log dont on extrait les champs via process_receipt.
--- Events : Deposit, Transfer, OwnershipTransferred ---
Deploye: EventsExample a 0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E
Deposit event : from=0xf39Fd6e5..., amount=1 ETH
Transfer event : from=0xf39Fd6e5..., to=0x70997970..., amount=0.3 ETH
OwnershipTransferred : previous=0xf39Fd6e5..., new=0x3C44CdDd...
Lecture du résultat : events, le logging on-chain
Les events (Deposit, Transfer, OwnershipTransferred) sont la seule façon pour un contrat d’émettre de l’information vers l’extérieur : ils produisent des logs stockés dans les receipts de transaction (pas dans le storage, donc peu coûteux). Les dApps et indexeurs (The Graph, Etherscan) écoutent ces logs pour suivre l’activité on-chain en temps réel. Ici, chaque emit génère un log lu via process_receipt : on extrait from, amount, previousOwner/newOwner — la traçabilité complète de l’exécution. Sans events, le contrat serait une boîte noire (le storage est lisible, mais l’historique des actions serait perdu).
3.1 Indexed parameters
Maximum 3 paramètres indexes par event
Les paramètres indexes sont recherchables dans les logs
--- Indexed parameters : recherchables dans les logs ---
Deploye: IndexedExample a 0xa82fF9aFd8f496c3d6ac40E2a0F282E47488CFc9
orderId (indexed) = 0x602d5ab7fec5c249...
buyer (indexed) = 0xf39Fd6e5...
seller (indexed) = 0x70997970...
amount (data) = 500
productId (data) = 'PROD-001'
Les 3 premiers champs sont indexables par filtrage, les 2 derniers sont dans data
Lecture du résultat : indexed vs data, la recherchabilité des logs
L’event OrderCreated montre la distinction clef : orderId, buyer, seller sont indexed (filtrables dans les logs via les topics), amount et productId sont dans data (non filtrables mais plus économiques). La règle des 3 indexed max vient de l’EVM : un event a 4 topics — topic 0 = hash de la signature de l’event, topics 1-3 = les paramètres indexed. Pratiquement : on indexe ce par quoi on veut filtrer (acheteur, vendeur, orderId) et on laisse en data ce qu’on ne fait que lire (montant, productId). Choisir les bons champs indexed est une décision d’architecture — elle détermine ce que les indexeurs pourront requêter efficacement.
--- Try/Catch pour appels externes ---
Deploye: MockExternal a 0x851356ae760d987E095750cCeb3bC6014560891C
Deploye: TryCatchExample a 0xf5059a5D33d5853360D16C683c16e67980206f36
MockExternal : shouldFail = True
safeOperation() [shouldFail=True] → OperationFailed: 'Operation failed by design'
safeOperation() [shouldFail=False] → OperationSuccess: True
Lecture : try/catch — isoler l’échec d’un appel externe
La sortie ci-dessus déroule le scénario complet : avec shouldFail = True, safeOperation() voit MockExternal.riskyOperation() reverting, le catch récupère la main et émet OperationFailed("Operation failed by design") ; avec shouldFail = False, le flux try se poursuit et émet OperationSuccess.
Le try/catch se distingue des autres mécanismes de gestion d’erreur de Solidity :
require(cond, msg) / revert() valident une condition interne au contrat (précondition sur les entrées, l’état, les permissions). En cas d’échec, la transaction entière est annulée (rollback d’état + gas consommé jusqu’au revert).
assert(cond) protège un invariant critique (ce qui ne devrait jamais arriver si le code est correct) ; un échec déclenche une Panic et consomme tout le gas restant.
try/catch est le seul à pouvoir récupérer un échec d’appel externe sans annuler la transaction appelante — sans lui, le revert du contrat appelé se propage et fait reverting le contrat appelant.
Côté branches : catch Error(string) récupère les reverts avec message (« Operation failed by design »), le catch nu les reverts sans données (panic, assert failed).
Deux pièges à retenir : (1) le try/catchn’attrape que les échecs du contrat externe appelé (revert, panic ou échec de transfert), jamais une erreur du contrat appelant lui-même ; (2) il ne se substitue pas à require — on l’utilise quand on veut réagir à un échec prévisible d’un tiers (oracle indisponible, DEX, token non-standard, appel facultatif) plutôt que de tout annuler. C’est pourquoi les contrats qui appellent des contrats non-trustés enveloppent toujours leurs appels de try/catch — sinon une panne du contrat externe paralyserait le contrat appelant.
Exercice : Gestionnaire de whitelist avec errors et events
Créez un contrat WhitelistManager qui gere une liste d’adresses autorisees, utilisant des custom errors pour les cas d’erreur et des events pour tracer les modifications.
Indices : - Definissez 3 custom errors : NotOwner(), AlreadyWhitelisted(address), NotWhitelisted(address) - Definissez 2 events : Whitelisted(address indexed account), RemovedFromWhitelist(address indexed account) - Utilisez un mapping(address => bool) pour suivre les adresses - Utilisez un address public owner avec un modificateur onlyOwner
# Exercice : Gestionnaire de whitelist avec errors et eventsEXERCICE_WHITELIST ='''// SPDX-License-Identifier: MITpragma solidity ^0.8.28;// TODO etudiant : definir les 3 custom errors (NotOwner, AlreadyWhitelisted, NotWhitelisted)contract WhitelistManager { // TODO etudiant : definir owner, mapping whitelist, 2 events constructor() { // TODO etudiant : initialiser owner pass } modifier onlyOwner() { // TODO etudiant : verifier msg.sender == owner, sinon revert NotOwner() _; } function addToWhitelist(address account) public onlyOwner { // TODO etudiant : verifier que account n'est pas deja whitelist (AlreadyWhitelisted) // TODO etudiant : ajouter au mapping et emettre l'event Whitelisted pass } function removeFromWhitelist(address account) public onlyOwner { // TODO etudiant : verifier que account est whitelist (NotWhitelisted) // TODO etudiant : retirer du mapping et emettre l'event RemovedFromWhitelist pass } function isWhitelisted(address account) public view returns (bool) { // TODO etudiant : retourner le statut return false; }}'''print("Exercice a completer : WhitelistManager avec custom errors et events")
Exercice a completer : WhitelistManager avec custom errors et events
Exercice 1 : Contrôle d’acces par events
Créez un contrat EventAccessControl qui utilise des events comme audit trail pour un système de rôles (admin, editor, viewer). Chaque changement de rôle doit emettre un event, et les custom errors doivent signaler les violations d’acces.
Objectifs : 1. Implementer un système de rôles avec 3 niveaux (ADMIN, EDITOR, VIEWER) 2. Emettre des events pour chaque changement de rôle (attribution, retrait) 3. Utiliser des custom errors pour les acces non autorises
Indices : - Definissez les errors : UnauthorizedRole(address caller, bytes32 requiredRole), RoleAlreadyGranted(address account, bytes32 rôle) - Definissez les events : RoleGranted(address indexed account, bytes32 indexed rôle, address indexed grantedBy), RoleRevoked(address indexed account, bytes32 indexed rôle, address indexed revokedBy) - Utilisez un mapping(address => bytes32) pour stocker le rôle de chaque adresse - Representez les rôles par des bytes32 constants (ex: keccak256("ADMIN"))
# Exercice 1 : Controle d'acces par eventsEXERCICE_ACCESS_CONTROL ='''// SPDX-License-Identifier: MITpragma solidity ^0.8.28;// TODO etudiant : definir custom errors UnauthorizedRole et RoleAlreadyGrantedcontract EventAccessControl { // TODO etudiant : definir bytes32 constants ADMIN, EDITOR, VIEWER // TODO etudiant : definir mapping(address => bytes32) roles // TODO etudiant : definir events RoleGranted et RoleRevoked address public owner; constructor() { // TODO etudiant : initialiser owner et lui attribuer le role ADMIN pass } modifier onlyRole(bytes32 _role) { // TODO etudiant : verifier que msg.sender a le role requis _; } function grantRole(address account, bytes32 role) public onlyRole(ADMIN) { // TODO etudiant : verifier que le role n'est pas deja attribue (RoleAlreadyGranted) // TODO etudiant : attribuer le role et emettre RoleGranted pass } function revokeRole(address account) public onlyRole(ADMIN) { // TODO etudiant : retirer le role et emettre RoleRevoked pass } function hasRole(address account, bytes32 role) public view returns (bool) { // TODO etudiant : verifier si l'adresse a le role donne return false; }}'''print("Exercice a completer : EventAccessControl avec roles et audit trail")
Exercice a completer : EventAccessControl avec roles et audit trail
5. Exercices
Exercice 2 : Contrat avec custom errors
Créez un contrat de vote avec custom errors pour les cas invalides.
# Exercice 2 : Contrat de vote avec custom errorsEXERCICE_VOTING ='''// SPDX-License-Identifier: MITpragma solidity ^0.8.28;// TODO etudiant : definir les 3 custom errors (AlreadyVoted, VotingClosed, InvalidProposal)contract Voting { // TODO etudiant : definir struct Proposal { string name; uint256 voteCount; } // TODO etudiant : definir Proposal[] proposals, mapping(address=>bool) hasVoted, bool votingOpen // TODO etudiant : definir les events Voted(address indexed voter, uint256 indexed proposalId) et VotingFinalized(uint256 timestamp) constructor(string[] memory _proposalNames) { // TODO etudiant : peupler proposals a partir de _proposalNames pass } function vote(uint256 proposalId) public { // TODO etudiant : reverts AlreadyVoted / VotingClosed / InvalidProposal // TODO etudiant : incrementer voteCount et emettre Voted pass } function closeVoting() public { // TODO etudiant : passer votingOpen a false et emettre VotingFinalized pass }}'''print("Exercice a completer : Voting avec custom errors (AlreadyVoted, VotingClosed, InvalidProposal)")
Exercice a completer : Voting avec custom errors (AlreadyVoted, VotingClosed, InvalidProposal)
Exercice 3 : Contrat Auction avec events
Créez un contrat d’enchere avec events pour chaque action.
# Exercice 3 : Contrat Auction avec eventsEXERCICE_AUCTION ='''// SPDX-License-Identifier: MITpragma solidity ^0.8.28;// TODO etudiant : definir les custom errors (AuctionEnded, BidTooLow)contract Auction { // TODO etudiant : definir beneficiary, auctionEndTime, highestBidder, highestBid, bool ended // TODO etudiant : definir mapping(address=>uint256) pendingReturns // TODO etudiant : definir les events HighestBidIncreased, AuctionFinalized, BidRefunded constructor(uint256 biddingTime, address _beneficiary) { // TODO etudiant : initialiser beneficiary et auctionEndTime pass } function bid() public payable { // TODO etudiant : reverts AuctionEnded / BidTooLow, gerer pendingReturns, emettre HighestBidIncreased pass } function withdraw() public returns (bool) { // TODO etudiant : rembourser via pendingReturns, emettre BidRefunded pass } function auctionEnd() public { // TODO etudiant : finaliser l'enchere, emettre AuctionFinalized pass }}'''print("Exercice a completer : Auction avec events (HighestBidIncreased, AuctionFinalized, BidRefunded)")
Exercice a completer : Auction avec events (HighestBidIncreased, AuctionFinalized, BidRefunded)
Lecture du contrat Auction : paiement « pull » et finalisation idempotente
La sortie ci-dessus met en œuvre deux patterns de sécurité fondamentaux en Solidity, qui ne sautent pas aux yeux depuis le seul journal d’événements.
1. Paiement « pull » plutôt que « push » (anti-DoS et anti-réentrance). Quand bidder2 surenchérit à 2 ETH, le contrat ne renvoie pas automatiquement le 1 ETH de bidder1. Il le crédite dans pendingReturns[bidder1] += 1 ETH et attend que bidder1 appelle lui-même withdraw() pour récupérer ses fonds. Pourquoi ne pas simplement payable(bidder1).transfer(...) dans bid() ? Parce qu’un enchérisseur peut être un contrat malveillant dont la fonction receive() consomme tout le gas ou déclenche une réentrance : en « pushant » les fonds à l’intérieur de bid(), on offrirait à ce contrat une prise d’attaque au cœur de la logique d’enchère, et un seul enchérisseur toxique pourrait bloquer toutes les surenchères (déni de service). Le pattern « pull » isole le risque : chacun retire ses fonds à sa propre initiative. Notez en outre que withdraw() met pendingReturns[msg.sender] = 0avant le transfer — c’est le pattern checks-effects-interactions qui empêche la réentrance sur cette même fonction.
2. Finalisation idempotente (garde-fou de machine à états). La fonction auctionEnd() effectue la transition OPEN → CLOSED exactement une fois : le second appel déclenche revert "Auction already finalized". Le booléen ended fait office de verrou d’état. L’enjeu est double — éviter un double-paiement au bénéficiaire, et garantir que la levée des fonds ne puisse pas être rejouée. C’est l’application directe du principe « une transition d’état critique est une opération one-shot » : tout effet financier irréversible (ici, le transfert des 2 ETH au bénéficiaire) doit être protégé par un garde-fou explicite, jamais supposé implicite.
À retenir : sur une enchère, ces deux patterns se complètent — le « pull » protège les remboursements (plusieurs enchérisseuses et enchérisseurs, confiance nulle), la finalisation protège le paiement final (bénéficiaire unique, exactement une fois). Ensemble, ils font du contrat une petite machine à états déterministe, robuste face à des participants potentiellement hostiles.
Exercice 4 : Hiérarchie d’erreurs personnalisees
Créez un contrat PaymentProcessor qui gere des paiements avec une hiérarchie de custom errors imbriquees. Certaines erreurs heritent d’autres contextes (erreur parent + erreur enfant), et le contrat doit les differencier dans ses reverts.
Objectifs : 1. Définir une hiérarchie de custom errors avec des paramètres 2. Implementer un contrat qui utilise différentes erreurs selon le contexte 3. Utiliser des events pour tracer les opérations reussies et echouees
Indices : - Definissez PaymentError(address payer, uint256 amount) comme erreur generique - Definissez InsufficientFunds(address payer, uint256 available, uint256 required) et PaymentExpired(uint256 deadline) comme erreurs spécifiques - Utilisez un mapping(address => uint256) pour les soldes - Emettez un event PaymentProcessed(address indexed from, address indexed to, uint256 amount) en cas de succes
# Exercice 4 : Hierarchie d'erreurs personnaliseesEXERCICE_PAYMENT_PROCESSOR ='''// SPDX-License-Identifier: MITpragma solidity ^0.8.28;// TODO etudiant : definir les 3 custom errors (PaymentError, InsufficientFunds, PaymentExpired)contract PaymentProcessor { // TODO etudiant : definir mapping balances, uint256 deadline, event PaymentProcessed constructor(uint256 _deadline) { // TODO etudiant : initialiser deadline (timestamp de validite) pass } function deposit() public payable { // TODO etudiant : incrementer le solde de msg.sender pass } function pay(address to, uint256 amount) public { // TODO etudiant : verifier deadline non depassee (PaymentExpired) // TODO etudiant : verifier solde suffisant (InsufficientFunds) // TODO etudiant : transferer et emettre PaymentProcessed pass } function getBalance(address account) public view returns (uint256) { // TODO etudiant : retourner le solde return 0; }}'''print("Exercice a completer : PaymentProcessor avec hierarchie d'erreurs et events")
Exercice a completer : PaymentProcessor avec hierarchie d'erreurs et events