SC-27 : Dette d’irréversibilité — la boucle de gouvernance, mesurée

Navigation : Sommaire | << SC-26 Final Project


Objectifs d’apprentissage

  1. Dérouler la boucle de gouvernance complète — proposition, vote, timelock, exécution — sur une vraie chaîne (anvil), en mesurant le coût de chaque étape
  2. Tenter le retour arrière après une exécution : mesurer ce qu’il coûte, et énumérer ce qui ne revient jamais
  3. Établir la table de dette : pour trois formes de changement (paramètre / logique / migration), le quadruplet coût-quorum-délai-réversibilité

Le concept

Une gouvernance on-chain déroule, sans métaphore, la boucle :

proposition -> vote -> timelock -> exécution -> NOUVEL ÉTAT INSTITUTIONNEL

À l’arrivée, le mécanisme n’est plus une représentation du monde : il fait partie du monde qu’il représente. Et l’intérêt propre du substrat est que l’irréversibilité y devient mesurable au lieu d’être discutée : coût d’un changement (gas), quorum requis et ce qu’il rend inatteignable, durée de timelock et ce qu’elle rend révocable, et ce qui n’a pas de rollback du tout.

Prérequis

  • SC-09-DAO-Governance-Python — la mécanique vote/timelock/quorum y est enseignée ; ce notebook ne la ré-explique pas, il la mesure
  • SC-02-Setup-Web3py-Python — pattern web3 + anvil
  • anvil doit tourner sur http://127.0.0.1:8545 (dans un terminal séparé : anvil)

Toutes les mesures de ce notebook sont prises sur une exécution réelle : vraie chaîne anvil (chain id 31337), vraies transactions minées, vrais receipts. Aucune valeur n’est estimée ou simulée.

Plan

  1. §1 Deux contrats, un transfert d’autorité — le Protocole (la chose gouvernée) et la Gouvernance (le mécanisme), compilés par solc et déploiements mesurés.
  2. §2 La boucle complète, étape par étape — changement du paramètre frais, coût de chaque transaction, tableau final.
  3. §3 La fenêtre de surpaiement — ce que paient les utilisateurs pendant que le monde institutionnel tourne.
  4. §4 Le retour arrière — son coût, et la liste explicite de ce qui ne revient pas.
  5. §5 Logique et migration — deux formes de changement plus profondes, deux régimes de réversibilité différents.
  6. §6 Le verrou à sens unique — brûler des jetons de gouvernance : la démonstration qu’il existe des actions sans aucun rollback.
  7. §7 La table de dette — le quadruplet mesuré pour chaque forme de changement.
  8. §8-10 Exercices (3) — votre propre boucle, votre propre retour arrière, votre propre table.

1. Deux contrats, un transfert d’autorité

Le contrat Protocole est la chose gouvernée : un paramètre (frais), un pointeur de logique (logique), un trésor (le solde ETH du contrat), et une fonction migrer qui vide le trésor vers l’extérieur. Chaque fonction d’action n’est appelable que par autorite.

Le contrat Gouvernance est le mécanisme : propositions portant un call arbitraire, votes pondérés par jetons (un jeton par électeur), quorum, et un timelock compté en blocs entre l’approbation et l’exécution. Il contient aussi le verrou qui intéresse ce notebook : brulerJetons, une fonction sans inverse — aucun mint n’existe dans le contrat.

La mécanique (vote pondéré, timelock, quorum) est celle de SC-9 ; ce qui change ici est la transparence du dispositif de mesure : chaque transaction envoyée par ce notebook passe par le même helper, qui enregistre gas, prix effectif, coût en wei et bloc.

from web3 import Web3
import solcx

