A normal Ethereum wallet has one rule: whoever holds the key controls the account. Blunt, but predictable.
ERC-4337 swaps that rule for a function you write, validateUserOp. Whatever logic sits in there is now the account's whole security. The EntryPoint will call it, the bundler will forward whatever passes it, and if it's wrong, nothing downstream saves you. Something that used to be protected by cryptography is now protected by code, and code has bugs.
So when I review a smart account, that's where I start. Trail of Bits published their own list of six common mistakes in 4337 accounts in March, after auditing dozens of them, and a lot of it matches what we run into. These are the six I'd check first, with what they look like in code.
1. The signature doesn't cover the whole UserOperation
The classic one: a team ports an EOA-style signature check into validateUserOp and only signs part of the operation.
// Vulnerable: the signature only covers callData
function validateUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256 validationData) {
bytes32 hash = keccak256(abi.encode(userOp.callData));
address recovered = ECDSA.recover(hash, userOp.signature);
if (recovered != owner) return SIG_VALIDATION_FAILED;
return 0;
}
The EntryPoint checks and increments the nonce in the UserOperation, but this signature doesn't cover the nonce. So anyone who sees a valid operation can resubmit the same signature with the next nonce and run the same call again, on the same chain. The gas fields aren't covered either, so they can also set the gas price as high as they like and the account pays it from its deposit. And if the account sits at the same address on another chain (common with CREATE2), the operation works there too.
The fix is to verify the signature against userOpHash, which the EntryPoint already gives you. It covers every field of the operation except the signature, plus the EntryPoint address and the chain id.
Same idea for isValidSignature (ERC-1271): if two accounts share an owner key, a signature made for one can pass on the other unless the hash includes the account address and the chain.
2. A paymaster that pays for anything
Paymasters pay gas for other people. That sentence alone should start a threat-modeling session.
The common failure is a paymaster that decides to sponsor in validatePaymasterUserOp based only on who the sender is. "Is this user on our allowlist?" Yes, so it pays, whatever the callData does and however much gas it asks for. An allowlisted user (or anyone who controls an allowlisted account) can then burn through the paymaster's deposit in the EntryPoint.
What I'd check:
- Does validation look at the target, the function selector and the gas limits, not just the sender?
- If you charge the user in
postOp(an ERC-20 paymaster pulling tokens, for example), what happens whenpostOpfails? The user can make it fail, for example by revoking the token allowance inside the same operation. The execution reverts, and the paymaster has still paid the gas (since EntryPoint v0.7 there's no secondpostOpcall to clean up). Getting paid shouldn't depend only onpostOpsucceeding. - What does it cost an attacker to drain the deposit? If the answer is "less than the deposit", you need more limits.
3. The packed return value
validateUserOp doesn't return a simple true or false. It returns a uint256 with three things packed in it:
- The low 20 bytes:
0means the signature is valid,1means it failed, and anything else is read as the address of an aggregator. - The next 6 bytes:
validUntil, a timestamp, where0means it never expires. - The next 6 bytes:
validAfter.
The mistakes I see around this:
-
Reverting on a wrong signature. The spec says to return
1(SIG_VALIDATION_FAILED) on a signature mismatch and revert only on other errors. Reverting breaks gas estimation for your users. -
Packing by hand and swapping
validUntilandvalidAfter. The window you meant to allow is not the window the EntryPoint enforces. -
Forgetting that
validUntil = 0means forever. A session key whose expiry never got set is a key that never expires.
Use the _packValidationData helper from the reference implementation, and write a test that runs your return values through the real EntryPoint, not a mock.
4. Modules anyone can install
More and more accounts use modules (validation modules, execution modules, fallback handlers, ERC-7579 style). Composable, yes, but the account becomes only as safe as the weakest module installed on it.
The specific failure: module installation without access control.
// Vulnerable: no access control on module installation
function installModule(address module, bytes calldata initData) external {
IModule(module).onInstall(initData);
_modules[module] = true;
}
If anyone can call that, an attacker installs a validation module that says yes to every signature, and the account is theirs. Installing a module should go through the same validation as any other privileged action, and every module you allow deserves the same review as your own validateUserOp.
5. Validation that reads storage it shouldn't
Bundlers simulate your validation before including an operation, and they enforce rules (now in ERC-7562) on what validation can touch. Roughly: the account's own storage, and slots in other contracts that are tied to the account's address, like a token balance keyed by the account. The rules exist so an operation can't pass simulation and then fail on chain because someone changed shared state in between.
Read a shared price oracle or a third-party registry during validation and compliant bundlers will drop your operations. The annoying part is that it all passes locally, and teams find out in production when operations quietly never land. Run your operations through a real bundler on testnet before mainnet, and call eth_estimateUserOperationGas against it during testing.
6. Social recovery that can be griefed
Social recovery is one of the best things about smart accounts, and one of the most often broken. Usually it's not theft, it's a way to lock the owner out.
The pattern: any single guardian can start a recovery, the threshold is needed to finish it, and starting a new one resets the timer. A single bad guardian can keep restarting the clock and block a real recovery forever, for the price of gas.
What I'd check: can one guardian restart or block the process on their own? Is there a delay on ownership changes, so the real owner has time to cancel? What happens if a recovery starts while another one is still open?
What the bundler and EntryPoint don't do for you
A misconception I hear a lot: "the bundler and the EntryPoint are a security layer". They're not. They enforce the 4337 protocol rules, not your business logic. An operation that passes your validateUserOp will be executed even if it drains the account, installs a malicious module or hands ownership to someone else. Protocol conformance is their job. The rest is yours.
The agent problem
More and more AI agents use a 4337 account as their wallet: the agent builds UserOperations, the account validates and executes them. That changes the threat model. If the agent can be prompt-injected, the attacker doesn't need the key. They only need to change what the agent wants to do, and the agent will sign it correctly.
So "the signature is valid, so the action is authorized" stops being true. For agent-controlled accounts, the limits have to live in the account: spending caps per period, allowed targets and functions, rate limits, in validateUserOp or in a dedicated module. Not in the agent's system prompt. I think a lot of review time in the next year goes exactly here.
A short checklist
Validation
- Is the signature checked against
userOpHash, and nothing narrower? - Do you return
1on a bad signature instead of reverting? - Are
validUntilandvalidAfterpacked with the helper and tested against the real EntryPoint? - Does validation read only storage the bundler rules allow?
Paymaster
- Is sponsorship tied to targets, selectors and gas limits, not just the sender?
- Does the paymaster still come out fine if
postOpfails?
Modules
- Does installing a module need the owner's authorization?
- Can a module override core validation?
Recovery
- Can one guardian grief the process?
- Is there a delay on ownership changes that the owner can use to cancel?
If you've built on 4337 or ERC-7579 modules, which part was hardest to get right for you? I'm curious whether it's the paymaster or the validation rules that bite people most.
Adapted from a piece I wrote on zealynx.io.
I wrote this with help from AI for drafting and editing. The opinions and the audit experience are mine.
Top comments (0)