SC-10-Account-Abstraction-Python - ERC-4337 v0.9

<< DAO Governance | LLM Assisted >>


Objectifs d’apprentissage

  1. Comprendre l’account abstraction (ERC-4337)
  2. Construire une PackedUserOperation v0.9
  3. Comprendre le packing de accountGasLimits, gasFees et paymasterAndData
  4. Compiler et déployer un Smart Account, un EntryPoint et un Paymaster réels
  5. Distinguer une preuve locale de contrat d’un pipeline bundler complet

Prérequis

  • SC-3 à SC-9 complétés
  • Comprendre le modèle de compte Ethereum (EOA vs contrats)
  • Notions sur ERC-4337 (optionnel, couvert dans le notebook)

Durée estimée : 50 minutes


0. Périmètre de la démonstration locale

Le notebook compile avec le vrai binaire Foundry, déploie le véritable contrat EntryPoint de account-abstraction v0.9.0 ainsi que les contrats d’exemple, puis envoie les transactions à Anvil via Web3.

Anvil doit être lancé avec anvil --disable-code-size-limit : l’EntryPoint non optimisé dépasse la limite EIP-170 sur cette configuration pédagogique.

La preuve locale couvre le bytecode, le déploiement, l’interface ERC-165, le typehash v0.9, le SenderCreator et un appel réel à handleOps sur un batch vide. Elle ne prétend pas démontrer un service bundler, la soumission RPC eth_sendUserOperation, ni une UserOperation signée de bout en bout.


1. Concepts ERC-4337 v0.9

ERC-4337 permet de séparer la logique des comptes des wallets externes. La dépendance locale est épinglée sur account-abstraction v0.9.0, révision b36a1ed52ae00da6f8a4c8d50181e2877e4fa410.

Composant Description
PackedUserOperation Requête utilisateur dont plusieurs limites de gas sont regroupées en champs de 32 octets
EntryPoint Contrat singleton qui valide et exécute les opérations
Smart Account Wallet programmable qui valide l’autorisation
Paymaster Contrat qui dépose des fonds auprès de l’EntryPoint pour sponsoriser le gas
Bundler Service hors chaîne qui simule, agrège et soumet les opérations
# Configuration Web3, Foundry et account-abstraction v0.9.0
import json
import os
import shutil
import sys
from pathlib import Path

import solcx
from web3 import Web3

sys.path.insert(0, os.path.abspath(".."))
from forge_helper import forge_compile, forge_compile_and_deploy

if shutil.which("forge") is None:
    raise RuntimeError("forge doit être installé et présent dans PATH")

w3 = Web3(Web3.HTTPProvider("http://127.0.0.1:8545"))
if not w3.is_connected():
    raise RuntimeError("Anvil doit écouter sur http://127.0.0.1:8545")

deployer = w3.eth.accounts[0]
lock_path = Path("../foundry-lib/foundry.lock")
with lock_path.open(encoding="utf-8") as lock_file:
    foundry_lock = json.load(lock_file)

account_abstraction_rev = foundry_lock["lib/account-abstraction"]["rev"]
expected_rev = "b36a1ed52ae00da6f8a4c8d50181e2877e4fa410"
if account_abstraction_rev != expected_rev:
    raise RuntimeError("La dépendance account-abstraction n'est pas épinglée sur v0.9.0")

solcx.install_solc("0.8.28")
solcx.set_solc_version("0.8.28")


def compile_and_deploy(
    w3_instance, contract_source, constructor_arg=None, deploy_from=None
):
    """Compile via solc 0.8.28 et déploie réellement sur le provider connecté."""
    compiled = solcx.compile_source(
        contract_source,
        output_values=["abi", "bin"],
        solc_version="0.8.28",
    )
    contract_id, contract_interface = compiled.popitem()
    contract_name = contract_id.split(":")[-1]
    contract_factory = w3_instance.eth.contract(
        abi=contract_interface["abi"], bytecode=contract_interface["bin"]
    )
    sender = deploy_from if deploy_from is not None else w3_instance.eth.accounts[0]
    if constructor_arg is None:
        constructor_args = []
    elif isinstance(constructor_arg, (list, tuple)):
        constructor_args = list(constructor_arg)
    else:
        constructor_args = [constructor_arg]
    tx_hash = contract_factory.constructor(*constructor_args).transact({"from": sender})
    receipt = w3_instance.eth.wait_for_transaction_receipt(tx_hash)
    instance = w3_instance.eth.contract(
        address=receipt.contractAddress, abi=contract_interface["abi"]
    )
    print(f"Déployé : {contract_name} à {receipt.contractAddress}")
    return instance, receipt


def pack_uint128_pair(high_value: int, low_value: int) -> bytes:
    """Encode deux uint128 dans un bytes32, partie haute puis partie basse."""
    limit = 1 << 128
    if not (0 <= high_value < limit and 0 <= low_value < limit):
        raise ValueError("Chaque valeur doit tenir dans uint128")
    return ((high_value << 128) | low_value).to_bytes(32, "big")


PACKED_USER_OP_STRUCT = """
// account-abstraction v0.9.0
struct PackedUserOperation {
    address sender;
    uint256 nonce;
    bytes initCode;
    bytes callData;
    bytes32 accountGasLimits;
    uint256 preVerificationGas;
    bytes32 gasFees;
    bytes paymasterAndData;
    bytes signature;
}
"""

verification_gas_limit = 150_000
call_gas_limit = 100_000
max_priority_fee_per_gas = w3.to_wei(1, "gwei")
max_fee_per_gas = w3.to_wei(3, "gwei")
account_gas_limits = pack_uint128_pair(verification_gas_limit, call_gas_limit)
gas_fees = pack_uint128_pair(max_priority_fee_per_gas, max_fee_per_gas)