SOURCE = r"""
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

/// @title Protocole - la chose gouvernée. Chaque fonction d'action n'est
/// appelable que par la Gouvernance : le mécanisme fait partie du monde.
contract Protocole {
    address public autorite;
    uint256 public frais;
    address public logique;
    mapping(address => uint256) public payes;

    event FraisModifies(uint256 ancien, uint256 nouveau);
    event LogiqueRemplacee(address ancienne, address nouvelle);

    error AutoriteSeulement();

    constructor(uint256 fraisInitial) payable {
        autorite = msg.sender;
        frais = fraisInitial;
    }

    function transfererAutorite(address nouvelle) external {
        if (msg.sender != autorite) revert AutoriteSeulement();
        autorite = nouvelle;
    }

    function payerFrais() external payable {
        require(msg.value >= frais, "frais insuffisants");
        payes[msg.sender] += msg.value;
    }

    function setFrais(uint256 nouveau) external {
        if (msg.sender != autorite) revert AutoriteSeulement();
        emit FraisModifies(frais, nouveau);
        frais = nouveau;
    }

    function setLogique(address nouvelle) external {
        if (msg.sender != autorite) revert AutoriteSeulement();
        emit LogiqueRemplacee(logique, nouvelle);
        logique = nouvelle;
    }

    function migrer(address payable destination, uint256 montant) external {
        if (msg.sender != autorite) revert AutoriteSeulement();
        destination.transfer(montant);
    }

    /// Le Protocole accepte les retours volontaires de fonds, mais aucune
    /// fonction ne peut les exiger : recevoir n'est pas rappeler.
    receive() external payable {}
}

/// @title Gouvernance - proposition -> vote -> timelock -> exécution,
/// avec un verrou irréversible : brulerJetons n'a pas de fonction inverse.
contract Gouvernance {
    enum Etape { Active, Refusee, Approuvee, Executee }

    struct Proposition {
        string description;
        address cible;
        bytes donnees;
        address proposeur;
        uint256 blocFinVote;
        uint256 blocExecutable;
        uint256 pour;
        uint256 contre;
        Etape etape;
    }

    uint256 public constant QUORUM = 4;
    uint256 public constant DELAI_VOTE = 5;
    uint256 public constant DELAI_TIMELOCK = 5;

    mapping(address => uint256) public jetons;
    uint256 public totalJetons;
    mapping(uint256 => Proposition) public propositions;
    uint256 public nbPropositions;
    mapping(uint256 => mapping(address => bool)) public aVote;

    event Proposee(uint256 id, string description, address cible);
    event VoteExprime(uint256 id, address electeur, bool pour);
    event Finalisee(uint256 id, Etape etape, uint256 pour, uint256 contre);
    event Executee(uint256 id, bool succes);
    event JetonsBrules(address qui, uint256 montant);

    constructor(address[] memory electeurs) {
        for (uint256 i = 0; i < electeurs.length; i++) {
            jetons[electeurs[i]] = 1;
        }
        totalJetons = electeurs.length;
    }

    function proposer(string calldata description, address cible, bytes calldata donnees)
        external returns (uint256 id)
    {
        require(jetons[msg.sender] >= 1, "il faut des jetons");
        id = nbPropositions++;
        propositions[id] = Proposition({
            description: description,
            cible: cible,
            donnees: donnees,
            proposeur: msg.sender,
            blocFinVote: block.number + DELAI_VOTE,
            blocExecutable: 0,
            pour: 0,
            contre: 0,
            etape: Etape.Active
        });
        emit Proposee(id, description, cible);
    }

    function voter(uint256 id, bool pour) external {
        Proposition storage p = propositions[id];
        require(p.etape == Etape.Active, "pas active");
        require(block.number <= p.blocFinVote, "vote clos");
        require(jetons[msg.sender] >= 1, "pas electeur");
        require(!aVote[id][msg.sender], "deja vote");
        aVote[id][msg.sender] = true;
        if (pour) { p.pour += jetons[msg.sender]; }
        else { p.contre += jetons[msg.sender]; }
        emit VoteExprime(id, msg.sender, pour);
    }

    /// Verrou à sens unique : aucune fonction de mint n'existe dans ce contrat.
    function brulerJetons(uint256 montant) external {
        require(jetons[msg.sender] >= montant, "pas assez de jetons");
        jetons[msg.sender] -= montant;
        totalJetons -= montant;
        emit JetonsBrules(msg.sender, montant);
    }

    function finaliser(uint256 id) external {
        Proposition storage p = propositions[id];
        require(p.etape == Etape.Active, "pas active");
        require(block.number > p.blocFinVote, "vote ouvert");
        if (p.pour > p.contre && p.pour >= QUORUM) {
            p.etape = Etape.Approuvee;
            p.blocExecutable = block.number + DELAI_TIMELOCK;
        } else {
            p.etape = Etape.Refusee;
        }
        emit Finalisee(id, p.etape, p.pour, p.contre);
    }

    function executer(uint256 id) external returns (bool succes) {
        Proposition storage p = propositions[id];
        require(p.etape == Etape.Approuvee, "pas approuvee");
        require(block.number >= p.blocExecutable, "timelock en cours");
        p.etape = Etape.Executee;
        (succes, ) = p.cible.call(p.donnees);
        require(succes, "echec de l'appel");
        emit Executee(id, succes);
    }
}
"""

w3 = Web3(Web3.HTTPProvider("http://127.0.0.1:8545"))
if not w3.is_connected():
    raise ConnectionError("anvil doit tourner sur http://127.0.0.1:8545 (lancez 'anvil' dans un terminal)")

COMPTES = w3.eth.accounts
DEPLOYEUR, DESTINATION = COMPTES[0], COMPTES[9]
ELECTEURS = COMPTES[0:10]        # 10 jetons, quorum 4
UTILISATEURS = COMPTES[3:6]      # ils paient le frais, ils ne votent pas

print(f"Connecté à anvil | chain id {w3.eth.chain_id} | bloc {w3.eth.block_number}")
print(f"Quorum : 4 jetons sur {len(ELECTEURS)} | délais : {5} blocs de vote, {5} blocs de timelock")
Connecté à anvil | chain id 31337 | bloc 0
Quorum : 4 jetons sur 10 | délais : 5 blocs de vote, 5 blocs de timelock
# Compilation (solc 0.8.28, le compilateur réel de la série) et déploiement mesuré.
MESURES = []

def envoyer(nom, tx, etape=""):
    """Envoie la transaction, attend le receipt, enregistre tout."""
    rc = w3.eth.wait_for_transaction_receipt(tx)
    cout = rc.gasUsed * rc.effectiveGasPrice
    MESURES.append({"etape": etape or nom, "detail": nom, "gas": rc.gasUsed,
                    "prix": rc.effectiveGasPrice, "cout_wei": cout, "bloc": rc.blockNumber})
    return rc

def avancer(n=1):
    """Fait avancer la chaîne de n blocs avec des tx banales : le monde continue."""
    for _ in range(n):
        w3.eth.wait_for_transaction_receipt(
            w3.eth.send_transaction({"from": COMPTES[8], "to": COMPTES[7], "value": 1}))

compilee = solcx.compile_source(SOURCE, output_values=["abi", "bin"], solc_version="0.8.28")
ProtocoleC = w3.eth.contract(abi=compilee["<stdin>:Protocole"]["abi"],
                             bytecode=compilee["<stdin>:Protocole"]["bin"])
GouvC = w3.eth.contract(abi=compilee["<stdin>:Gouvernance"]["abi"],
                        bytecode=compilee["<stdin>:Gouvernance"]["bin"])

# Protocole : déploiement avec un trésor initial de 1 ETH
rc = envoyer("déploiement Protocole (trésor 1 ETH)",
             ProtocoleC.constructor(5).transact({"from": DEPLOYEUR, "value": w3.to_wei(1, "ether")}),
             etape="déploiement")
protocole = w3.eth.contract(address=rc.contractAddress, abi=ProtocoleC.abi)

# Gouvernance : 10 électeurs, 1 jeton chacun
rc = envoyer("déploiement Gouvernance (10 électeurs)",
             GouvC.constructor(ELECTEURS).transact({"from": DEPLOYEUR}), etape="déploiement")
gouv = w3.eth.contract(address=rc.contractAddress, abi=GouvC.abi)

# Le transfert d'autorité : le déployeur renonce à tout pouvoir immédiat
rc = envoyer("transfert d'autorité déployeur -> Gouvernance",
             protocole.functions.transfererAutorite(gouv.address).transact({"from": DEPLOYEUR}),
             etape="déploiement")

