# Paymasters ERC-4337 : les transactions sans gas expliquees
L'un des plus gros points de friction pour faire entrer de nouveaux utilisateurs dans une dapp, c'est le gas. Un utilisateur doit detenir de l'ETH natif avant de pouvoir faire quoi que ce soit, meme minter un NFT gratuit ou reclamer un airdrop. L'account abstraction ERC-4337 resout cela avec un composant appele paymaster : un smart contract qui accepte de payer le gas d'une transaction, soit gratuitement (sponsorise), soit en echange d'un token ERC-20. Cet article rappelle l'account abstraction, puis detaille exactement ce que fait un paymaster et comment en construire un verifiant minimal.
L'account abstraction et la UserOperation, en bref
ERC-4337 ne modifie pas le protocole Ethereum. Au lieu d'envoyer une transaction classique depuis un compte externe (EOA), l'utilisateur signe une UserOperation : une struct decrivant ce que son compte smart contract doit faire.
Les UserOperations sont envoyees dans une mempool alternative, recuperees par des acteurs appeles bundlers, et soumises par lot a un unique contrat audite appele EntryPoint. L'EntryPoint execute chaque UserOperation en deux phases :
Les champs cles d'une UserOperation incluent le sender (le compte smart), le nonce, le callData (ce qu'il faut executer), les limites de gas et paymasterAndData. C'est ce dernier champ qui active un paymaster.
Ce que fait reellement un paymaster
Un paymaster est un contrat qui a stake et depose de l'ETH aupres de l'EntryPoint. Quand une UserOperation contient un paymasterAndData non vide, l'EntryPoint debite le cout du gas du depot du paymaster au lieu du compte sender.
L'interface paymaster a deux fonctions que l'EntryPoint appelle :
validatePaymasterUserOp: appelee pendant la phase de validation. Le paymaster decide s'il sponsorise cette operation et retourne un blob de contexte ainsi que des donnees de validation.
postOp: appelee apres l'execution. C'est la que le paymaster reconcilie le cout reel, par exemple en retirant des tokens ERC-20 a l'utilisateur pour couvrir le gas qu'il vient d'avancer.
Voici l'interface telle que definie par le standard (forme EntryPoint v0.7, avec PackedUserOperation) :
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
struct PackedUserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
bytes32 accountGasLimits;
uint256 preVerificationGas;
bytes32 gasFees;
bytes paymasterAndData;
bytes signature;
}
enum PostOpMode {
opSucceeded, // la user op a reussi
opReverted, // la user op a revert, le gas doit quand meme etre paye
postOpReverted // un postOp precedent a revert ; usage interne seulement
}
interface IPaymaster {
function validatePaymasterUserOp(
PackedUserOperation calldata userOp,
bytes32 userOpHash,
uint256 maxCost
) external returns (bytes memory context, uint256 validationData);
function postOp(
PostOpMode mode,
bytes calldata context,
uint256 actualGasCost,
uint256 actualUserOpGasPrice
) external;
}
validatePaymasterUserOp en detail
Cette fonction doit etre peu couteuse et ne pas dependre d'un etat externe mutable qu'une autre UserOperation pourrait changer, car les bundlers la simulent hors chaine et rejettent les operations qui cassent les regles de validation.
Elle retourne deux choses :
- context : des bytes arbitraires transmis a
postOp. Gardez-le petit. Typiquement, l'adresse de l'utilisateur et le prix du token que vous avez verrouille.
- validationData : une valeur compactee portant une adresse d'agregateur (ou la valeur speciale pour "signature echouee"), plus une fenetre temporelle
validUntiletvalidAfter. Un retour de0signifie "valide pour toujours, sans agregateur".
L'argument maxCost est le maximum que l'operation pourrait couter en wei. Le paymaster s'en sert pour decider s'il est pret a avancer ce montant.
postOp en detail
Apres l'execution du callData du compte, l'EntryPoint appelle postOp avec le cout de gas reel. Le mode vous indique si la user op a reussi ou a revert. Point crucial : le paymaster paie le gas meme si l'appel de l'utilisateur a revert, donc la logique de facturation appartient ici, pas a l'execution elle-meme.
Un piege subtil : dans l'EntryPoint v0.7, si postOp lui-meme revert, toute la user op est annulee et rejouee, mais le paymaster est quand meme facture. Donc postOp doit etre robuste.
Gas sponsorise vs paiement du gas en ERC-20
Il existe deux designs courants de paymaster.
Paymaster sponsorise (verifiant). Le gas est gratuit pour l'utilisateur. Un service hors chaine appartenant a la dapp signe une approbation disant "je paierai pour cette UserOperation precise jusqu'a cet horodatage". Le paymaster on chain verifie seulement cette signature. C'est ainsi qu'on offre aux nouveaux utilisateurs une premiere experience sans gas. La dapp absorbe le cout.
Paymaster ERC-20 (token). L'utilisateur n'a pas d'ETH mais detient par exemple de l'USDC. Le paymaster avance le gas en ETH a l'EntryPoint, puis dans postOp appelle transferFrom pour retirer un montant d'USDC equivalent (plus une petite marge) a l'utilisateur. L'utilisateur doit avoir approuve le paymaster au prealable. Le taux de change est la partie delicate : soit vous faites confiance a un oracle, soit vous fixez un prix dans les donnees signees.
Une comparaison rapide :
- Qui paie : sponsorise = la dapp, ERC-20 = l'utilisateur (en tokens).
- Ce qu'il faut a l'utilisateur : sponsorise = rien, ERC-20 = un solde de tokens plus une approbation.
- Risque principal : sponsorise = abus et griefing de vos fonds, ERC-20 = slippage de prix entre la validation et
postOp.
Construire un paymaster verifiant minimal
Construisons la variante sponsorisee. La logique : le paymaster fait confiance a un verifyingSigner (une cle hors chaine). Le backend de la dapp signe un hash de la UserOperation plus une fenetre temporelle. On chain, le paymaster recupere le signataire et approuve.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;
import {IPaymaster, PackedUserOperation, PostOpMode} from "./IPaymaster.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
contract MinimalVerifyingPaymaster is IPaymaster {
using ECDSA for bytes32;
address public immutable entryPoint;
address public immutable verifyingSigner;
// marqueurs de succes / echec pour validationData (selon ERC-4337)
uint256 private constant SIG_VALIDATION_FAILED = 1;
uint256 private constant SIG_VALIDATION_SUCCESS = 0;
constructor(address _entryPoint, address _verifyingSigner) {
entryPoint = _entryPoint;
verifyingSigner = _verifyingSigner;
}
modifier onlyEntryPoint() {
require(msg.sender == entryPoint, "not from EntryPoint");
_;
}
// Le signataire hors chaine signe ce hash. Liez-le a la chaine + au
// paymaster pour qu'une signature ne soit pas rejouee ailleurs.
function getHash(
PackedUserOperation calldata userOp,
uint48 validUntil,
uint48 validAfter
) public view returns (bytes32) {
return keccak256(
abi.encode(
userOp.sender,
userOp.nonce,
keccak256(userOp.callData),
block.chainid,
address(this),
validUntil,
validAfter
)
);
}
function validatePaymasterUserOp(
PackedUserOperation calldata userOp,
bytes32, // userOpHash, inutilise ici
uint256 // maxCost, inutilise dans cette variante simple
) external view onlyEntryPoint returns (bytes memory context, uint256 validationData) {
// disposition de paymasterAndData (v0.7) :
// [0:20] adresse du paymaster
// [20:36] limite de gas de verification du paymaster
// [36:52] limite de gas postOp du paymaster
// [52:] nos donnees custom : validUntil | validAfter | signature
bytes calldata data = userOp.paymasterAndData[52:];
uint48 validUntil = uint48(bytes6(data[0:6]));
uint48 validAfter = uint48(bytes6(data[6:12]));
bytes calldata signature = data[12:];
bytes32 hash = getHash(userOp, validUntil, validAfter)
.toEthSignedMessageHash();
bool ok = hash.recover(signature) == verifyingSigner;
// Compacte validationData : flag d'echec dans le bit bas, puis fenetre.
validationData = _packValidationData(!ok, validUntil, validAfter);
context = ""; // rien a reconcilier dans postOp pour un sponsoring gratuit
}
function postOp(
PostOpMode,
bytes calldata,
uint256,
uint256
) external onlyEntryPoint {
// Gas sponsorise : rien a faire. Un paymaster ERC-20 ferait ici un
// transferFrom de l'utilisateur a partir de actualGasCost.
}
function _packValidationData(
bool sigFailed,
uint48 validUntil,
uint48 validAfter
) internal pure returns (uint256) {
return (sigFailed ? SIG_VALIDATION_FAILED : SIG_VALIDATION_SUCCESS)
| (uint256(validUntil) << 160)
| (uint256(validAfter) << (160 + 48));
}
}
Remarquez que pour le gas sponsorise, postOp est vide et context est vide. Pour un paymaster ERC-20, vous encoderiez plutot l'adresse de l'utilisateur et un taux de token dans context pendant la validation, puis appelleriez token.transferFrom(user, address(this), tokenAmount) dans postOp en utilisant actualGasCost.
Financer le paymaster
Un paymaster ne fait rien sans fonds. Deux actions sont requises aupres de l'EntryPoint :
entryPoint.depositTo{value: x}(paymaster).entryPoint.addStake{value: y}(unstakeDelaySec).Pieges courants
- Violations des regles de validation : lire le storage mutable d'un autre contrat dans
validatePaymasterUserOpfait rejeter vos operations par les bundlers. Gardez la validation pure et locale.
- Replay entre chaines : liez toujours le hash signe a
block.chainidetaddress(this), comme montre plus haut.
- Oublier le cas du revert : vous payez le gas meme quand la user op revert, donc un paymaster ERC-20 doit quand meme facturer dans
postOpquandmode == opReverted.
- Slippage de prix : un paymaster token verrouillant un taux perime peut perdre de l'argent sur un gas volatil. Ajoutez une marge.
Mettez-le en pratique sur Solingo
Lire l'interface est une chose, brancher correctement validatePaymasterUserOp et postOp en est une autre. Vous pouvez construire et tester un paymaster verifiant etape par etape, lancer la simulation de validation, et le casser volontairement pour voir ce que les bundlers rejettent, dans les lecons interactives sur app.solingo-blockchain.xyz. Essayez ensuite d'implementer la variante ERC-20 : gardez la validation pure, et faites tous les calculs de token dans postOp.