Gas sponsorship without draining your balance
The first version of my paymaster could be drained with a shell loop.
I had wired up gasless onboarding for a prediction market — Google sign-in, a smart account deployed on first login, and a paymaster covering the gas so users never touched ETH. The demo worked. A new user placed a bet in under a minute, never saw a gas prompt. Then I looked at the paymaster's validation logic and realized it said yes to everything. Any address could submit any UserOperation and I would pay for it. A few hundred lines of script could have emptied the deposit before lunch.
Sponsoring gas is a single boolean in the spec: set paymasterAndData, and the cost moves from the user to you. That is the easy part, and most tutorials stop there. The hard part is the question that boolean creates — which operations do you pay for, and how do you keep a public, permissionless entry point from turning your funded deposit into someone else's free compute. This post is about that second part.
What "sponsoring" actually commits you to
If you have not seen the ERC-4337 flow before, the bundler / paymaster / EntryPoint walkthrough covers the lifecycle. The one-paragraph version: a user signs a UserOperation, a bundler batches it and calls EntryPoint.handleOps, and the EntryPoint runs each op in two phases — validate everything first, then execute everything. A paymaster is a contract that has pre-deposited ETH into the EntryPoint and implements two functions the EntryPoint calls: validatePaymasterUserOp during the validation phase, and postOp after execution.
Here is the part that bites. When your paymaster returns success from validatePaymasterUserOp, you have agreed to pay the gas for that operation whether or not it does anything useful. The EntryPoint debits your deposit to reimburse the bundler based on gas actually consumed. It does not care if the user's callData reverted, looped uselessly, or did exactly nothing. Execution gas is billed either way.
So sponsorship is not "I'll pay if this succeeds." It is "I'll pay for the computation, full stop." That reframing is the whole game. Every defense below exists to make sure the only computation you pay for is computation you actually wanted.
The four ways the deposit bleeds
Before defending anything, it helps to name the attack surface. There are four distinct ways a naive paymaster loses money, and they fail at different points in the lifecycle.
No policy. The paymaster sponsors any sender calling any function. This is the shell-loop case. There is no attacker sophistication required — the contract is a faucet. Most "hello world" paymaster examples ship in exactly this state because the validation function just returns a zero validationData and an empty context.
Griefing reverts. An attacker passes the allowlist (maybe they are a legitimate user, maybe the allowlist is too loose) and submits operations whose callData is engineered to consume gas and then revert. The execution does nothing of value to your product, but the bundler still spent gas simulating and including it, and your deposit still reimburses that gas. Repeat cheaply, and you are buying an attacker's wasted compute.
Unbounded cost. The paymaster never inspects maxCost. A single operation arrives with a callGasLimit and gas price high enough that one inclusion eats a large slice of the deposit. You sponsored one op and the daily budget is gone.
The postOp gap. This one is specific to ERC-20 paymasters, where the user is supposed to pay you back in a token. The charge happens in postOp, after execution. If the operation itself can drain the token allowance you checked during validation, then by the time postOp runs there is nothing to pull — and the gas is already spent. You fronted ETH and recovered zero.
Notice these are not all "abuse." Two of them (unbounded cost, the postOp gap) can be triggered by ordinary users by accident. A sustainable paymaster has to survive both malice and clumsiness.
The only place you can say no for free
The validation phase is your one free veto. This is the single most important fact about paymaster economics, and it took me longer to internalize than I would like to admit.
A revert inside validatePaymasterUserOp costs you nothing. The EntryPoint has not committed any of your deposit yet; the bundler drops the op (and, in practice, will throttle a sender whose ops keep failing validation, because the bundler eats simulation cost too). A revert after validation — during execution — still bills you for the gas. Once an op crosses from phase 1 into phase 2, you are paying.
So every spending decision has to be made inside that function, before you let the op through. The validation logic is where allowlists, rate limits, and cost caps live. If a check can only be done after execution, it cannot protect your balance — it can only record the damage.
Here is the shape of a validation function that actually gates. The signature is fixed by the spec; the body is yours.
function validatePaymasterUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 maxCost
) external override returns (bytes memory context, uint256 validationData) {
require(msg.sender == address(entryPoint), "only EntryPoint");
// 1. Who is asking?
require(allowedSender[userOp.sender], "sender not sponsored");
// 2. What are they trying to do? First 4 bytes of the inner call.
bytes4 selector = bytes4(userOp.callData[:4]);
require(allowedSelector[selector], "action not sponsored");
// 3. How much could this cost me, worst case?
require(maxCost <= perOpCostCap, "exceeds per-op cap");
// 4. Have they used up their quota this window?
require(_withinRateLimit(userOp.sender), "rate limit");
// 5. Do I have budget left today?
require(spentToday + maxCost <= dailyBudget, "daily budget exhausted");
// Pass the sender forward so postOp can finalize accounting.
return (abi.encode(userOp.sender, maxCost), _packValidationData(false, 0, 0));
}
Every require here is a free rejection. None of them cost you a wei if they fail. The thing to resist is the temptation to "just let it through and sort it out later" — there is no later that is free.
A subtlety worth flagging: maxCost is the EntryPoint's worst-case estimate, computed from the op's gas limits and fee parameters. You are gating on the ceiling, not the actual spend. That is correct — you want to reject based on the most it could cost, because at validation time the actual cost is unknowable.
maxCost is a reservation, not a charge
A reasonable worry when you start capping maxCost is that you are over-charging users or over-reserving your own funds. You are not, and understanding why makes the per-op cap painless to set aggressively.
When validation passes, the EntryPoint reserves maxCost against your deposit — it locks that much ETH so it knows you can cover the worst case. Execution runs. Then the EntryPoint computes actualGasCost from the gas actually burned, debits only that, and the difference goes back to your deposit. Over-estimating maxCost locks idle ETH for the duration of one transaction; it never loses you that ETH.
That asymmetry means you should set perOpCostCap to the most you are ever willing to pay for a single sponsored action, and not worry that a generous cap is itself a cost. The cap is a ceiling on damage, paid only if the op truly consumes that much. A typical sponsored bet or mint costs a fraction of the cap; the cap only ever fires against the pathological op.
The postOp trap, and why it is sneaky
For pure sponsorship — you eat the gas, the user pays nothing — postOp is almost an afterthought: the EntryPoint already debited your deposit, and postOp just lets you record usage. The danger lives in ERC-20 paymasters, where postOp is where you charge the user back in a token.
The naive flow looks airtight. During validation you check that the user has approved enough of the token to cover maxCost. The op executes. In postOp, now that you know actualGasCost, you pull the exact token amount from the user. The allowance was there; you checked.
The problem is the gap between the check and the pull. The op's own callData runs between them, and it can spend that allowance.
// ❌ Trap: validate the allowance, then assume it survives execution
function validatePaymasterUserOp(/* ... */) external override
returns (bytes memory context, uint256 validationData)
{
require(token.allowance(userOp.sender, address(this)) >= maxCost, "approve first");
return (abi.encode(userOp.sender), 0); // looks safe, isn't
}
// In postOp:
// token.transferFrom(sender, address(this), tokenAmount);
// <-- reverts if the op already moved or re-approved the token. Gas already spent.
The attack: approve the paymaster, then craft an op whose callData transfers the token away (or resets the approval to zero) during execution. Validation sees a healthy allowance, execution drains it, postOp finds nothing to pull. The transferFrom reverts — and a revert in postOp does not un-spend the gas the EntryPoint already paid the bundler.
There are a few defensible answers. The cleanest is to not depend on an allowance that the op can mutate: pull the token into the paymaster during the validation phase as a deposit, then refund any unused portion in postOp (the spec permits paymasters to make limited token transfers during validation if they are staked). Failing that, restrict the sponsored selector set so that no sponsored operation can touch the payment token's balance or approvals — if callData can only call placeBet, it cannot call transfer on your USDC. The point is the same as the lifecycle lesson: assume execution is hostile to the assumptions you made in validation.
Stacking the policies
Each check defends one vector. Put together, they form a stack an operation has to clear in full before it reaches your deposit — and a rejection at any layer is free.
The allowlist answers who and what. For AAPM I kept this tight: only smart accounts the app itself deployed, and only the handful of selectors the product actually uses — placing and settling positions, nothing else. An account the app created calling a function the app ships is a different risk profile than an arbitrary address calling arbitrary calldata. You can loosen this later; start closed.
The rate limit answers how often. Even a legitimate, allowlisted user should not be able to submit ten thousand sponsored ops a minute. A counter keyed by sender and a time window turns "drain the deposit" into "drain my personal quota, then wait." Storing this on-chain costs gas in validation, which is itself a small ongoing cost — for higher volume you can move the rate decision to a signed-policy model where an off-chain signer approves each op and the paymaster only verifies the signature, but that adds a service you have to keep online.
The per-op cost cap answers how expensive, and the daily budget answers how expensive in aggregate. The per-op cap stops one fat operation; the daily budget stops a thousand small ones from compounding past what you meant to spend in a day. The daily budget is the check that most directly maps to "I am willing to lose at most X per day if something goes wrong," which is the number a finance-minded person actually wants to bound.
None of these are in the ERC-4337 spec. The spec gives you the hook — a function that can say no before any money moves — and leaves the policy to you. That is the right division: the protocol guarantees the timing of your veto, and you decide the content.
The off-chain backstop
On-chain gates are necessary and not sufficient. They stop the abuse vectors you anticipated. They do nothing about the one you didn't — a buggy frontend that loops submissions, a price spike that makes your cost cap suddenly too generous, a popular launch that burns the deposit faster than you expected at three in the morning.
Two things close that gap, and both live off-chain. The first is monitoring the deposit balance and burn rate, with an alert that fires while there is still runway, not after the pool hits zero. EntryPoint.balanceOf(paymaster) is one call; graph it, alert on the first derivative, and you will see a drain forming before it finishes. The second is a kill switch — an owner-only pause() that makes validatePaymasterUserOp reject everything. When something is wrong and you do not yet understand it, the correct move is to stop sponsoring and investigate with the deposit intact. A paymaster you cannot pause is a paymaster you have to watch perfectly forever.
One operational note on the deposit itself: the EntryPoint distinguishes your deposit (the ETH that pays gas) from your stake (locked ETH that buys you looser simulation rules, like being allowed to touch storage across validation calls). Sponsoring needs deposit. Some validation patterns — accessing per-sender storage for rate limits, pulling tokens during validation — need stake too. Keep them mentally separate: topping up the deposit keeps sponsorship running, the stake is a one-time setup with an unbonding delay if you ever withdraw it.
What I would tell past me
The boolean that turns on sponsorship is the least interesting decision in the whole system. The interesting decisions are the policy ones, and they are economic before they are technical: how much am I willing to lose per operation, per user, per day, and what makes me stop entirely.
If you are building a paymaster, the checklist is short and the order matters:
- Gate in validation, never after. It is the only place a "no" is free. A check that runs in
postOprecords loss; it does not prevent it. - Allowlist senders and selectors. Start closed. An arbitrary address calling arbitrary calldata is the faucet case.
- Cap
maxCostper op and budget per day. The cap is a ceiling on damage paid only if the op truly costs that much; the budget is the most you are willing to lose in a day. - Treat execution as hostile to your validation assumptions. Anything the op can mutate — token allowances especially — cannot be trusted to survive into
postOp. - Monitor the burn rate and keep a kill switch. On-chain policy stops the abuse you predicted; the off-chain layer stops the bleed you didn't.
Gasless onboarding is one of the few genuinely good UX wins account abstraction unlocked — a user who has never held a token can act on-chain and not know they did. That win is worth defending properly. The deposit is real money, the entry point is public, and the only thing standing between the two is the function you wrote to say no.