print(f"Web3 connecté à Anvil, chain_id={w3.eth.chain_id}, déployeur={deployer}")
print(f"Foundry disponible : {shutil.which('forge') is not None}")
print(f"account-abstraction v0.9.0 : {account_abstraction_rev}")
print(PACKED_USER_OP_STRUCT)
print(f"accountGasLimits : 0x{account_gas_limits.hex()}")
print(f"  verificationGasLimit={int.from_bytes(account_gas_limits[:16], 'big')}")
print(f"  callGasLimit={int.from_bytes(account_gas_limits[16:], 'big')}")
print(f"gasFees : 0x{gas_fees.hex()}")
print(f"  maxPriorityFeePerGas={int.from_bytes(gas_fees[:16], 'big')}")
print(f"  maxFeePerGas={int.from_bytes(gas_fees[16:], 'big')}")
Web3 connecté à Anvil, chain_id=31337, déployeur=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Foundry disponible : True
account-abstraction v0.9.0 : b36a1ed52ae00da6f8a4c8d50181e2877e4fa410

// account-abstraction v0.9.0
struct PackedUserOperation {
    address sender;
    uint256 nonce;
    bytes initCode;
    bytes callData;
    bytes32 accountGasLimits;
    uint256 preVerificationGas;
    bytes32 gasFees;
    bytes paymasterAndData;
    bytes signature;
}

accountGasLimits : 0x000000000000000000000000000249f0000000000000000000000000000186a0
  verificationGasLimit=150000
  callGasLimit=100000
gasFees : 0x0000000000000000000000003b9aca00000000000000000000000000b2d05e00
  maxPriorityFeePerGas=1000000000
  maxFeePerGas=3000000000

Interprétation : PackedUserOperation v0.9

La sortie provient du pin account-abstraction vérifié dans foundry.lock, puis montre le packing sur des valeurs décodées immédiatement :

Champ Layout v0.9
accountGasLimits 128 bits hauts : verificationGasLimit ; 128 bits bas : callGasLimit
gasFees 128 bits hauts : maxPriorityFeePerGas ; 128 bits bas : maxFeePerGas
paymasterAndData paymaster (20 octets), validation gas (16), postOp gas (16), données à partir de l’offset 52

Le prix effectivement facturable par l’opération est min(maxFeePerGas, maxPriorityFeePerGas + block.basefee). Comme une transaction Ethereum classique, la structure ne porte pas directement to et value : l’appel est encodé dans callData.

PackedUserOperation ne signifie pas que tous les champs sont compressés : sender, nonce, initCode, callData, preVerificationGas, paymasterAndData et signature restent distincts. Seules les deux paires de valeurs uint128 sont regroupées dans des bytes32.


2. EntryPoint v0.9 et Smart Account

Le contrat suivant hérite de BaseAccount v0.9. Nous compilons également le véritable EntryPoint, puis déployons les deux contrats sur Anvil. Le test handleOps([]) traverse réellement l’EntryPoint, mais son batch vide constitue seulement un smoke test du contrat déployé, pas une preuve de bundler ni de validation de signature.

# Compilation et déploiement du véritable EntryPoint v0.9 et du Smart Account
ENTRY_POINT = """
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "@account-abstraction/contracts/core/EntryPoint.sol";

contract LocalEntryPoint is EntryPoint {}
"""

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

import "@account-abstraction/contracts/core/BaseAccount.sol";
import "@account-abstraction/contracts/core/Helpers.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