print(f"Protocole  : {protocole.address} | frais = {protocole.functions.frais().call()}")
print(f"Gouvernance: {gouv.address} | autorite = {protocole.functions.autorite().call()}")
print(f"Trésor     : {w3.from_wei(w3.eth.get_balance(protocole.address), 'ether')} ETH")
print()
for m in MESURES:
    print(f"  {m['detail']:45s} gas={m['gas']:>9,d}  coût={m['cout_wei']:>16,d} wei")
Protocole  : 0x5FbDB2315678afecb367f032d93F642f64180aa3 | frais = 5
Gouvernance: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 | autorite = 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
Trésor     : 1 ETH

  déploiement Protocole (trésor 1 ETH)          gas=  624,687  coût=1,249,374,000,000,000 wei
  déploiement Gouvernance (10 électeurs)        gas=2,087,888  coût=3,925,658,970,758,800 wei
  transfert d'autorité déployeur -> Gouvernance gas=   27,181  coût=48,531,533,289,008 wei

Interprétation : le transfert d’autorité est déjà une porte à sens unique

Trois transactions ont suffi à faire naître l’institution : 624 687 gas pour le Protocole (avec son trésor d’1 ETH), 2 087 888 gas pour la Gouvernance (les 10 jetons et les mappings), 27 181 gas pour le transfert d’autorité. Ce dernier est la première leçon d’irréversibilité du notebook, et elle est discrète : au moment où il est confirmé, le déployeur a perdu tout pouvoir immédiat sur le Protocole. Aucune fonction du Protocole ne lui est réservée désormais — changer frais, remplacer logique, vider le trésor : tout passe désormais par la boucle complète de la Gouvernance. Le mécanisme est devenu la seule clé, et cette cession ne se révoque pas plus vite qu’un changement gouverné (il faudrait une proposition, quatre votes, un timelock).

C’est le sens précis de le mécanisme fait partie du monde qu’il représente : le contrat ne décrit pas une gouvernance, il est l’autorité. Dès cette cellule, la seule façon d’agir sur le Protocole est de convaincre quatre électeurs et d’attendre le timelock.

2. La boucle complète, étape par étape

Nous allons changer le paramètre frais de 5 à 50 wei — un changement de paramètre, la forme la plus bénigne. La boucle complète :

  1. proposer — un électeur dépose l’appel encodé (setFrais(50)) visant le Protocole ;
  2. voter — cinq électeurs votent pour (chaque vote est une transaction, donc un bloc) ;
  3. finaliser — après la clôture du vote, le décompte : 5 pour, 0 contre, quorum 4 atteint, la proposition devient Approuvée et le timelock est posé (5 blocs) ;
  4. la fenêtre — pendant le timelock, le monde tourne : des utilisateurs paient le frais à l’ancienne valeur ;
  5. exécuter — la Gouvernance effectue l’appel ; frais devient 50.

Un détail technique mérite d’être noté avant d’exécuter : entre le dernier vote et finaliser, nous faisons avancer la chaîne d’un bloc de plus (avancer(1)). La raison est une propriété réelle de web3 : transact() commence par une estimation de gas, qui simule la transaction au bloc courant — or le dernier vote atterrit exactement au bloc de clôture, où block.number > blocFinVote échoue d’un bloc. Le monde continue de tourner pendant la fermeture du vote ; ce bloc supplémentaire est exactement cela.

def boucle(description, fonction_cible, votants=5, fenetre=None):
    """Déroule la boucle complète pour un appel donné, mesure chaque étape.

    fenetre : callable qui renvoie une liste [(nom, tx)] à lancer PENDANT le
    timelock (None -> simplement avancer de 5 blocs). C'est un callable et non
    une liste : les .transact() ne doivent partir qu'au moment du timelock.
    Renvoie (id_proposition, mesures de la boucle).
    """
    avant = len(MESURES)
    donnees = fonction_cible._encode_transaction_data()
    envoyer("proposer", gouv.functions.proposer(description, protocole.address, donnees)
            .transact({"from": ELECTEURS[1]}), etape=description)
    pid = gouv.functions.nbPropositions().call() - 1
    for i in range(votants):
        envoyer(f"vote {i + 1}/{votants}",
                gouv.functions.voter(pid, True).transact({"from": ELECTEURS[i + 1]}),
                etape=description)
    avancer(1)   # le monde tourne pendant la clôture du vote
    envoyer("finaliser", gouv.functions.finaliser(pid).transact({"from": ELECTEURS[1]}),
            etape=description)
    p = gouv.functions.propositions(pid).call()
    print(f"  [{description}] après finalisation : pour={p[6]} contre={p[7]} "
          f"étape={p[8]} (2=Approuvée) exécutable au bloc {p[5]}")
    if fenetre is not None:
        for nom, tx in fenetre():
            envoyer(nom, tx, etape=description)
    else:
        avancer(5)
    envoyer("exécuter", gouv.functions.executer(pid).transact({"from": ELECTEURS[1]}),
            etape=description)
    return pid, MESURES[avant:]

pid1, m_boucle1 = boucle("paramètre : frais 5 -> 50", protocole.functions.setFrais(50),
    fenetre=lambda: [(f"usage à 5 pendant le timelock {i}",
                      protocole.functions.payerFrais().transact(
                          {"from": UTILISATEURS[i % 3], "value": 5}))
                     for i in range(5)])

total_gas = sum(m["gas"] for m in m_boucle1)
total_wei = sum(m["cout_wei"] for m in m_boucle1)
print()
print(f"=== Boucle complète du changement de paramètre : {len(m_boucle1)} transactions ===")
for m in m_boucle1:
    print(f"  {m['detail']:34s} gas={m['gas']:>8,d}  bloc={m['bloc']}")
print(f"  {'TOTAL':34s} gas={total_gas:>8,d}  coût={total_wei:,d} wei "
      f"({w3.from_wei(total_wei, 'gwei'):.0f} gwei brûlés)")
print()
print(f"frais maintenant : {protocole.functions.frais().call()}")
  [paramètre : frais 5 -> 50] après finalisation : pour=5 contre=0 étape=2 (2=Approuvée) exécutable au bloc 16

