The Problem Nobody's Talking About
Account Abstraction (AA) was supposed to fix crypto UX forever. No more seed phrases. Social recovery. Gas sponsorship. Batch transactions. The dream.
But here's what the dream looks like in practice: Trail of Bits audited dozens of ERC-4337 smart account implementations in early 2026 and found the same 6 critical vulnerability patterns recurring across nearly every single one.
Each flaw is equivalent to a private key compromise — full wallet drain, no recovery.
As of March 2026, over $2.3 billion sits in smart contract wallets. ERC-4337 adoption is accelerating with major wallets (Safe, ZeroDev, Biconomy, Alchemy) pushing AA as the default. The attack surface is massive and growing.
Let me walk you through each vulnerability with real code, working exploit logic, and the fixes.
1. Missing Access Control on execute()
Severity: Critical — anyone can drain the wallet
This is the most embarrassing one, and it's disturbingly common. The execute() function — which sends arbitrary transactions from the wallet — has no access control.
// ❌ VULNERABLE — no access control
contract SmartAccount {
function execute(
address target,
uint256 value,
bytes calldata data
) external {
(bool success, ) = target.call{value: value}(data);
require(success, "execution failed");
}
}
The exploit is trivial:
// Attacker calls execute() directly
vulnerableWallet.execute(
address(USDC), // target: USDC contract
0, // no ETH needed
abi.encodeWithSignature(
"transfer(address,uint256)",
attacker,
IERC20(USDC).balanceOf(address(vulnerableWallet))
)
);
// All USDC transferred to attacker. Done.
Why it happens: Developers assume execute() is only called via the EntryPoint's handleOps() flow. They forget that any address can call a contract's public functions directly.
The fix:
// ✅ SECURE — proper access control
contract SmartAccount {
address public immutable entryPoint;
address public owner;
modifier onlyAuthorized() {
require(
msg.sender == entryPoint ||
msg.sender == owner ||
msg.sender == address(this), // for self-calls in batch
"unauthorized"
);
_;
}
function execute(
address target,
uint256 value,
bytes calldata data
) external onlyAuthorized {
(bool success, ) = target.call{value: value}(data);
require(success, "execution failed");
}
}
Audit checklist:
- [ ] Can
execute()be called by addresses other than EntryPoint, owner, or self? - [ ] Are there batch/multi-call functions with the same missing check?
- [ ] Does the
fallback()orreceive()function expose execution paths?
2. Incomplete Signature Validation (Gas Fields)
Severity: Critical — signature can be replayed with different gas parameters
ERC-4337's UserOperation includes gas fields: callGasLimit, verificationGasLimit, preVerificationGas, maxFeePerGas, maxPriorityFeePerGas. Many implementations exclude these fields from the signature hash.
// ❌ VULNERABLE — gas fields not included in hash
function _validateSignature(
UserOperation calldata userOp,
bytes32 userOpHash
) internal view returns (uint256 validationData) {
// This hash is computed by EntryPoint and includes ALL fields
// But some implementations compute their OWN hash, skipping gas:
bytes32 customHash = keccak256(abi.encode(
userOp.sender,
userOp.nonce,
userOp.callData
// Missing: callGasLimit, verificationGasLimit,
// preVerificationGas, maxFeePerGas, maxPriorityFeePerGas
));
// Signature is only over partial data!
require(ECDSA.recover(customHash, userOp.signature) == owner);
return 0;
}
The attack:
- User signs a legitimate
UserOpwith specific gas parameters - Bundler (or MEV bot) intercepts the
UserOp - Attacker modifies gas fields — sets
maxFeePerGasto extreme values - Signature still validates because gas fields weren't signed
- User's wallet pays 100x the expected gas fee
- Attacker (running as block builder) captures the inflated gas payment
In extreme cases, the attacker can set callGasLimit so low that the inner call reverts but the wallet still pays for the full UserOp — burning the user's ETH with zero useful execution.
The fix:
// ✅ SECURE — use the EntryPoint-provided hash (covers ALL fields)
function _validateSignature(
UserOperation calldata userOp,
bytes32 userOpHash // This is already computed correctly by EntryPoint
) internal view returns (uint256 validationData) {
// ALWAYS use the EntryPoint-provided hash
bytes32 ethSignedHash = ECDSA.toEthSignedMessageHash(userOpHash);
require(ECDSA.recover(ethSignedHash, userOp.signature) == owner);
return 0;
}
Never compute your own hash. The EntryPoint's getUserOpHash() already includes every field plus the entryPoint address and chainId. Use it.
3. State Modification During Validation
Severity: Critical — batch semantics break, funds can be misdirected
The ERC-4337 spec says validateUserOp should be a view-like function — it checks if the operation is authorized. But nothing in the EVM actually prevents state modifications during validation.
// ❌ VULNERABLE — state modified during validation
function validateUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256 validationData) {
// This looks innocent...
lastValidatedNonce = userOp.nonce; // State write!
pendingTarget = extractTarget(userOp.callData); // State write!
if (missingAccountFunds > 0) {
// Prefund the EntryPoint
(bool success, ) = payable(msg.sender).call{
value: missingAccountFunds
}("");
}
return _checkSignature(userOp, userOpHash);
}
Why this is dangerous: When the EntryPoint processes a batch of UserOps, it runs ALL validations first, then ALL executions. If validation modifies state, the execution phase sees state that was modified by the last validated op, not the one being executed.
Exploit scenario:
- Attacker submits two
UserOpsin one bundle - Op A validates: sets
pendingTarget = legitimate_address - Op B validates: overwrites
pendingTarget = attacker_address - Op A executes: reads
pendingTarget→ sends funds to attacker instead of legitimate address
The fix:
// ✅ SECURE — no state writes during validation
function validateUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256 validationData) {
// ONLY do signature verification and prefunding
// NO state modifications
if (missingAccountFunds > 0) {
(bool success, ) = payable(msg.sender).call{
value: missingAccountFunds
}("");
// This is the ONLY acceptable state change (required by spec)
}
return _checkSignature(userOp, userOpHash);
}
Audit checklist:
- [ ] Does
validateUserOpwrite to any storage slots beyond EntryPoint prefunding? - [ ] Are there any
SSTOREopcodes reachable from the validation path? - [ ] Could a bundler batch two UserOps that interact via shared state?
4. ERC-1271 Signature Replay
Severity: High — signed messages can be replayed across chains or contexts
Smart accounts implement ERC-1271 for isValidSignature() — this lets other contracts verify that the smart wallet "signed" a message. The problem: many implementations don't include the wallet address or chain ID in the signature verification.
// ❌ VULNERABLE — no domain separation
function isValidSignature(
bytes32 hash,
bytes calldata signature
) external view returns (bytes4) {
// Owner signed this hash... but for WHICH wallet? WHICH chain?
if (ECDSA.recover(hash, signature) == owner) {
return IERC1271.isValidSignature.selector;
}
return 0xffffffff;
}
The attack:
- User owns SmartWallet-A on Ethereum and SmartWallet-B on Arbitrum (same owner key)
- User signs a permit for SmartWallet-A to approve USDC spending
- Attacker replays the same signature against SmartWallet-B on Arbitrum
- SmartWallet-B's
isValidSignaturereturns valid (same owner, same hash) - Attacker drains USDC from SmartWallet-B
This also works across different wallets on the same chain if they share an owner.
The fix:
// ✅ SECURE — proper domain separation
function isValidSignature(
bytes32 hash,
bytes calldata signature
) external view returns (bytes4) {
// Include wallet address and chain ID in the verification
bytes32 domainSeparator = keccak256(abi.encode(
keccak256("EIP712Domain(address verifyingContract,uint256 chainId)"),
address(this),
block.chainid
));
bytes32 typedHash = keccak256(abi.encodePacked(
"\x19\x01",
domainSeparator,
hash
));
if (ECDSA.recover(typedHash, signature) == owner) {
return IERC1271.isValidSignature.selector;
}
return 0xffffffff;
}
Key insight: EIP-712 domain separation exists for exactly this reason. Use it. Always include verifyingContract (the wallet address) and chainId.
5. Insufficient Revert Protection
Severity: Medium-High — silent failures lead to fund loss
ERC-4337 has a nuanced execution model. When the EntryPoint calls execute(), a revert in the inner call doesn't necessarily revert the entire UserOp. The wallet still pays gas. This creates a dangerous pattern:
// ❌ VULNERABLE — inner call failure is silently swallowed
function execute(
address target,
uint256 value,
bytes calldata data
) external onlyEntryPoint {
(bool success, bytes memory result) = target.call{value: value}(data);
// No revert on failure! Gas is consumed, nothing happened.
// User thinks the tx went through, but it silently failed.
}
The attack scenario:
- User submits a
UserOpto swap 10 ETH for USDC on Uniswap - Bundler manipulates mempool timing — the swap will fail (price moved, slippage exceeded)
-
execute()calls Uniswap → call fails → no revert - User pays full gas cost (~$5-50 depending on network)
- 10 ETH is still in the wallet but user's gas is burned
- Bundler repeats this, draining gas from the wallet over time
More dangerously, for multi-step operations:
// Multi-call where step 2 silently fails
// Step 1: approve USDC spending → succeeds
// Step 2: deposit USDC into vault → fails silently
// Result: USDC is approved but never deposited — attacker can transferFrom
The fix:
// ✅ SECURE — revert on inner call failure
function execute(
address target,
uint256 value,
bytes calldata data
) external onlyEntryPoint {
(bool success, bytes memory result) = target.call{value: value}(data);
if (!success) {
// Bubble up the revert reason
assembly {
revert(add(result, 32), mload(result))
}
}
}
// For batch operations — atomic by default
function executeBatch(
address[] calldata targets,
uint256[] calldata values,
bytes[] calldata datas
) external onlyEntryPoint {
require(targets.length == values.length && values.length == datas.length);
for (uint256 i = 0; i < targets.length; i++) {
(bool success, bytes memory result) = targets[i].call{
value: values[i]
}(datas[i]);
if (!success) {
assembly {
revert(add(result, 32), mload(result))
}
}
}
}
6. ERC-7702 Compatibility Gaps
Severity: Medium — affects wallets upgrading to ERC-7702 delegation
ERC-7702 (Pectra upgrade, live on Ethereum since early 2026) allows EOAs to delegate to smart contract code. This creates a new class of issues for existing ERC-4337 accounts:
// ❌ VULNERABLE — doesn't handle ERC-7702 delegation correctly
function validateUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256 validationData) {
// Assumes msg.sender is always the EntryPoint
// But with ERC-7702, the "sender" field in UserOp could be
// an EOA that has delegated to this contract's code
// This check fails for ERC-7702 delegated accounts:
require(msg.sender == ENTRY_POINT, "not entry point");
// The nonce management also breaks — EOA nonces and
// EntryPoint nonces are separate systems
}
The issue: ERC-7702 lets an EOA say "execute my transactions using this smart account's code." But the smart account code was written assuming it's deployed as a standalone contract with its own storage. When an EOA delegates to it:
- Storage slots may overlap with existing EOA state
- Nonce tracking between ERC-4337 and ERC-7702 can desync
-
address(this)returns the EOA address, not the implementation -
EXTCODESIZEchecks break (EOA has zero code, delegation is transparent)
The fix:
// ✅ SECURE — ERC-7702 aware
contract SmartAccount {
// Use EIP-7201 namespaced storage to avoid slot collisions
bytes32 constant ACCOUNT_STORAGE_SLOT =
keccak256("smartaccount.storage.v1") - 1;
struct AccountStorage {
address owner;
uint256 nonce;
mapping(bytes32 => bool) usedSignatures;
}
function _getStorage() internal pure returns (AccountStorage storage s) {
bytes32 slot = ACCOUNT_STORAGE_SLOT;
assembly {
s.slot := slot
}
}
function validateUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256 validationData) {
// Works for both standalone deployment AND ERC-7702 delegation
// because storage is namespaced
AccountStorage storage store = _getStorage();
// Verify signature against stored owner
bytes32 ethSignedHash = ECDSA.toEthSignedMessageHash(userOpHash);
require(ECDSA.recover(ethSignedHash, userOp.signature) == store.owner);
return 0;
}
}
The Audit Checklist
If you're building or auditing an ERC-4337 smart account, run through this:
| # | Check | Severity |
|---|---|---|
| 1 | Can execute() be called by anyone? |
Critical |
| 2 | Are ALL UserOp fields included in signature hash? | Critical |
| 3 | Does validateUserOp modify state beyond prefunding? |
Critical |
| 4 | Is isValidSignature replay-safe across chains/wallets? |
High |
| 5 | Do inner call failures revert the UserOp? | Medium-High |
| 6 | Is storage namespaced for ERC-7702 compatibility? | Medium |
One "yes" to any Critical = full wallet drain potential.
Why This Matters Now
Account Abstraction isn't optional anymore. It's the direction the entire Ethereum ecosystem is heading. Pectra (ERC-7702) is live. Major wallets are AA-first. Layer 2s are built around smart accounts.
The $2.3B currently in smart wallets is going to be $20B+ by end of 2026. Every dollar is protected (or not) by the quality of these implementations.
Trail of Bits found these 6 patterns across dozens of implementations. Not one or two edge cases — dozens. The same mistakes, repeated by different teams, because the ERC-4337 spec is complex and the security surface is non-obvious.
If you're a security researcher, these patterns are your new audit checklist. If you're building a smart account, this is your "must not ship without" list.
The tooling gap is real. Standard Solidity analysis tools (Slither, Mythril) don't have ERC-4337-specific detectors yet. Custom Semgrep rules or manual review are your best options today. That gap is also an opportunity — building AA-specific security tooling is a wide-open space.
References
- Trail of Bits: Six Mistakes in ERC-4337 Smart Accounts
- ERC-4337 Specification
- ERC-7702 Specification
- EIP-7201: Namespaced Storage Layout
- OWASP Smart Contract Top 10 2026
This article is part of the DeFi Security Research series. Follow @ohmygod for weekly deep-dives into smart contract vulnerabilities, audit techniques, and security tooling.
Top comments (0)