contract SimpleAccount is BaseAccount {
    using ECDSA for bytes32;

    address public owner;
    IEntryPoint private immutable _entryPoint;

    constructor(address anOwner, IEntryPoint anEntryPoint) {
        owner = anOwner;
        _entryPoint = anEntryPoint;
    }

    function entryPoint() public view override returns (IEntryPoint) {
        return _entryPoint;
    }

    function _validateSignature(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash
    ) internal view override returns (uint256 validationData) {
        bytes32 hash = MessageHashUtils.toEthSignedMessageHash(userOpHash);
        (address recovered, ECDSA.RecoverError error, ) =
            ECDSA.tryRecover(hash, userOp.signature);
        if (error != ECDSA.RecoverError.NoError || recovered != owner) {
            return SIG_VALIDATION_FAILED;
        }
        return SIG_VALIDATION_SUCCESS;
    }

    function _requireForExecute() internal view override {
        require(
            msg.sender == address(_entryPoint) || msg.sender == owner,
            "only entry point or owner"
        );
    }

    receive() external payable {}
}
"""

entry_point_abi, entry_point_bytecode = forge_compile(
    ENTRY_POINT, "LocalEntryPoint"
)
entry_point_factory = w3.eth.contract(
    abi=entry_point_abi, bytecode=entry_point_bytecode
)
entry_point_tx = entry_point_factory.constructor().transact(
    {"from": deployer, "gas": 15_000_000}
)
entry_point_receipt = w3.eth.wait_for_transaction_receipt(entry_point_tx)
if entry_point_receipt.status != 1:
    raise RuntimeError("Le déploiement du véritable EntryPoint v0.9 a échoué")
entry_point = w3.eth.contract(
    address=entry_point_receipt.contractAddress, abi=entry_point_abi
)

account, account_receipt = forge_compile_and_deploy(
    w3,
    SIMPLE_ACCOUNT,
    "SimpleAccount",
    deployer,
    deployer,
    entry_point.address,
)

packed_type = (
    "PackedUserOperation(address sender,uint256 nonce,bytes initCode,bytes callData,"
    "bytes32 accountGasLimits,uint256 preVerificationGas,bytes32 gasFees,"
    "bytes paymasterAndData)"
)
expected_typehash = Web3.keccak(text=packed_type)
onchain_typehash = entry_point.functions.getPackedUserOpTypeHash().call()
sender_creator = entry_point.functions.senderCreator().call()

smoke_tx = entry_point.functions.handleOps([], deployer).transact(
    {"from": deployer, "gas": 500_000}
)
smoke_receipt = w3.eth.wait_for_transaction_receipt(smoke_tx)

print(f"EntryPoint v0.9 déployé : {entry_point.address}")
print(f"Bytecode de création : {len(entry_point_bytecode.removeprefix('0x')) // 2} octets")
print(f"SenderCreator local : {sender_creator}")
print(f"Typehash v0.9 vérifié : {onchain_typehash == expected_typehash}")
print(f"SimpleAccount déployé : {account.address}")
print(f"SimpleAccount.entryPoint() : {account.functions.entryPoint().call()}")
print(f"handleOps([]) réel : status={smoke_receipt.status}, gasUsed={smoke_receipt.gasUsed}")
print("Limite de preuve : aucun bundler ni UserOperation signée n'est exécuté ici.")
Deploye: SimpleAccount a 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82
EntryPoint v0.9 déployé : 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0
Bytecode de création : 33837 octets
SenderCreator local : 0x1F708C24a0D3A740cD47cC0444E9480899f3dA7D
Typehash v0.9 vérifié : True
SimpleAccount déployé : 0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82
SimpleAccount.entryPoint() : 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0
handleOps([]) réel : status=1, gasUsed=24319
Limite de preuve : aucun bundler ni UserOperation signée n'est exécuté ici.

Interprétation : preuve locale sur le véritable EntryPoint v0.9

La cellule précédente ne se contente pas d’imprimer du Solidity : forge_compile invoque Foundry sur les sources épinglées, puis Web3 envoie les transactions de déploiement à Anvil.

Les contrôles observables ont chacun une portée précise :

  • le typehash lu sur le contrat déployé est comparé au type PackedUserOperation v0.9 attendu ;
  • senderCreator() confirme que l’EntryPoint a créé son composant auxiliaire ;
  • SimpleAccount.entryPoint() confirme le câblage du compte vers cette instance locale ;
  • le receipt de handleOps([]) prouve un appel EVM réussi au véritable EntryPoint.

Le batch vide n’exerce ni validateUserOp, ni signature, ni remboursement de gas. Une preuve end-to-end exigerait une opération non vide correctement signée et, pour le chemin réseau usuel, un bundler exposant les méthodes RPC ERC-4337. Cette limite est volontairement maintenue dans la sortie.

Modèle autonome complémentaire

Le compte ERC-4337 ci-dessus est réellement déployé contre l’EntryPoint local. Le contrat autonome suivant conserve un exemple plus simple d’owner, d’exécution, de batch et de nonce. Il illustre ces propriétés de wallet sur Anvil, mais n’implémente ni validateUserOp ni le pipeline ERC-4337.

# Version autonome d'un wallet programmable (hors ERC-4337)
# Déploiement réel sur Anvil

STANDALONE_ACCOUNT = '''
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

/// @title StandaloneSmartAccount
/// @notice Wallet pédagogique autonome : owner, exécution, batch et nonce.
/// @dev Ce contrat n'implémente pas l'interface ERC-4337.
contract StandaloneSmartAccount {
    address public owner;
    uint256 public nonce;

    event Executed(address indexed dest, uint256 value, bytes data);
    event OwnerChanged(address indexed oldOwner, address indexed newOwner);

    modifier onlyOwner() {
        require(msg.sender == owner, "not owner");
        _;
    }

    constructor(address _owner) {
        owner = _owner;
    }

    // Exécuter une transaction
    function execute(address dest, uint256 value, bytes calldata data) external onlyOwner {
        nonce++;
        (bool success, ) = dest.call{value: value}(data);
        require(success, "execution failed");
        emit Executed(dest, value, data);
    }

    // Exécuter un batch
    function executeBatch(
        address[] calldata dests,
        uint256[] calldata values,
        bytes[] calldata datas
    ) external onlyOwner {
        require(dests.length == values.length && dests.length == datas.length, "length mismatch");
        for (uint256 i = 0; i < dests.length; i++) {
            nonce++;
            (bool success, ) = dests[i].call{value: values[i]}(datas[i]);
            require(success, "batch call failed");
            emit Executed(dests[i], values[i], datas[i]);
        }
    }

    // Changer le owner (récupération sociale, etc.)
    function changeOwner(address newOwner) external onlyOwner {
        require(newOwner != address(0), "zero address");
        emit OwnerChanged(owner, newOwner);
        owner = newOwner;
    }

    receive() external payable {}
}
'''

account, receipt = compile_and_deploy(w3, STANDALONE_ACCOUNT, deployer, deployer)

print(f"Owner: {account.functions.owner().call()}")
print(f"Nonce: {account.functions.nonce().call()}")

tx = w3.eth.send_transaction({
    'from': deployer,
    'to': account.address,
    'value': w3.to_wei(1, 'ether')
})
w3.eth.wait_for_transaction_receipt(tx)
balance = w3.eth.get_balance(account.address)
print(f"Balance du smart account: {w3.from_wei(balance, 'ether')} ETH")
Déployé : StandaloneSmartAccount à 0x0B306BF915C4d645ff596e518fAf3F9669b97016
Owner: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Nonce: 0
Balance du smart account: 1 ETH

Interprétation : déploiement et test du StandaloneSmartAccount

La sortie fraîche confirme que le contrat autonome est déployé sur Anvil, financé avec 1 ETH et lu directement via Web3. L’adresse locale est celle affichée par la cellule précédente : elle dépend de l’ordre des transactions de cette chaîne de test et n’est donc pas épinglée dans la prose.

Mesure Valeur observée Signification
Owner Compte deployer affiché dans la sortie Propriétaire initial du contrat
Nonce 0 Compteur d’opérations initialisé à zéro
Balance 1 ETH Solde après financement du compte autonome

Flux d’exécution : 1. Construction : le contrat est créé avec le deployer comme owner. 2. Funding : 1 ETH est envoyé au contrat pour les futures transactions. 3. Vérification : Web3 lit owner(), nonce() et la balance sur Anvil.

Ce modèle teste réellement les propriétés owner, nonce, exécution et batch du wallet autonome. Il ne possède cependant ni validateUserOp ni liaison à l’EntryPoint : la preuve ERC-4337 est portée par le SimpleAccount de la section précédente, et non par ce contrat complémentaire.

Exercice 2 : Smart Account avec liste blanche

Ajoutez un mécanisme de liste blanche au smart account : seules les adresses autorisees peuvent recevoir des fonds via execute().

Objectif : Comprendre comment un smart account peut restreindre ses transactions a un ensemble de destinataires de confiance.

Indice : Utilisez un mapping(address => bool) pour la liste blanche. Ajoutez des fonctions addToWhitelist(address) et removeFromWhitelist(address) reservees au owner. Dans execute(), ajoutez un require(isWhitelisted[dest]) avant l’appel externe.

# Exercice 2 : Smart Account avec liste blanche
# TODO etudiant : implementer un smart account avec une whitelist de destinataires autorises
# Indice : ajoutez un mapping(address => bool) pour la whitelist
# Etape 1 : implementer addToWhitelist(address) et removeFromWhitelist(address) - onlyOwner
# Etape 2 : modifier execute() pour verifier que dest est dans la whitelist
# Etape 3 : implementer isWhitelisted(address) - view

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

contract WhitelistAccount {
    address public owner;
    uint256 public nonce;

    // TODO etudiant : mapping de la liste blanche
    // Indice : mapping(address => bool) private _whitelist;

    event Executed(address indexed dest, uint256 value, bytes data);
    event WhitelistAdded(address indexed account);
    event WhitelistRemoved(address indexed account);

    modifier onlyOwner() {
        require(msg.sender == owner, \"not owner\");
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function addToWhitelist(address account) external onlyOwner {
        // TODO etudiant : ajouter account a la liste blanche
        // Indice : _whitelist[account] = true
        // TODO etudiant : emettre WhitelistAdded
    }

    function removeFromWhitelist(address account) external onlyOwner {
        // TODO etudiant : retirer account de la liste blanche
        // Indice : _whitelist[account] = false
        // TODO etudiant : emettre WhitelistRemoved
    }

    function isWhitelisted(address account) public view returns (bool) {
        // TODO etudiant : retourner le statut de la liste blanche
        return false;
    }

    function execute(address dest, uint256 value, bytes calldata data) external onlyOwner {
        // TODO etudiant : verifier que dest est dans la liste blanche
        // Indice : require(_whitelist[dest], \"dest not whitelisted\")
        // TODO etudiant : incrementer nonce, executer l appel, emettre Executed
    }

    receive() external payable {}
}
"""

