Fix: ERC-4337 Bundler Error “AA21 didn’t pay prefund” in UserOperations
During the development and production deployment of Account Abstraction (ERC-4337) smart contract accounts (such as Safe, Biconomy Nexus, ZeroDev Kernel, or custom modular wallets), developers and backend node operators frequently hit an abrupt bundler rejection during RPC simulation:
RPC Error: -32500: EntryPoint simulation failed: AA21 didn't pay prefund
at EntryPoint.simulateValidation (EntryPoint_v0.7.sol:184)
at Bundler.estimateUserOperationGas (bundler-rpc.ts:312)
sender: 0x51731D6a2A944883445C5f49e0881977799b61d4
callGasLimit: 140000
verificationGasLimit: 120000
preVerificationGas: 54000
This error is notoriously perplexing for users and developers because it often occurs even when the smart account holds an adequate balance of native ETH (e.g., 0.05 ETH for a transaction that should only consume 0.001 ETH).
1. Mathematical Anatomy of requiredPrefund
To protect decentralized bundlers against denial-of-service (DoS) attacks and unpaid gas consumption, the EntryPoint requires the user to front-load the absolute maximum fee the transaction could conceivably consume:
$$\text{totalGas} = \text{verificationGasLimit} + \text{callGasLimit} + \text{preVerificationGas}$$
$$\text{requiredPrefund} = \text{totalGas} \times \text{maxFeePerGas}$$
When the bundler calls EntryPoint.handleOps():
- The EntryPoint inspects the account’s existing deposit on the EntryPoint contract via
getDepositInfo(sender). - If
deposit < requiredPrefund, the EntryPoint calculates the deficit: $$\text{missingAccountFunds} = \text{requiredPrefund} - \text{deposit}$$ - The EntryPoint invokes
validateUserOp(userOp, userOpHash, missingAccountFunds)on the sender account. - The account contract is expected to transfer exactly
missingAccountFundsin native currency back tomsg.sender(the EntryPoint) during this call. - If the account fails to refund the deficit, the EntryPoint executes
revert("AA21 didn't pay prefund").
2. Primary Root Causes
Root Cause 1: Missing or Broken missingAccountFunds Callback
Many custom smart account implementations forget to wire up the refund transfer in Solidity:
// VULNERABLE IMPLEMENTATION
function validateUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external override onlyEntryPoint returns (uint256 validationData) {
_validateSignature(userOp, userOpHash);
// FATAL BUG: missingAccountFunds is completely ignored!
// The contract holds 1 ETH, but never transfers the needed gas deposit to the EntryPoint.
return 0;
}
Root Cause 2: SDK Gas Multiplier Inflation
In high-throughput EVM environments (Arbitrum, Base, Polygon, or Ethereum Mainnet), front-end SDKs like Viem, Ethers, or Permissionless.js estimate fees using estimateFeesPerGas().
To prevent transaction drops, dApps frequently configure aggressive fee padding:
// DANGEROUS FEE PADDING
maxFeePerGas: baseFee * 250n / 100n, // 2.5x base fee
maxPriorityFeePerGas: parseGwei('3'),
When multiplied across a batch execution requiring 400,000 total gas, requiredPrefund skyrockets to 0.045 ETH. If the user account holds 0.038 ETH, the simulation fails with AA21 even though actual execution would have consumed only 0.006 ETH.
Root Cause 3: Paymaster Omission or Paymaster Rejection
If a dApp intends to sponsor transactions using a gas paymaster (e.g., Pimlico VerifyingPaymaster), but:
paymasterAndDatais left empty or malformed (0x),- The paymaster’s on-chain deposit inside the EntryPoint has run dry,
The EntryPoint falls back to charging the user’s account directly, triggering
AA21on accounts with 0 native ETH.
(For paymaster execution issues, see our companion guide on ERC-7579 Session Key Cross-Chain Signature Replay Exploits).
3. Step-by-Step Remediation Protocols
Solution 1: Fix Solidity validateUserOp Implementation
Ensure your account contract transfers the exact deficit back to msg.sender (the EntryPoint) using low-level call:
// HARDENED SOLDIER IMPLEMENTATION
function validateUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external override returns (uint256 validationData) {
require(msg.sender == address(entryPoint()), "Only EntryPoint allowed");
// 1. Verify signatures and nonce
validationData = _validateSignature(userOp, userOpHash);
// 2. Pay the required prefund deficit back to the EntryPoint
if (missingAccountFunds > 0) {
(bool success, ) = payable(msg.sender).call{
value: missingAccountFunds,
gas: type(uint256).max
}("");
require(success, "Failed to pay prefund to EntryPoint");
}
return validationData;
}
Solution 2: Pre-Fund the Account’s EntryPoint Deposit Directly
Instead of relying on JIT (just-in-time) transfers during every transaction, maintain an on-chain deposit in the EntryPoint contract for high-frequency bots:
# Deposit 0.05 ETH directly into EntryPoint for your smart account via Foundry cast
cast send 0x0000000071727De22E5E9d8BAf0edAc6f37da032 \
"depositTo(address)" 0xYourSmartAccountAddress \
--value 0.05ether \
--rpc-url $RPC_URL \
--private-key $PRIVATE_KEY
Once deposited, missingAccountFunds remains 0 for subsequent transactions, bypassing AA21 entirely.
Solution 3: Dynamic Fee Estimation & Gas Buffer Tuning
In your TypeScript client, normalize gas parameters and ensure you do not over-inflate maxFeePerGas:
import { createBundlerClient, toHex } from 'viem';
export async function prepareOptimizedUserOp(bundlerClient, smartAccount, callData) {
// 1. Fetch live gas price from RPC
const { maxFeePerGas, maxPriorityFeePerGas } = await bundlerClient.client.estimateFeesPerGas();
// 2. Query account balance
const accountBalance = await bundlerClient.client.getBalance({
address: smartAccount.address,
});
// 3. Estimate UserOp Gas
const gasEstimate = await bundlerClient.estimateUserOperationGas({
account: smartAccount,
calls: [callData],
});
const totalGas = gasEstimate.callGasLimit + gasEstimate.verificationGasLimit + gasEstimate.preVerificationGas;
const prefundRequired = totalGas * maxFeePerGas;
if (accountBalance < prefundRequired) {
throw new Error(
`Insufficient funds for prefund. Required: ${prefundRequired} wei, Available: ${accountBalance} wei. Consider attaching a Paymaster or lowering gas buffers.`
);
}
return {
...gasEstimate,
maxFeePerGas: (maxFeePerGas * 115n) / 100n, // Conservative 15% buffer rather than 200%
maxPriorityFeePerGas,
};
}
4. Production Diagnostic Matrix
| Symptom | Underlying Cause | Quick Fix |
|---|---|---|
| Account has 0 ETH, Paymaster used | Paymaster deposit in EntryPoint exhausted or paymaster signature expired. | Top up paymaster on-chain via entryPoint.depositTo(paymaster) or re-request sponsorship token. |
| Account has ETH, but AA21 throws instantly | validateUserOp lacks payable(msg.sender).call{value: missingAccountFunds}(""). | Update smart account implementation or pre-deposit funds via depositTo(account). |
| Occasional spikes during network congestion | maxFeePerGas multiplied by high gas limits exceeds account balance. | Implement dynamic prefund validation before dispatching to bundler RPC. |