=== Boucle complète du changement de paramètre : 13 transactions ===
  proposer                           gas= 217,206  bloc=4
  vote 1/5                           gas=  75,781  bloc=5
  vote 2/5                           gas=  58,681  bloc=6
  vote 3/5                           gas=  58,681  bloc=7
  vote 4/5                           gas=  58,681  bloc=8
  vote 5/5                           gas=  58,681  bloc=9
  finaliser                          gas=  75,619  bloc=11
  usage à 5 pendant le timelock 0    gas=  45,771  bloc=12
  usage à 5 pendant le timelock 1    gas=  45,771  bloc=13
  usage à 5 pendant le timelock 2    gas=  45,771  bloc=14
  usage à 5 pendant le timelock 3    gas=  28,671  bloc=15
  usage à 5 pendant le timelock 4    gas=  28,671  bloc=16
  exécuter                           gas=  51,816  bloc=17
  TOTAL                              gas= 849,801  coût=1,211,575,128,523,104 wei (1211575 gwei brûlés)

frais maintenant : 50

Interprétation : ce que coûte une décision, exactement

La boucle complète — la seule façon légale de changer un paramètre dans cette institution — a consommé treize transactions : une proposition (217 206 gas), cinq votes (58 681 gas chacun, le premier à 75 781 car il initialise le storage du vote), une finalisation (75 619), cinq paiements d’usage à l’ancienne valeur (45 771 puis 28 671 — la première écriture d’une clé coûte plus cher), et l’exécution (51 816). Le squelette de gouvernance seul — proposition, votes, finalisation, exécution — totalise 655 146 gas ; avec la fenêtre d’usage, 849 801 gas, dont chaque wei de coût est brûlé (détruit, pas transféré) : sur Ethereum, le gas paie la sécurité du consensus, il ne revient à personne.

Deux lectures de ce chiffre :

  • le coût marginal d’un changement gouverné est structurellement élevé : même pour tourner un uint256 de 5 à 50, il faut convaincre un quorum, attendre un timelock, et payer une boucle complète. C’est la friction délibérée de la gouvernance on-chain — la même friction qui protège contre la capture ;
  • le timelock fait son travail, et on le voit : pendant les cinq blocs de fenêtre, les utilisateurs ont payé le frais à l’ancienne valeur (5 wei). Le changement n’était pas encore effectif — c’est précisément la fonction d’un timelock : laisser le monde voir venir.

Mais la question du notebook n’est pas là : elle est de savoir ce qui se passe après l’exécution — et notamment pour les utilisateurs qui arrivent pendant que le nouveau paramètre est en vigueur.

3. La fenêtre de surpaiement

Le frais est désormais 50. Trois utilisateurs — qui ne votent pas, qui ne sont pas des électeurs, simplement des usagers — paient le frais pendant que le monde institutionnel vit avec son nouveau paramètre. Puis, plus tard (§4), le paramètre sera restauré à 5. La question : ce qu’ils ont surpayé, leur revient-il ?

avant = len(MESURES)
for u in UTILISATEURS:
    envoyer(f"usage : {u[:10]} paie le frais à 50",
            protocole.functions.payerFrais().transact({"from": u, "value": 50}),
            etape="fenêtre de surpaiement")

print("Ce que chaque usager a payé, cumulé depuis le début :")
for u in UTILISATEURS:
    paye = protocole.functions.payes(u).call()
    print(f"  {u[:10]}... : {paye:>6,d} wei payés au total")

gas_fenetre = sum(m['gas'] for m in MESURES[avant:])
print()
print(f"Surpaiement par usager pendant la fenêtre : 50 - 5 = 45 wei")
print(f"Surpaiement total (3 usagers x 45)        : {3 * 45} wei, versés au trésor")
print(f"Gas des transactions d'usage               : {gas_fenetre:,d} (brûlé, pas au trésor)")
print()
print("Le Protocole offre-t-il une fonction de remboursement ?",
      "rembourser" in SOURCE.lower())
Ce que chaque usager a payé, cumulé depuis le début :
  0x90F79bf6... :     60 wei payés au total
  0x15d34AAf... :     60 wei payés au total
  0x9965507D... :     55 wei payés au total

Surpaiement par usager pendant la fenêtre : 50 - 5 = 45 wei
Surpaiement total (3 usagers x 45)        : 135 wei, versés au trésor
Gas des transactions d'usage               : 86,013 (brûlé, pas au trésor)

Le Protocole offre-t-il une fonction de remboursement ? False

Interprétation : le surpaiement ne revient pas par lui-même

Les trois usagers ont payé 50 wei pour un service qui, après restauration du paramètre, coûtera 5 : 135 wei sont partis au trésor (cumulés aux 25 wei payés à l’ancienne valeur pendant le timelock de la première boucle, et aux 250 wei des usagers de la §4 à venir — le lecteur peut vérifier le total de 425 wei dans le solde final). Et la dernière ligne de la cellule le dit sans détour : le mot rembourser n’existe nulle part dans le contrat. Le mapping payes enregistre ce que chacun a versé — une mémoire, pas une dette payable. Récupérer ces 135 wei exigerait une nouvelle proposition (rembourser via migrer), donc une nouvelle boucle complète, un nouveau quorum, un nouveau timelock — et surtout la volonté politique de le faire.

C’est la forme la plus concrète de la dette d’irréversibilité : l’argent des usages passés vit désormais dans l’état institutionnel, et ne sort que par une décision institutionnelle nouvelle.

Décomposition de ces cumuls (vérification arithmétique) : 60 wei (U0) = 50 (fenêtre §3) + 5 + 5 (timelock §2 sur UTILISATEURS[i%3] où U0=i%3 paie deux fois), 60 wei (U1) idem, 55 wei (U2) = 50 (fenêtre) + 5 (timelock, U2=i%3 paie une seule fois). Soit 5 paiements × 5 wei = 25 wei pendant le timelock + 3 × 50 wei = 150 wei pendant la fenêtre = 175 wei versés, mais le mapping payes ne reporte que ce qu’individuellement chaque usager a versé.

4. Le retour arrière : son coût, et ce qui ne revient pas

Nous voulons maintenant défaire le changement : ramener frais de 50 à 5. La boucle est exactement la même que celle de l’aller — c’est le point : le retour arrière d’un changement de paramètre est un changement de paramètre. Mesurons-le, puis dressons la liste de ce qui, lui, ne revient pas.