print("Exercice a completer : WhitelistAccount avec liste blanche de destinataires")
Exercice a completer : WhitelistAccount avec liste blanche de destinataires

3. Paymaster v0.9

Un paymaster sponsorise le gas en déposant des fonds auprès de l’EntryPoint. En v0.9, paymasterAndData possède un en-tête standard de 52 octets ; les données propres à l’application commencent seulement après cet en-tête.

# VerifyingPaymaster v0.9 : parsing de l'en-tête standard et déploiement réel
PAYMASTER = """
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import "@account-abstraction/contracts/core/BasePaymaster.sol";
import "@account-abstraction/contracts/core/Helpers.sol";
import "@account-abstraction/contracts/core/UserOperationLib.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

contract VerifyingPaymaster is BasePaymaster {
    using ECDSA for bytes32;
    using UserOperationLib for bytes;

    address public verifyingSigner;

    constructor(IEntryPoint anEntryPoint, address aVerifyingSigner)
        BasePaymaster(anEntryPoint, msg.sender)
    {
        verifyingSigner = aVerifyingSigner;
    }

    function _validatePaymasterUserOp(
        PackedUserOperation calldata userOp,
        bytes32 userOpHash,
        uint256
    ) internal view override returns (bytes memory context, uint256 validationData) {
        (uint48 validUntil, uint48 validAfter) = abi.decode(
            userOp.paymasterAndData.getSignedPaymasterData(),
            (uint48, uint48)
        );
        bytes calldata signature =
            userOp.paymasterAndData.getPaymasterSignature();
        bytes32 digest = MessageHashUtils.toEthSignedMessageHash(
            keccak256(abi.encode(userOpHash, validUntil, validAfter))
        );
        (address recovered, ECDSA.RecoverError error, ) =
            ECDSA.tryRecover(digest, signature);
        bool signatureFailed =
            error != ECDSA.RecoverError.NoError || recovered != verifyingSigner;
        return ("", _packValidationData(signatureFailed, validUntil, validAfter));
    }

    function parseStaticFields(bytes calldata paymasterAndData)
        external pure
        returns (
            address paymaster,
            uint256 validationGasLimit,
            uint256 postOpGasLimit,
            bytes calldata signedPaymasterData,
            bytes calldata paymasterSignature
        )
    {
        require(
            paymasterAndData.length >= PAYMASTER_DATA_OFFSET,
            "invalid paymasterAndData"
        );
        (paymaster, validationGasLimit, postOpGasLimit) =
            UserOperationLib.unpackPaymasterStaticFields(paymasterAndData);
        signedPaymasterData = paymasterAndData.getSignedPaymasterData();
        paymasterSignature = paymasterAndData.getPaymasterSignature();
    }
}
"""

paymaster, paymaster_receipt = forge_compile_and_deploy(
    w3,
    PAYMASTER,
    "VerifyingPaymaster",
    deployer,
    entry_point.address,
    deployer,
)

paymaster_validation_gas = 120_000
post_op_gas = 80_000
valid_until = 2_000_000_000
valid_after = 0
validity_data = valid_until.to_bytes(32, "big") + valid_after.to_bytes(32, "big")
paymaster_signature = bytes.fromhex("11" * 65)
paymaster_signature_magic = bytes.fromhex("22e325a297439656")
paymaster_and_data = (
    bytes.fromhex(paymaster.address[2:])
    + paymaster_validation_gas.to_bytes(16, "big")
    + post_op_gas.to_bytes(16, "big")
    + validity_data
    + paymaster_signature
    + len(paymaster_signature).to_bytes(2, "big")
    + paymaster_signature_magic
)

