# 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:
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
validUntilandvalidAftertime window. A return of0means "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.
Sponsored gas vs paying gas in an ERC-20
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:
entryPoint.depositTo{value: x}(paymaster).entryPoint.addStake{value: y}(unstakeDelaySec).Common pitfalls
- Validation rule violations: reading another contract's mutable storage in
validatePaymasterUserOpgets your operations rejected by bundlers. Keep validation pure and local.
- Replay across chains: always bind the signed hash to
block.chainidandaddress(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
postOpwhenmode == 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.