pid2, m_boucle2 = boucle("retour arrière : frais 50 -> 5", protocole.functions.setFrais(5),
    fenetre=lambda: [(f"usage à 50 pendant le timelock {i}",
                      protocole.functions.payerFrais().transact(
                          {"from": UTILISATEURS[i % 3], "value": 50}))
                     for i in range(5)])

g_aller = sum(m["gas"] for m in m_boucle1)
g_retour = sum(m["gas"] for m in m_boucle2)
w_aller = sum(m["cout_wei"] for m in m_boucle1)
w_retour = sum(m["cout_wei"] for m in m_boucle2)

print(f"frais restauré : {protocole.functions.frais().call()}")
print()
print(f"coût de l'aller  (5 -> 50) : {g_aller:>8,d} gas, {w_aller:>14,d} wei brûlés")
print(f"coût du retour  (50 -> 5)  : {g_retour:>8,d} gas, {w_retour:>14,d} wei brûlés")
print(f"l'état du paramètre est revenu à l'identique ; "
      f"{w_aller + w_retour:,d} wei sont partis en fumée pour l'aller-retour")
  [retour arrière : frais 50 -> 5] après finalisation : pour=5 contre=0 étape=2 (2=Approuvée) exécutable au bloc 33
frais restauré : 5

coût de l'aller  (5 -> 50) :  849,801 gas, 1,211,575,128,523,104 wei brûlés
coût du retour  (50 -> 5)  :  781,545 gas, 816,919,968,601,171 wei brûlés
l'état du paramètre est revenu à l'identique ; 2,028,495,097,124,275 wei sont partis en fumée pour l'aller-retour

Interprétation : la liste explicite de ce qui ne revient pas

Le paramètre est revenu exactement à sa valeur initiale — et pourtant rien n’est comme avant. Énumérons, chaque item étant mesuré dans les outputs de ce notebook :

  1. Le gas brûlé — l’aller-retour complet a détruit plus d’un million de gas cumulés (w_aller + w_retour ci-dessus). Brûlé ne signifie pas transféré : cette valeur n’existe plus, pour personne. Le coût d’un aller-retour qui finit où il a commencé n’est pas nul.
  2. L’historique est append-only — les deux propositions (ids 0 et 1), leurs votes, leurs événements (FraisModifies x2, Finalisee, Executee…) sont gravés dans la chaîne pour toujours. N’importe qui peut rejouer l’époque où le frais valait 50. Un état restauré n’efface pas le passé qui l’a séparé de l’original.
  3. Le surpaiement de la fenêtre — les 135 wei de la §3 (plus les 250 de la fenêtre du retour) sont au trésor, sans fonction de remboursement. Les usagers qui ont payé 50 ne seront pas crédités par la restauration du paramètre.
  4. La confiance, non mesurée mais réelle — pendant les blocs où le frais valait 50, chaque usager a vécu une règle différente. Le notebook ne mesure pas ce coût-là ; il signale qu’il est exactement la raison d’être des timelocks et des quorums.

Un changement de paramètre est donc réversible en état, irréversible en coût et en histoire. Les deux sections suivantes montrent des formes de changement où même l’état ne revient pas.

5. Logique et migration : deux régimes différents

Changer la logique — remplacer le pointeur logique du Protocole (le patron simplifié du couple proxy/implementation) — ressemble à un changement de paramètre : c’est un champ, il se re-change par une nouvelle boucle. La différence est dans ce qui peut se casser pendant la fenêtre : si la nouvelle logique est boguée, les usagers qui l’utilisent pendant la fenêtre en font les frais.

Migrer — vider le trésor vers une adresse externe — est d’une autre nature : après l’exécution, la valeur est sortie du périmètre du mécanisme. Le Protocole n’a plus aucun droit sur elle. Le retour arrière ne dépend plus d’une proposition : il dépend de la volonté du destinataire.

# --- Changement de logique : une boucle de plus, mesurée ---
pid3, m_logique = boucle("logique : nouveau pointeur",
                         protocole.functions.setLogique(w3.eth.accounts[7]))
print(f"logique maintenant : {protocole.functions.logique().call()[:12]}...")
print()

# --- Migration : 500 mETH quittent le trésor ---
tresor_avant = w3.eth.get_balance(protocole.address)
dest_avant = w3.eth.get_balance(DESTINATION)
pid4, m_migr = boucle("migration : 500 mETH vers DESTINATION",
                      protocole.functions.migrer(DESTINATION, w3.to_wei(500, "milliether")))
tresor_apres = w3.eth.get_balance(protocole.address)
dest_apres = w3.eth.get_balance(DESTINATION)

print(f"trésor Protocole : {w3.from_wei(tresor_avant, 'milliether'):>16.6f} -> "
      f"{w3.from_wei(tresor_apres, 'milliether'):>16.6f} mETH")
print(f"solde DESTINATION: {w3.from_wei(dest_avant, 'milliether'):>16.6f} -> "
      f"{w3.from_wei(dest_apres, 'milliether'):>16.6f} mETH")
print()
noms = [e.get("name", "") for e in protocole.abi if e.get("type") == "function"]
print("Fonctions du Protocole qui pourraient rappeler les fonds depuis DESTINATION :")
print("  ", [n for n in noms if "rappeler" in n or "clawback" in n or "recup" in n] or "aucune")
  [logique : nouveau pointeur] après finalisation : pour=5 contre=0 étape=2 (2=Approuvée) exécutable au bloc 47
logique maintenant : 0x14dC79964d...

  [migration : 500 mETH vers DESTINATION] après finalisation : pour=5 contre=0 étape=2 (2=Approuvée) exécutable au bloc 61
trésor Protocole :      1000.000000 ->       500.000000 mETH
solde DESTINATION:  10000000.000000 ->  10000500.000000 mETH

Fonctions du Protocole qui pourraient rappeler les fonds depuis DESTINATION :
   aucune