(
    parsed_paymaster,
    parsed_validation_gas,
    parsed_post_op_gas,
    parsed_validity_data,
    parsed_signature,
) = paymaster.functions.parseStaticFields(paymaster_and_data).call()
parsed_valid_until = int.from_bytes(parsed_validity_data[:32], "big")
parsed_valid_after = int.from_bytes(parsed_validity_data[32:], "big")

print(f"VerifyingPaymaster déployé : {paymaster.address}")
print(f"EntryPoint câblé : {paymaster.functions.entryPoint().call()}")
print(f"paymasterAndData : {len(paymaster_and_data)} octets")
print(f"  [0:20] paymaster={parsed_paymaster}")
print(f"  [20:36] paymasterValidationGasLimit={parsed_validation_gas}")
print(f"  [36:52] postOpGasLimit={parsed_post_op_gas}")
print(f"  [52:data] validUntil={parsed_valid_until}, validAfter={parsed_valid_after}")
print(f"  suffixe optionnel : signature={len(parsed_signature)} octets, magic=0x{paymaster_signature_magic.hex()}")
Deploye: VerifyingPaymaster a 0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE
VerifyingPaymaster déployé : 0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE
EntryPoint câblé : 0xA51c1fc2f0D1a1b8494Ed1FE312d7C3a78Ed91C0
paymasterAndData : 191 octets
  [0:20] paymaster=0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE
  [20:36] paymasterValidationGasLimit=120000
  [36:52] postOpGasLimit=80000
  [52:data] validUntil=2000000000, validAfter=0
  suffixe optionnel : signature=65 octets, magic=0x22e325a297439656

Interprétation : layout paymasterAndData v0.9

La sortie lit les champs avec UserOperationLib du SDK v0.9 déployé et confirme les bornes standard :

Intervalle Contenu
[0:20] adresse du paymaster
[20:36] paymasterValidationGasLimit, encodé en uint128
[36:52] postOpGasLimit, encodé en uint128
[52:dataLen] données propres au paymaster

Dans cet exemple, les données propres au paymaster encodent validUntil et validAfter avec abi.encode. Elles ne sont donc plus confondues avec l’en-tête standard.

L’extension de signature v0.9 est facultative. Quand elle est utilisée, le suffixe est :

paymasterSignature || uint16(signatureLength) || 0x22e325a297439656

La bibliothèque exclut ce suffixe du hash signé par l’utilisateur et le retourne séparément. Le contrat est réellement déployé contre l’EntryPoint local, mais cette cellule teste le parsing ; elle ne présente pas la signature factice de 65 octets comme une autorisation valide.

Modèle pédagogique standalone

La version suivante illustre localement une comptabilité de budget de sponsoring, sans dépendre de l’EntryPoint. Elle est explicitement séparée du VerifyingPaymaster v0.9 ci-dessus : débiter un mapping et émettre GasSponsored ne rembourse pas du gas EVM et ne constitue pas une exécution ERC-4337.

# Modèle standalone de comptabilité de sponsoring (hors pipeline ERC-4337)
STANDALONE_PAYMASTER = """
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

/// @title StandaloneSponsorBudget
/// @notice Modèle pédagogique de budget ; ce contrat n'est pas un paymaster ERC-4337.
contract StandaloneSponsorBudget {
    address public owner;
    mapping(address => uint256) public deposits;

    event Deposited(address indexed account, uint256 amount);
    event Withdrawn(address indexed account, uint256 amount);
    event BudgetDebited(address indexed account, uint256 amount);

    constructor() {
        owner = msg.sender;
    }

    function deposit() external payable {
        deposits[msg.sender] += msg.value;
        emit Deposited(msg.sender, msg.value);
    }

    function withdraw(uint256 amount) external {
        require(deposits[msg.sender] >= amount, "insufficient deposit");
        deposits[msg.sender] -= amount;
        payable(msg.sender).transfer(amount);
        emit Withdrawn(msg.sender, amount);
    }

    function debitBudget(address account, uint256 amount) external {
        require(msg.sender == owner, "only owner");
        require(deposits[owner] >= amount, "insufficient funds");
        deposits[owner] -= amount;
        emit BudgetDebited(account, amount);
    }
}
"""

sponsor_budget, receipt = compile_and_deploy(
    w3, STANDALONE_PAYMASTER, None, deployer
)

tx = sponsor_budget.functions.deposit().transact(
    {"from": deployer, "value": w3.to_wei(5, "ether")}
)
w3.eth.wait_for_transaction_receipt(tx)
deposit = sponsor_budget.functions.deposits(deployer).call()

user = w3.eth.accounts[1]
tx = sponsor_budget.functions.debitBudget(
    user, w3.to_wei(0.1, "ether")
).transact({"from": deployer})
w3.eth.wait_for_transaction_receipt(tx)
deposit_after = sponsor_budget.functions.deposits(deployer).call()

print(f"Modèle StandaloneSponsorBudget déployé : {sponsor_budget.address}")
print(f"Budget initial : {w3.from_wei(deposit, 'ether')} ETH")
print(f"Débit comptable attribué à {user[:10]}... : 0.1 ETH")
print(f"Budget restant : {w3.from_wei(deposit_after, 'ether')} ETH")
print("Limite : aucune mesure de gas ni interaction EntryPoint dans ce modèle.")
Déployé : StandaloneSponsorBudget à 0x68B1D87F95878fE05B998F19b66F4baba5De1aed
Modèle StandaloneSponsorBudget déployé : 0x68B1D87F95878fE05B998F19b66F4baba5De1aed
Budget initial : 5 ETH
Débit comptable attribué à 0x70997970... : 0.1 ETH
Budget restant : 4.9 ETH
Limite : aucune mesure de gas ni interaction EntryPoint dans ce modèle.

Interprétation : comptabilité standalone, pas remboursement de gas

La transaction réelle sur Anvil démontre qu’un budget déposé peut être débité et audité par événement. Elle ne démontre pas un paymaster ERC-4337 :

  1. aucun PackedUserOperation n’est fourni ;
  2. aucun appel validatePaymasterUserOp ou postOp n’a lieu ;
  3. aucun coût EVM réel n’est calculé ni remboursé ;
  4. le montant débité est une unité comptable choisie pour l’exemple.

