ERC-4337 Paymaster Validation Pitfalls: How AA Bundlers Get Drained
Paymasters are the most attackable surface in account abstraction — a single missing check in validatePaymasterUserOp can let an attacker drain your entire deposit while paying zero gas. ERC-4337's validation rules are unforgiving, the storage access restrictions are stricter than most devs…
The Bug
In ERC-4337, a **paymaster** is a contract that agrees to pay gas on behalf of a smart account. The EntryPoint calls `validatePaymasterUserOp` before execution; if it returns success, the paymaster's deposit gets debited for gas — no matter what happens in the user's call.
That's the entire attack surface in one sentence: **whatever your paymaster approves, it pays for.** And unlike a normal contract where a revert undoes state, in AA a failed `postOp` doesn't refund the gas. The paymaster eats it.
Most paymaster exploits fall into five buckets:
1. Missing or weak signature verification on the `paymasterAndData` blob
2. Replay across chains, accounts, or nonces
3. Trusting `UserOperation.callData` without binding it to the signature
4. Violating ERC-4337's storage access rules (causing bundler bans, then forced on-chain submission griefing)
5. Unbounded sponsorship — no per-user, per-call, or time-window caps
A Vulnerable Paymaster
Here's a sponsored-transaction paymaster that looks reasonable and is completely broken:
```solidity
contract BadPaymaster is BasePaymaster {
address public signer;
mapping(address => bool) public sponsored;
function validatePaymasterUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 maxCost
) internal override returns (bytes memory context, uint256 validationData) {
// Pull signature off paymasterAndData
bytes calldata sig = userOp.paymasterAndData[20:];
// Verify signer approved this user
bytes32 h = keccak256(abi.encode(userOp.sender));
require(ECDSA.recover(h, sig) == signer, "bad sig");
return ("", 0);
}
}
```
Count the bugs:
**The signed hash only commits to `userOp.sender`.** An attacker grabs any historical signature, swaps `callData` to call `token.transfer(attacker, balance)` on their own smart account, and the paymaster pays for the drain.
- **No chain ID, no expiry, no nonce.** A sig from Optimism replays on Base, Arbitrum, and mainnet.
- **No `maxCost` cap.** The attacker sets `callGasLimit` to 10M and burns your deposit on garbage computation.
- **`validationData` returns 0** — meaning "valid forever." There's a packed format here `(aggregator, validUntil, validAfter)` that exists precisely so you can scope sigs to a time window. Use it.
There's also a subtle one: if you added `sponsored[userOp.sender] = true` inside this function, the bundler would reject your paymaster because writing to non-sender-associated storage during validation violates [ERC-7562](https://eips.ethereum.org/EIPS/eip-7562) (the validation rules spec). Your paymaster would silently stop working in production.
The Fix
```solidity
contract GoodPaymaster is BasePaymaster {
address public signer;
mapping(bytes32 => bool) public usedHashes;
function validatePaymasterUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 maxCost
) internal override returns (bytes memory context, uint256 validationData) {
// Layout: [paymaster(20) | validUntil(6) | validAfter(6) | sig(65)]
require(userOp.paymasterAndData.length == 20 + 6 + 6 + 65, "len");
uint48 validUntil = uint48(bytes6(userOp.paymasterAndData[20:26]));
uint48 validAfter = uint48(bytes6(userOp.paymasterAndData[26:32]));
bytes calldata sig = userOp.paymasterAndData[32:97];
require(maxCost <= MAX_SPONSORED_COST, "cost cap");
// Bind signature to: full userOpHash (includes callData, gas, sender,
// nonce, chainId via EntryPoint), validity window, and maxCost.
bytes32 h = keccak256(abi.encode(
userOpHash,
block.chainid,
address(this),
validUntil,
validAfter,
maxCost
)).toEthSignedMessageHash();
if (ECDSA.recover(h, sig) != signer) {
// Return SIG_VALIDATION_FAILED instead of revert — lets EntryPoint
// handle it cleanly without griefing the bundler.
return ("", _packValidationData(true, validUntil, validAfter));
}
return (
abi.encode(userOp.sender, maxCost),
_packValidationData(false, validUntil, validAfter)
);
}
function _postOp(PostOpMode, bytes calldata context, uint256 actualGasCost)
internal override
{
(address user, uint256 cap) = abi.decode(context, (address, uint256));
require(actualGasCost <= cap, "over cap");
emit Sponsored(user, actualGasCost);
}
}
```
Key changes:
Signature commits to `userOpHash` (which already encodes `callData`, `nonce`, gas params, sender, and via EntryPoint domain separation, chain + paymaster address)
- Explicit `validUntil` / `validAfter` window — sigs expire
- `maxCost` ceiling enforced both at validation and in `postOp`
- Failed sig returns `SIG_VALIDATION_FAILED` rather than reverting, per the [EIP-4337 spec](https://eips.ethereum.org/EIPS/eip-4337)
- No writes to non-sender storage — bundler stays happy
Real-World Pressure
Account abstraction is the security frontier of 2024–2025. Stackup, Pimlico, Alchemy, Biconomy, and Candide have all shipped paymaster infra, and several have published post-mortems on bundler DoS vectors and signature replays caught in audit. Public exploit dollar figures are still small because AA TVL is still small — but every major paymaster operator is funding deposits in the **seven to eight figure** range, and the same class of bug that drained $197M from Euler (logic in a privileged validation path) maps cleanly onto a misconfigured paymaster.
The Safe team's `4337 module` audits, Coinbase Smart Wallet, and the recent push from EIP-7702 (which lets EOAs temporarily become smart accounts) all sit on this same validation contract. A bug in your paymaster doesn't just drain you — it can grief the bundler, get your contract throttled across the mempool, and brick UX for every user who depended on sponsored gas.
The ERC-7562 Storage Rules — Don't Skip This
Bundlers simulate your `validatePaymasterUserOp` off-chain and reject the UserOp if it touches storage outside a strict allowlist:
✅ Storage of the paymaster itself, keyed by `userOp.sender`
- ✅ Reading from sender-associated slots
- ❌ Writing to global counters, rate-limiters, or shared mappings
- ❌ Reading another contract's storage that isn't sender-associated
- ❌ Calling `GAS`, `NUMBER`, `TIMESTAMP`, `BLOCKHASH`, etc. (banned opcodes)
The sneaky failure mode: your paymaster works in unit tests, works on testnet, even works when you submit directly to the EntryPoint — but bundlers refuse to include it, so production users see "transaction failed" with no on-chain trace.
If you're rate-limiting, do it via signer-side off-chain logic (a server signs only N userOps per user per day), not via on-chain counters in `validatePaymasterUserOp`.
Checklist Before Mainnet
[ ] Signature commits to full `userOpHash`, not a subset of fields
- [ ] `validUntil` / `validAfter` enforced via `_packValidationData`
- [ ] `maxCost` capped, and re-checked in `postOp` against `actualGasCost`
- [ ] Failed sig returns `SIG_VALIDATION_FAILED`, doesn't revert
- [ ] No writes to non-sender-associated storage during validation
- [ ] No banned opcodes (`TIMESTAMP`, `BLOCKHASH`, `GAS`, etc.) in the validation path
- [ ] Deposit monitored; circuit breaker to pause via `EntryPoint.withdrawTo` if abuse detected
- [ ] Off-chain signer key in HSM or threshold setup — it controls your entire deposit
Run your paymaster through the [free AI audit](https://www.cryptohawking.com/audit) for a first pass on signature and storage-rule issues. For anything sponsoring real volume, get a [manual audit](https://www.cryptohawking.com/audit/manual) — paymaster bugs are the kind that don't trigger until someone with a fuzzer starts hunting your deposit.
Account abstraction is going to be how the next 100M users touch Ethereum. Make sure your paymaster isn't the funnel that pays for their abuse.
FAQ
Why does my paymaster work in tests but fail when submitted through a bundler?
Almost always an ERC-7562 storage rule violation. Bundlers run an off-chain simulation with strict rules — your validation function can only touch storage associated with `userOp.sender`, can't call banned opcodes like `TIMESTAMP` or `BLOCKHASH`, and can't make external calls to arbitrary contracts. If you're writing to a global mapping, reading from a price oracle, or checking `block.timestamp` during validation, the bundler will reject silently. Move time checks into the packed `validationData` return value, and move rate-limiting off-chain into your signer service.
What's the difference between reverting and returning SIG_VALIDATION_FAILED?
Reverting from `validatePaymasterUserOp` causes the entire UserOp to fail at the EntryPoint level, which bundlers treat as a paymaster-side fault and may throttle or ban your paymaster. Returning `SIG_VALIDATION_FAILED` (the first bit of `validationData` set to 1) signals a normal signature failure — EntryPoint rejects the op cleanly, no reputation hit. Reserve reverts for genuinely exceptional cases (malformed data length, paused contract). Use `SIG_VALIDATION_FAILED` for anything signature- or authorization-related.
Does userOpHash already include chain ID and paymaster address?
Yes — `EntryPoint.getUserOpHash` computes `keccak256(abi.encode(userOp.hash(), entryPoint, chainid))`. So if your paymaster signature commits to `userOpHash`, you get chain and entrypoint domain separation for free. However, `userOp.hash()` only includes `paymasterAndData[:20]` (the paymaster address), not the full blob — so the signature inside `paymasterAndData` is correctly excluded from the hash it's signing. Still, I recommend re-including `block.chainid` and `address(this)` explicitly in your signed message for defense in depth and future-proofing against EntryPoint upgrades.
How do I rate-limit sponsored transactions per user without violating storage rules?
Don't do it on-chain in the validation path. The bundler will reject any write to non-sender-associated storage. Instead, rate-limit at the signer service: your backend tracks per-user quotas and simply refuses to issue a paymaster signature once a user exceeds their limit. The on-chain paymaster just verifies the signature and time window. If you absolutely need on-chain enforcement, put it in `_postOp` (which has no storage restrictions) and track usage there — but at that point you've already paid for the gas, so it's only useful for detection, not prevention.
What's the worst-case loss if my paymaster signature is forgeable?
Your entire EntryPoint deposit, drained as fast as the attacker can submit UserOps. They don't need a victim account — they deploy their own smart account, point `callData` at any contract (or even at a contract that just burns gas in a loop), and your paymaster pays. With a 10M gas limit per op and a bundler willing to batch, an attacker can drain hundreds of ETH of deposit in a single block. Always set a `MAX_SPONSORED_COST` ceiling and monitor deposit balance with an automated `withdrawTo` circuit breaker.