# --- Le retour "volontaire" : seule voie existante, et elle est hors mécanisme ---
# DESTINATION (un compte ordinaire, maître de ses clés) choisit de rendre une partie.
part_rendue = w3.to_wei(200, "milliether")
w3.eth.wait_for_transaction_receipt(
    w3.eth.send_transaction({"from": DESTINATION, "to": protocole.address, "value": part_rendue}))
print(f"DESTINATION a rendu {w3.from_wei(part_rendue, 'milliether'):.0f} mETH volontairement")
print(f"trésor Protocole : {w3.from_wei(w3.eth.get_balance(protocole.address), 'milliether'):.6f} mETH")
print(f"détenus hors mécanisme : {w3.from_wei(dest_apres - part_rendue, 'milliether'):.0f} mETH "
      f"-- rappelables uniquement si DESTINATION le veut bien")
DESTINATION a rendu 200 mETH volontairement
trésor Protocole : 700.000000 mETH
détenus hors mécanisme : 10000300 mETH -- rappelables uniquement si DESTINATION le veut bien

Interprétation : la réversibilité quitte le smart contract

La migration s’est exécutée exactement comme le changement de paramètre — même boucle, même quorum, même timelock — et pourtant leur régime de réversibilité n’a rien à voir :

  • la logique remplacée est re-remplaçable par une boucle équivalente (le pointeur est un champ du contrat) ; le risque réel de cette forme est la fenêtre avec une logique cassée, que ni le quorum ni le timelock ne réparent après coup ;
  • la migration est re-remplaçable en intention seulement : les fonds rendus le sont par un acte hors mécanisme — un simple transfert depuis une clé privée, aucune proposition, aucun quorum, aucun événement de gouvernance. Le mécanisme a perdu la main : les 300 mETH restants chez DESTINATION ne reviennent que si leur détenteur le décide. Sur une vraie migration (pont cross-chain, nouvelle version de protocole), ce détenteur peut être une équipe, un multi-sig, ou un contrat bogué — et l’histoire de la DeFi est riche de trésors partis vers des adresses que personne ne contrôle plus.

C’est l’échelon intermédiaire de la table de dette : réversible par le mécanisme (paramètre), réversible hors mécanisme (migration), et il reste à montrer l’échelon zéro — l’action dont il n’existe aucun chemin de retour, même hors mécanisme.

6. Le verrou à sens unique : brûler les jetons de gouvernance

Sept électeurs sur dix brûlent leurs jetons. Le contrat Gouvernance ne contient aucune fonction de mint — relisez la source de la §1 : les jetons n’existent que parce que le constructeur les a distribués une fois, à la naissance. Après le burn, totalJetons vaut 3, le quorum reste 4 : aucune proposition ne peut plus jamais être approuvée. Et la démonstration se fait sans faille : nous allons tenter normalement une proposition, la faire voter par tous les électeurs restants — et la voir refusée, défaitivement, pour toujours.

avant = len(MESURES)
for i in range(2, 9):   # ELECTEURS[2..8], sept comptes
    envoyer(f"brûlage du jeton de {ELECTEURS[i][:10]}",
            gouv.functions.brulerJetons(1).transact({"from": ELECTEURS[i]}),
            etape="brûlage des jetons")
gas_burn = sum(m["gas"] for m in MESURES[avant:])

print(f"totalJetons : {gouv.functions.totalJetons().call()} | quorum inchangé : "
      f"{gouv.functions.QUORUM().call()}")
print(f"coût du brûlage : {gas_burn:,d} gas -- {len(MESURES) - avant} transactions, "
      f"chacune ~30 000 gas")
print()

# La démonstration : une proposition parfaitement légitime, votée par TOUS les survivants
donnees = protocole.functions.setFrais(7)._encode_transaction_data()
envoyer("proposer après brûlage",
        gouv.functions.proposer("frais -> 7 (teste la gouvernance morte)",
                                protocole.address, donnees).transact({"from": ELECTEURS[0]}),
        etape="brûlage des jetons")
pid5 = gouv.functions.nbPropositions().call() - 1
for i in (0, 1, 9):   # les trois seuls électeurs restants votent tous
    envoyer(f"vote de survie {ELECTEURS[i][:10]}",
            gouv.functions.voter(pid5, True).transact({"from": ELECTEURS[i]}),
            etape="brûlage des jetons")
avancer(3)
envoyer("finaliser après brûlage",
        gouv.functions.finaliser(pid5).transact({"from": ELECTEURS[0]}),
        etape="brûlage des jetons")
p = gouv.functions.propositions(pid5).call()
print(f"proposition unanime-des-survivants : pour={p[6]} contre={p[7]} étape={p[8]}")
print(f"  (0=Active 1=Refusée 2=Approuvée 3=Exécutée)")
print()
noms_g = [e.get("name", "") for e in gouv.abi if e.get("type") == "function"]
print("Fonctions de la Gouvernance qui pourraient recréer des jetons :")
print("  ", [n for n in noms_g if "mint" in n.lower()] or "aucune -- le verrou est définitif")
totalJetons : 3 | quorum inchangé : 4
coût du brûlage : 203,987 gas -- 7 transactions, chacune ~30 000 gas

proposition unanime-des-survivants : pour=3 contre=0 étape=1
  (0=Active 1=Refusée 2=Approuvée 3=Exécutée)

Fonctions de la Gouvernance qui pourraient recréer des jetons :
   aucune -- le verrou est définitif

Interprétation : l’échelon zéro, démontré par échec

La proposition a réuni tous les votes possibles — les trois électeurs survivants ont voté pour, unanimement, sans opposition — et elle est refusée : 3 pour, quorum 4, étape = 1 (Refusée). Ce n’est pas un bug, c’est la définition même du verrou : le mécanisme ne peut plus s’auto-réparer, et il ne le pourra jamais, parce qu’aucune fonction du contrat ne crée de jetons. Le Protocole vit désormais avec son frais figé à 5, sa logique figée, son trésor sous clé morte — chaque fonction d’action est toujours là, parfaitement fonctionnelle, et inatteignable pour l’éternité.

Le coût du geste lui-même est presque embarrassant : sept transactions d’environ 30 000 gas chacune, soit de l’ordre de 200 000 gas — moins qu’une seule boucle de changement de paramètre. Détruire l’institution coûte moins cher que de la faire tourner une fois. C’est l’asymétrie la plus vertigineuse de la table de dette : la porte à sens unique est la porte la moins chère du mécanisme.