Le mécanisme v0.9 réel est celui de la cellule précédente : le paymaster dépose auprès de l’EntryPoint, lequel prélève le coût de l’opération et appelle éventuellement postOp. Cette version standalone reste utile pour l’exercice de budget par utilisateur, mais elle ne porte pas la preuve ERC-4337.

Exercice 3 : politique autonome de budget par utilisateur

Complétez le contrat StandaloneBudgetPolicy pour attribuer un budget comptable individuel à chaque utilisateur. Le débit ne doit pas dépasser le budget alloué.

Objectif : isoler la logique de quotas qui pourrait alimenter la politique d’un paymaster, sans prétendre que ce scaffold autonome implémente BasePaymaster ou rembourse du gas via l’EntryPoint.

Indice : ajoutez deux mappings budgets(address => uint256) et spent(address => uint256). La fonction debitBudget doit vérifier que spent[user] + amount <= budgets[user] avant de débiter.

# Exercice 3 : politique autonome de budget par utilisateur
# TODO etudiant : implémenter les quotas comptables d'une politique de sponsoring
# Indice : ajoutez un mapping(address => uint256) pour les budgets
# Etape 1 : implémenter setUserBudget(address user, uint256 budget) - onlyOwner
# Etape 2 : implémenter getUserBudget(address user) - view
# Etape 3 : modifier debitBudget pour vérifier spent[user] + amount <= budgets[user]
# Etape 4 : ajouter un mapping(address => uint256) pour suivre les dépenses

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

/// @notice Exercice autonome de politique budgétaire, hors pipeline ERC-4337.
contract StandaloneBudgetPolicy {
    address public owner;

    // TODO etudiant : mapping des budgets alloués par utilisateur
    // Indice : mapping(address => uint256) public budgets;

    // TODO etudiant : mapping des dépenses par utilisateur
    // Indice : mapping(address => uint256) public spent;

    event BudgetSet(address indexed user, uint256 budget);
    event BudgetDebited(address indexed user, uint256 amount);

    modifier onlyOwner() {
        // TODO etudiant : vérifier que msg.sender == owner
        pass;
        _;
    }

    constructor() {
        owner = msg.sender;
    }

    function setUserBudget(address user, uint256 budget) external onlyOwner {
        // TODO etudiant : définir le budget de l'utilisateur
        pass;
    }

    function getUserBudget(address user) external view returns (uint256) {
        // TODO etudiant : retourner le budget de l'utilisateur
        return 0;
    }

    function debitBudget(address user, uint256 amount) external onlyOwner {
        // TODO etudiant : vérifier que le budget de l'utilisateur est suffisant
        // Indice : require(spent[user] + amount <= budgets[user], "budget exceeded")
        // TODO etudiant : incrémenter spent[user]
        // TODO etudiant : émettre BudgetDebited
        pass;
    }

    function getRemainingBudget(address user) external view returns (uint256) {
        // TODO etudiant : retourner budgets[user] - spent[user]
        return 0;
    }
}
"""

print("Exercice à compléter : StandaloneBudgetPolicy avec quotas par utilisateur")
Exercice à compléter : StandaloneBudgetPolicy avec quotas par utilisateur

4. Flux complet ERC-4337 v0.9 et limite de la preuve

La cellule suivante récapitule le pipeline complet. Les étapes marquées exécutées ici ont une preuve dans les receipts Anvil précédents ; les étapes bundler et UserOperation non vide restent décrites, mais ne sont pas présentées comme exécutées.

# Flux ERC-4337 v0.9 et statut de chaque étape
print("""
FLUX ERC-4337 v0.9

1. CONSTRUCTION — DÉCRITE
   Wallet -> construit PackedUserOperation -> packe accountGasLimits et gasFees

2. SOUMISSION — NON EXÉCUTÉE ICI
   Wallet -> eth_sendUserOperation -> Bundler

3. SIMULATION ET VALIDATION — NON EXÉCUTÉES SUR UNE OPÉRATION NON VIDE
   Bundler -> simule auprès de l'EntryPoint
   EntryPoint -> validateUserOp() sur le Smart Account
   EntryPoint -> validatePaymasterUserOp() si paymasterAndData est renseigné

4. EXÉCUTION LOCALE — PARTIELLEMENT PROUVÉE
   Web3 -> handleOps([]) sur le véritable EntryPoint v0.9 déployé sur Anvil
   Le receipt réussi prouve l'appel au contrat, pas une UserOperation signée

ADRESSE SINGLETON OFFICIELLE v0.9 :
- Réseaux compatibles : 0x433709009B8330FDa32311DF1C2AFA402eD8D009
- SenderCreator v0.9 : 0x0A630a99Df908A81115A3022927Be82f9299987e
- Instance de ce notebook : adresse locale dynamique affichée plus haut

