Tutoriel·8 min de lecture·Par Solingo

ERC-4337 Paymasters: Gasless Transactions Explained

Paymasters let someone other than the user pay for gas, or let users pay in an ERC-20 instead of native ETH. Here is how validatePaymasterUserOp and postOp actually work, plus a minimal verifying paymaster you can build.

# ERC-4337 Paymasters: Gasless Transactions Explained

One of the biggest friction points in onboarding new users to a dapp is gas. A user must hold native ETH before they can do anything, even mint a free NFT or claim an airdrop. ERC-4337 account abstraction solves this with a component called a paymaster: a smart contract that agrees to pay the gas for a transaction, either for free (sponsored) or in exchange for an ERC-20 token. This article recaps account abstraction, then walks through exactly what a paymaster does and how to build a minimal verifying one.

Account abstraction and UserOperation, briefly

ERC-4337 does not change the Ethereum protocol. Instead of sending a regular transaction from an externally owned account (EOA), a user signs a UserOperation: a struct describing what their smart contract account should do.

UserOperations are sent to an alternative mempool, picked up by actors called bundlers, and submitted in a batch to a single audited contract called the EntryPoint. The EntryPoint runs each UserOperation in two phases:

  • Validation: it asks the account whether the operation is valid (signature, nonce) and asks the paymaster whether it agrees to pay.
  • Execution: it calls the account to perform the actual action, then settles gas accounting.
  • The key fields of a UserOperation include the sender (the smart account), nonce, callData (what to execute), gas limits, and paymasterAndData. That last field is what activates a paymaster.

    What a paymaster actually does

    A paymaster is a contract that has staked and deposited ETH with the EntryPoint. When a UserOperation includes a non-empty paymasterAndData, the EntryPoint debits the gas cost from the paymaster's deposit instead of from the sender account.

    The paymaster interface has two functions the EntryPoint calls:

    • validatePaymasterUserOp: called during the validation phase. The paymaster decides whether to sponsor this operation and returns a context blob plus validation data.
    • postOp: called after execution. This is where the paymaster reconciles the real cost, for example pulling ERC-20 tokens from the user to cover the gas it just fronted.

    Here is the interface as defined by the standard (EntryPoint v0.7 shape, using 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, // user op succeeded

    opReverted, // user op reverted, still has to pay for gas

    postOpReverted // a previous postOp reverted; only used internally

    }

    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 in detail

    This function must be cheap and must not depend on mutable external state that another UserOperation could change, because bundlers simulate it off chain and reject operations that break the validation rules.

    It returns two things:

    • context: arbitrary bytes passed forward to postOp. Keep it small. Typical contents are the user address and the token price you locked in.
    • validationData: a packed value carrying an aggregator address (or the special value for "signature failed"), plus a validUntil and validAfter time window. A return of 0 means "valid forever, no aggregator".

    The maxCost argument is the maximum the operation could cost in wei. The paymaster uses it to decide if it is willing to front that amount.

    postOp in detail

    After the account's callData executes, the EntryPoint calls postOp with the actual gas cost. The mode tells you whether the user operation succeeded or reverted. Crucially, the paymaster still pays gas even if the user's call reverted, so charging logic belongs here, not in the execution itself.

    A subtle gotcha: in EntryPoint v0.7, if postOp itself reverts, the whole user operation is reverted and re-run, but the paymaster is still charged. So postOp must be robust.

    There are two common paymaster designs.

    Sponsored (verifying) paymaster. Gas is free for the user. An off chain service owned by the dapp signs an approval saying "I will pay for this specific UserOperation until this timestamp". The on chain paymaster only checks that signature. This is how you give new users a gasless first experience. The dapp eats the cost.

    ERC-20 (token) paymaster. The user has no ETH but holds, say, USDC. The paymaster fronts ETH gas to the EntryPoint, then in postOp it calls transferFrom to pull an equivalent USDC amount (plus a small markup) from the user. The user must have approved the paymaster beforehand. The exchange rate is the tricky part: you either trust an oracle or pin a price in the signed data.

    A quick comparison:

    • Who pays: sponsored = dapp, ERC-20 = user (in tokens).
    • User needs: sponsored = nothing, ERC-20 = token balance plus an approval.
    • Main risk: sponsored = abuse and griefing of your funds, ERC-20 = price slippage between validation and postOp.

    Building a minimal verifying paymaster

    Let us build the sponsored variant. The logic: the paymaster trusts a verifyingSigner (an off chain key). The dapp backend signs a hash of the UserOperation plus a time window. On chain, the paymaster recovers the signer and approves.

    // 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;

    // validationData success / failure markers (per 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");

    _;

    }

    // The off chain signer signs this hash. Bind it to chain + paymaster

    // so a signature cannot be replayed elsewhere.

    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, unused here

    uint256 // maxCost, unused in this simple variant

    ) external view onlyEntryPoint returns (bytes memory context, uint256 validationData) {

    // paymasterAndData layout (v0.7):

    // [0:20] paymaster address

    // [20:36] paymaster verification gas limit

    // [36:52] paymaster postOp gas limit

    // [52:] our custom data: 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;

    // Pack validationData: failure flag in low bit, then time window.

    validationData = _packValidationData(!ok, validUntil, validAfter);

    context = ""; // nothing to reconcile in postOp for free sponsorship

    }

    function postOp(

    PostOpMode,

    bytes calldata,

    uint256,

    uint256

    ) external onlyEntryPoint {

    // Sponsored gas: nothing to do. An ERC-20 paymaster would

    // transferFrom the user here based on 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));

    }

    }

    Notice that for sponsored gas, postOp is empty and context is empty. For an ERC-20 paymaster you would instead encode the user address and a token rate into context during validation, then call token.transferFrom(user, address(this), tokenAmount) inside postOp using actualGasCost.

    Funding the paymaster

    A paymaster does nothing without funds. Two actions are required against the EntryPoint:

  • deposit: ETH the EntryPoint draws from to pay bundlers. Call entryPoint.depositTo{value: x}(paymaster).
  • stake: a separate locked stake that lets your paymaster touch storage during validation without being throttled by bundler reputation rules. Call entryPoint.addStake{value: y}(unstakeDelaySec).
  • Common pitfalls

    • Validation rule violations: reading another contract's mutable storage in validatePaymasterUserOp gets your operations rejected by bundlers. Keep validation pure and local.
    • Replay across chains: always bind the signed hash to block.chainid and address(this), as shown above.
    • Forgetting the revert case: you pay gas even when the user op reverts, so an ERC-20 paymaster must still charge in postOp when mode == opReverted.
    • Price slippage: a token paymaster locking a stale rate can lose money on volatile gas. Add a margin.

    Practice it on Solingo

    Reading the interface is one thing, wiring validatePaymasterUserOp and postOp correctly is another. You can build and test a verifying paymaster step by step, run the validation simulation, and break it on purpose to see what bundlers reject, in the interactive lessons at app.solingo-blockchain.xyz. Try implementing the ERC-20 variant next: keep the validation pure, and do all the token math in postOp.

    Prêt à mettre en pratique ?

    Applique ces concepts avec des exercices interactifs sur Solingo.

    Commencer gratuitement