(Le lecteur attentif notera que dans un contrat de production, ce risque est attaqué de front : mint gardé par un timelock distinct, supply plafonné, quorum relatif au supply vivant. Notre contrat pédagogique l’omet délibérément — c’est le phénomène à mesurer, pas un modèle à copier.)

7. La table de dette

Il ne reste qu’à ranger ce qui a été mesuré. Pour chaque forme de changement : le coût (gas de la boucle complète, mesuré), le quorum (le nombre de voix qu’il faut et qui ne peut pas être forcé), le délai (les blocs qu’aucune urgence ne peut court-circuiter), et la réversibilité — telle qu’elle a été démontrée dans les sections précédentes, pas telle qu’on l’imaginerait.

def cout_boucle(t):
    return sum(m["gas"] for m in t)

TABLE_DETTE = [
    # forme, coût boucle (gas), quorum, délai (blocs), réversibilité démontrée
    ("paramètre (frais 5->50)",
     cout_boucle(m_boucle1), "4 voix", "5 (vote) + 5 (timelock)",
     "ÉTAT réversible par boucle équivalente ; COÛT et HISTOIRE irréversibles ; "
     "surpaiement de fenêtre non remboursé"),
    ("logique (pointeur)",
     cout_boucle(m_logique), "4 voix", "5 + 5",
     "ÉTAT réversible par boucle ; fenêtre avec logique cassée = dégâts "
     "non réparés par le mécanisme"),
    ("migration (500 mETH)",
     cout_boucle(m_migr), "4 voix", "5 + 5",
     "réversible HORS mécanisme uniquement (volonté du destinataire) ; "
     "300 mETH démontrativement non rappelables"),
    ("brûlage (7 jetons)",
     gas_burn, "0 (action directe)", "0",
     "AUCUN rollback : aucune fonction de mint ; quorum inatteignable "
     "pour toujours, prouvé par proposition refusée"),
    ("transfert d'autorité",
     MESURES[2]["gas"], "1 signature (au setup)", "0",
     "irréversible pour le déployeur (porte à sens unique d'entrée en gouvernance)"),
]

print(f"{'forme':24s} {'gas mesurés':>12s}  {'quorum':>18s}  {'délai (blocs)':>14s}  réversibilité démontrée")
print("-" * 140)
for forme, gas, quorum, delai, rev in TABLE_DETTE:
    print(f"{forme:24s} {gas:>12,d}  {quorum:>18s}  {delai:>14s}  {rev}")

total_gas_boucles = (cout_boucle(m_boucle1) + cout_boucle(m_boucle2)
                     + cout_boucle(m_logique) + cout_boucle(m_migr))
print()
print(f"Le protocole a brûlé au total {sum(m['gas'] for m in MESURES):,d} gas sur cette chaîne, "
      f"dont {total_gas_boucles:,d} pour quatre boucles de gouvernance.")
forme                     gas mesurés              quorum   délai (blocs)  réversibilité démontrée
--------------------------------------------------------------------------------------------------------------------------------------------
paramètre (frais 5->50)       849,801              4 voix  5 (vote) + 5 (timelock)  ÉTAT réversible par boucle équivalente ; COÛT et HISTOIRE irréversibles ; surpaiement de fenêtre non remboursé
logique (pointeur)            655,823              4 voix           5 + 5  ÉTAT réversible par boucle ; fenêtre avec logique cassée = dégâts non réparés par le mécanisme
migration (500 mETH)          710,949              4 voix           5 + 5  réversible HORS mécanisme uniquement (volonté du destinataire) ; 300 mETH démontrativement non rappelables
brûlage (7 jetons)            203,987  0 (action directe)               0  AUCUN rollback : aucune fonction de mint ; quorum inatteignable pour toujours, prouvé par proposition refusée
transfert d'autorité           27,181  1 signature (au setup)               0  irréversible pour le déployeur (porte à sens unique d'entrée en gouvernance)

Le protocole a brûlé au total 6,519,414 gas sur cette chaîne, dont 2,998,118 pour quatre boucles de gouvernance.

Interprétation : ce que la table dit

Trois asymétries structurelles émergent de la table, toutes mesurées :

  1. Le coût ne croît pas avec la gravité. Le changement de paramètre le plus anodin et la migration la plus lourde coûtent le même ordre de grandeur (la migration est même souvent moins chère — moins de storage à écrire), et le brûlage, l’acte le plus destructeur, est le moins cher de tous. Le prix du gas mesure le travail computationnel, pas l’enjeu institutionnel : la gravité d’un acte on-chain n’est pas tarifée.
  2. Le quorum et le délai protègent l’aller, jamais le retour. Quatre voix et dix blocs s’appliquent à chaque boucle — y compris à celle qui répare la précédente. Un mécanisme qui se trompe se corrige aux mêmes conditions que ceux qu’il a prévenus.
  3. La réversibilité est un spectre, pas un booléen. Paramètre : réversible en état. Logique : réversible en état, pas en dégâts de fenêtre. Migration : réversible hors mécanisme. Brûlage : rien. Réduire « on-chain c’est irréversible » à un cliché cache précisément cette gradation, qui est l’outil opérationnel du concepteur de protocole.

La dette d’irréversibilité est le solde de tout ce qui, dans l’histoire des états passés, ne participera plus jamais à l’état futur : gas brûlés, historiques gravés, surpaiements non remboursés, portes fermées. Ce notebook en a mesuré chaque composante.

8. Exercice 1 : votre propre boucle, votre propre tableau

Le mécanisme est mort (§6) — c’est même sa leçon. Pour exercer la boucle vous-même, redéployez une instance vierge avec deployer_tout() (fourni ci-dessous), puis déroulez une boucle complète pour un changement de votre choix (par exemple setFrais(7), ou setLogique vers une adresse de votre choix) et produisez votre tableau de coûts par étape, au format de la §2. Comparez vos gas à ceux de la §2 : ils devraient être proches mais pas identiques — d’où vient l’écart ?

  • Étape 1 : appeler deployer_tout() pour une instance vierge
  • Étape 2 : choisir l’appel cible et dérouler la boucle comme en section 2
  • Étape 3 : imprimer le tableau gas/coût/bloc de chaque transaction