Goerli est déprécié : il n'est pas proposé comme réseau actif.
""")

FLUX ERC-4337 v0.9

1. CONSTRUCTION — DÉCRITE
   Wallet -> construit PackedUserOperation -> packe accountGasLimits et gasFees

2. SOUMISSION — NON EXÉCUTÉE ICI
   Wallet -> eth_sendUserOperation -> Bundler

3. SIMULATION ET VALIDATION — NON EXÉCUTÉES SUR UNE OPÉRATION NON VIDE
   Bundler -> simule auprès de l'EntryPoint
   EntryPoint -> validateUserOp() sur le Smart Account
   EntryPoint -> validatePaymasterUserOp() si paymasterAndData est renseigné

4. EXÉCUTION LOCALE — PARTIELLEMENT PROUVÉE
   Web3 -> handleOps([]) sur le véritable EntryPoint v0.9 déployé sur Anvil
   Le receipt réussi prouve l'appel au contrat, pas une UserOperation signée

ADRESSE SINGLETON OFFICIELLE v0.9 :
- Réseaux compatibles : 0x433709009B8330FDa32311DF1C2AFA402eD8D009
- SenderCreator v0.9 : 0x0A630a99Df908A81115A3022927Be82f9299987e
- Instance de ce notebook : adresse locale dynamique affichée plus haut

Goerli est déprécié : il n'est pas proposé comme réseau actif.

5. Exemple guide et Exercice

L’exemple guidé ci-dessous montre un contrat résolu (solution étudiante). Un exercice à compléter suit.

# Exemple guide : Social Recovery Account (solution damiendth + godric + kim, TP 2026)
# Version standalone (sans dependances ERC-4337)
# Contrat avec recuperation sociale via des gardiens : startRecovery, approveRecovery, cancelRecovery
# Solution etudiante : Godric Bouteloup, Kim Tayant-Serrat, Damien Duthou (TP 2026)

EXERCISE_RECOVERY = '''
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

/// @title SocialRecoveryAccount
/// @notice Smart account avec recuperation sociale via des gardiens.
/// Si le owner perd sa cle, les gardiens peuvent voter pour changer le owner.
contract SocialRecoveryAccount {
    address public owner;
    address[] public guardians;
    mapping(address => bool) public isGuardian;
    uint256 public guardianCount;
    uint256 public threshold;
    uint256 public nonce;

    // Recovery state
    address public pendingNewOwner;
    uint256 public recoveryApprovals;
    mapping(address => bool) public hasApprovedRecovery;

    event GuardianAdded(address indexed guardian);
    event RecoveryStarted(address indexed newOwner);
    event RecoveryApproved(address indexed guardian);
    event RecoveryCompleted(address indexed newOwner);
    event Executed(address indexed dest, uint256 value);

    modifier onlyOwner() {
        require(msg.sender == owner, "not owner");
        _;
    }

    modifier onlyGuardian() {
        require(isGuardian[msg.sender], "not guardian");
        _;
    }

    constructor(
        address _owner,
        address[] memory _guardians,
        uint256 _threshold
    ) {
        require(_owner != address(0), "zero owner");
        require(_threshold > 0, "threshold zero");
        require(_threshold <= _guardians.length, "threshold too high");
        owner = _owner;
        threshold = _threshold;
        for (uint256 i = 0; i < _guardians.length; i++) {
            address g = _guardians[i];
            require(g != address(0), "zero guardian");
            require(!isGuardian[g], "duplicate guardian");
            isGuardian[g] = true;
            guardians.push(g);
            guardianCount++;
            emit GuardianAdded(g);
        }
    }

    function execute(address dest, uint256 value, bytes calldata data) external onlyOwner {
        nonce++;
        (bool success, ) = dest.call{value: value}(data);
        require(success, "execution failed");
        emit Executed(dest, value);
    }

    function startRecovery(address newOwner) external onlyGuardian {
        require(pendingNewOwner == address(0), "recovery in progress");
        require(newOwner != address(0), "zero new owner");
        pendingNewOwner = newOwner;
        hasApprovedRecovery[msg.sender] = true;
        recoveryApprovals = 1;
        emit RecoveryStarted(newOwner);
        emit RecoveryApproved(msg.sender);
    }

    function approveRecovery() external onlyGuardian {
        require(pendingNewOwner != address(0), "no recovery");
        require(!hasApprovedRecovery[msg.sender], "already approved");
        hasApprovedRecovery[msg.sender] = true;
        recoveryApprovals++;
        emit RecoveryApproved(msg.sender);
        if (recoveryApprovals >= threshold) {
            address newOwner = pendingNewOwner;
            owner = newOwner;
            for (uint256 i = 0; i < guardians.length; i++) {
                hasApprovedRecovery[guardians[i]] = false;
            }
            pendingNewOwner = address(0);
            recoveryApprovals = 0;
            emit RecoveryCompleted(newOwner);
        }
    }

    function cancelRecovery() external onlyOwner {
        require(pendingNewOwner != address(0), "no recovery");
        for (uint256 i = 0; i < guardians.length; i++) {
            hasApprovedRecovery[guardians[i]] = false;
        }
        pendingNewOwner = address(0);
        recoveryApprovals = 0;
    }

    receive() external payable {}
}
'''

guardian1 = w3.eth.accounts[1]
guardian2 = w3.eth.accounts[2]
guardian3 = w3.eth.accounts[3]
new_owner = w3.eth.accounts[4]

recovery, receipt = compile_and_deploy(
    w3, EXERCISE_RECOVERY, [deployer, [guardian1, guardian2, guardian3], 2], deploy_from=deployer
)
print(f"Owner initial: {recovery.functions.owner().call()}")
print(f"Gardiens: {recovery.functions.guardianCount().call()}, threshold: {recovery.functions.threshold().call()}")

tx = w3.eth.send_transaction({'from': deployer, 'to': recovery.address, 'value': w3.to_wei(2, 'ether')})
w3.eth.wait_for_transaction_receipt(tx)

dest = w3.eth.accounts[5]
balance_before = w3.eth.get_balance(dest)
tx = recovery.functions.execute(dest, w3.to_wei(1, 'ether'), b'').transact({'from': deployer})
w3.eth.wait_for_transaction_receipt(tx)
balance_after = w3.eth.get_balance(dest)
print(f"Execute: {w3.from_wei(balance_after - balance_before, 'ether')} ETH transfere, nonce: {recovery.functions.nonce().call()}")

tx = recovery.functions.startRecovery(new_owner).transact({'from': guardian1})
w3.eth.wait_for_transaction_receipt(tx)
print(f"Recovery demarree par guardian1, approvals: {recovery.functions.recoveryApprovals().call()}")

tx = recovery.functions.approveRecovery().transact({'from': guardian2})
w3.eth.wait_for_transaction_receipt(tx)
print(f"Approuvee par guardian2 -> nouveau owner: {recovery.functions.owner().call()}")
print(f"Nouveau owner attendu:                    {new_owner}")
print(f"Etat recovery reset (pendingNewOwner): {recovery.functions.pendingNewOwner().call()}")
Déployé : SocialRecoveryAccount à 0x59b670e9fA9D0A427751Af201D676719a970857b
Owner initial: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
Gardiens: 3, threshold: 2
Execute: 1 ETH transfere, nonce: 1
Recovery demarree par guardian1, approvals: 1
Approuvee par guardian2 -> nouveau owner: 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65
Nouveau owner attendu:                    0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65
Etat recovery reset (pendingNewOwner): 0x0000000000000000000000000000000000000000

Analyse : Social Recovery Account

Solution validée (Godric Bouteloup, Kim Tayant-Serrat, Damien Duthou, TP 2026) : Le contrat SocialRecoveryAccount implémente un système complet de récupération sociale.

Fonction Modificateur Mécanisme
constructor(owner, guardians[], threshold) - Initialise owner + gardiens avec vérification doublons
execute(dest, value, data) onlyOwner Exécution de transaction avec incrément nonce
startRecovery(newOwner) onlyGuardian Premier vote + enregistrement pendingNewOwner
approveRecovery() onlyGuardian Vote supplémentaire, si approvals >= threshold → transfert ownership
cancelRecovery() onlyOwner Reset complet de l’état de récupération

Flux de récupération : 1. Un gardien appelle startRecovery(newOwner) → pendingNewOwner enregistré, premier vote compté 2. D’autres gardiens appellent approveRecovery() → votes accumulés 3. Quand recoveryApprovals >= threshold → ownership transféré automatiquement, état reset 4. L’owner peut cancelRecovery() à tout moment avant la complétion

Points clés : - Le pattern threshold sur N gardiens évite la perte de compte si un gardien est compromis - Le require(!isGuardian[g]) dans le constructeur empêche les doublons - hasApprovedRecovery empêche un gardien de voter deux fois - Le reset complet dans approveRecovery() et cancelRecovery() nettoie tous les mappings

Exercice : Daily Spending Limit

Créez un contrat SpendingLimitAccount qui reprend la structure du StandaloneSmartAccount de la section 2 et ajoute une limite de dépense journalière. Le scaffold reste autonome : il ne prétend pas hériter du contrat compilé dans une autre cellule.

# Exercice : Daily Spending Limit
# TODO etudiant : implementer un contrat SpendingLimitAccount
# Indice : inspirez-vous du StandaloneSmartAccount (section 2) pour la structure de base
# Etape 1 : ajouter un dailyLimit (uint256) et spentToday (mapping address => uint256)
# Etape 2 : ajouter lastResetDate (mapping address => uint256 timestamp)
# Etape 3 : override execute() pour verifier spentToday + amount <= dailyLimit
# Etape 4 : si la date a change (block.timestamp / 86400 != lastResetDate), reset spentToday

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

contract SpendingLimitAccount {
    address public owner;
    uint256 public nonce;
    uint256 public dailyLimit;

    // TODO etudiant : ajouter les variables d'etat pour le tracking quotidien
    // Indice : spentToday, lastResetDay (jour = block.timestamp / 86400)

    event Executed(address indexed dest, uint256 value, bytes data);

    modifier onlyOwner() {
        // TODO etudiant : verifier que msg.sender == owner
        pass;
        _;
    }

    constructor(uint256 _dailyLimit) {
        // TODO etudiant : initialiser owner et dailyLimit
    }

    function execute(address dest, uint256 value, bytes calldata data) external onlyOwner {
        // TODO etudiant : verifier et mettre a jour le plafond quotidien
        // Etape 1 : verifier si le jour a change, si oui reset spentToday
        // Etape 2 : verifier que spentToday + value <= dailyLimit
        // Etape 3 : incrementer nonce, executer l'appel, mettre a jour spentToday
        pass
    }

    function setDailyLimit(uint256 newLimit) external onlyOwner {
        // TODO etudiant : permettre au owner de changer la limite
        pass
    }

    function getSpentToday() public view returns (uint256) {
        // TODO etudiant : retourner la depense du jour
        return 0;
    }

    receive() external payable {}
}
"""

print("Exercice a completer : SpendingLimitAccount avec plafond quotidien")
Exercice a completer : SpendingLimitAccount avec plafond quotidien

6. Résumé

Concept État démontré dans ce notebook
PackedUserOperation v0.9 Structure exacte et packing uint128 exécuté en Python
EntryPoint v0.9 Compilation Foundry, déploiement Anvil, typehash et handleOps([]) vérifiés
Smart Account Contrat BaseAccount compilé et déployé contre l’EntryPoint local
Paymaster v0.9 Contrat BasePaymaster déployé et layout [0:20]/[20:36]/[36:52]/[52:] décodé
Modèles standalone Transactions Anvil réelles, explicitement hors pipeline ERC-4337
Bundler / opération signée Décrits, non exécutés dans cette démonstration

Notebook suivant : SC-11-LLM-Assisted-Python

Résumé et perspectives

Ce notebook aligne le cours sur account-abstraction v0.9.0. Il montre la structure PackedUserOperation, le regroupement de deux uint128 dans accountGasLimits et gasFees, ainsi que le layout standard de paymasterAndData et son suffixe de signature facultatif.

La chaîne locale utilise les vrais outils : Foundry compile les contrats épinglés, Web3 les déploie sur Anvil, puis les receipts et lectures on-chain vérifient l’EntryPoint, le Smart Account et le Paymaster. Les exemples autonomes restent présents pour isoler owner, récupération sociale et budgets, mais leur plafond pédagogique est annoncé explicitement.

La démonstration ne revendique pas un pipeline bundler complet : elle n’appelle pas eth_sendUserOperation et ne soumet pas d’opération non vide signée. Cette séparation permet de distinguer une preuve locale réelle des contrats d’une preuve end-to-end ERC-4337.

Le notebook suivant, SC-11-LLM-Assisted-Python, explore l’assistance des modèles de langage pour générer, compiler et auditer des smart contracts.


<< DAO Governance | LLM Assisted >>

Retour au sommet