def deployer_tout(frais_initial=5, tresor_eth=1):
    """Redéploie une paire Protocole+Gouvernance vierge et rend les deux contrats."""
    rc = w3.eth.wait_for_transaction_receipt(
        ProtocoleC.constructor(frais_initial).transact(
            {"from": DEPLOYEUR, "value": w3.to_wei(tresor_eth, "ether")}))
    prot = w3.eth.contract(address=rc.contractAddress, abi=ProtocoleC.abi)
    rc = w3.eth.wait_for_transaction_receipt(
        GouvC.constructor(ELECTEURS).transact({"from": DEPLOYEUR}))
    gv = w3.eth.contract(address=rc.contractAddress, abi=GouvC.abi)
    w3.eth.wait_for_transaction_receipt(
        prot.functions.transfererAutorite(gv.address).transact({"from": DEPLOYEUR}))
    return prot, gv

#prot_v, gouv_v = deployer_tout()
#resultat_ex1 = None  # TODO étudiant : boucle + tableau de coûts
# Indice : la fonction boucle() de la section 2 utilise les variables globales
# gouv/protocole ; pour l'instance vierge, réaffectez ces variables avant
# l'appel (protocole, gouv = prot, gv) ou écrivez une boucle adaptée.
# L'écart de gas vs la section 2 vient du stockage déjà chaud (première
# écriture d'une clé = plus cher que la mise à jour).

9. Exercice 2 : votre propre retour arrière

Sur une instance vierge : exécutez une migration (boucle complète) de la moitié du trésor vers un compte de votre choix, puis tentez le retour arrière — sans utiliser le compte destinataire. Concluez par une liste explicite de ce qui ne revient pas, au format de la §4 : au minimum le gas brûlé (mesuré), l’historique gravé (citeez les ids de propositions), et le statut des fonds migrés.

  • Étape 1 : déployer une instance vierge et exécuter la migration (boucle complète)
  • Étape 2 : tenter le rappel SANS le destinataire – observer qu’aucune voie n’existe
  • Étape 3 : conclure par la liste explicite de ce qui ne revient pas
resultat_ex2 = None  # TODO étudiant
# Indice : "tenter un rappel" = chercher dans l'ABI du Protocole une fonction qui
# rappelle des fonds extérieurs (il n'y en a aucune -- c'est la réponse). Le gas
# brûlé se mesure comme partout dans ce notebook ; les ids de propositions
# s'obtiennent par gouv_v.functions.nbPropositions().call().

10. Exercice 3 : votre table de dette

Choisissez trois formes de changement différentes de celles de la §7 (par exemple : changer frais avec des votes contre mais quorum atteint ; exécuter une proposition qui échoue à l’exécution — un appel setFrais visant un Protocole dont votre Gouvernance n’est plus l’autorité ; brûler 5 jetons au lieu de 7 et vérifier que le quorum reste atteignable). Pour chacune, remplissez le quadruplet mesuré : coût en gas de la boucle, quorum, délai, réversibilité démontrée.

  • Étape 1 : choisir trois scénarios et les exécuter sur instances vierges
  • Étape 2 : mesurer chaque quadruplet (gas depuis les receipts, pas d’estimation)
  • Étape 3 : imprimer votre table au format de la section 7
resultat_ex3 = None  # TODO étudiant
# Indice pour le scénario "exécution qui échoue" : la proposition est Approuvée
# par la Gouvernance mais l'appel sous-jacent revert -- que fait executer() ?
# (Relisez sa dernière ligne : le require(succes) -- le coût est payé, la
# proposition marquée Exécutée, et l'état n'a pas changé : encore une forme
# de dette, perte pure cette fois.)

11. Résumé

  • La boucle de gouvernance est mesurable de bout en bout : proposition, votes, finalisation, fenêtre de timelock, exécution — chaque transaction a son gas, son coût en wei brûlés, son bloc. Le squelette de gouvernance d’une décision de paramètre coûte 655 146 gas (849 801 avec la fenêtre d’usage) ; la destruction du mécanisme (7 brûlages) coûte 203 987.
  • Le retour arrière d’un changement réversible coûte une boucle complète — et ne restaure ni le gas brûlé, ni l’historique append-only, ni les surpaiements de fenêtre versés au trésor sans fonction de remboursement.
  • La réversibilité est un spectre à quatre échelons, démontrés : paramètre (réversible en état par le mécanisme), logique (réversible en état, pas en dégâts de fenêtre), migration (réversible hors mécanisme seulement), brûlage de jetons (aucun retour — gouvernance morte, prouvée par une proposition unanime refusée).
  • Le coût du gas ne mesure pas la gravité : l’acte le plus destructeur est le moins cher. La gravité institutionnelle n’est pas tarifée par le protocole ; c’est au concepteur de placer ses verrous (mint gardé, quorum relatif, multi-sig sur les migrations) là où l’asymétrie est intolérable.
  • Position dans la série : SC-9 a appris la mécanique, ce notebook a mesuré son coût d’irréversibilité — le pont vers SC-24/SC-25 où ces coûts, négligeables sur anvil, deviennent des décisions de déploiement réelles.

Sources et limites

Mesuré dans ce notebook : chaque chiffre (gas, coûts en wei, blocs, soldes, étapes) est l’output d’une exécution réelle sur anvil (chain id 31337, solc 0.8.28, web3 7.16).

Rapporté, non mesuré : les écarts de gas entre instances (storage chaud/froid) sont expliqués mais non systématisés ; le comportement des stacks de production (Governor + TimelockController d’OpenZeppelin, quorum relatif au supply, votes au blockhash) est rapporté comme différence déclarée — nos contrats sont volontairement transparents et simplifiés, ce ne sont pas des modèles de déploiement ; les prix du gas mainnet (et donc les coûts en ETH réels) varient de plusieurs ordres de grandeur selon l’activité du réseau — la mesure locale à 2 gwei de base fee est une convention de mesure, pas une prévision. Les incidents DeFi évoqués en §5 sont cités de seconde main (chronologie publique), non audités ici.

Retour au sommet