LayerZeroFault
wallet security-fixes

Fix: ERC-4337 Bundler Error 'AA21 didn't pay prefund' in UserOperations

VV

Written by

Fact-Checked on September 11, 2026

Verified Expert

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).

Placeholder: ERC-4337 EntryPoint Prefund Calculation and Settlement Flow


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():

  1. The EntryPoint inspects the account’s existing deposit on the EntryPoint contract via getDepositInfo(sender).
  2. If deposit < requiredPrefund, the EntryPoint calculates the deficit: $$\text{missingAccountFunds} = \text{requiredPrefund} - \text{deposit}$$
  3. The EntryPoint invokes validateUserOp(userOp, userOpHash, missingAccountFunds) on the sender account.
  4. The account contract is expected to transfer exactly missingAccountFunds in native currency back to msg.sender (the EntryPoint) during this call.
  5. 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:

  • paymasterAndData is 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 AA21 on accounts with 0 native ETH.

(For paymaster execution issues, see our companion guide on ERC-7579 Session Key Cross-Chain Signature Replay Exploits).

Placeholder: Diagram of EntryPoint MissingAccountFunds Transfer Mechanism


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,
  };
}

Placeholder: Viem and Permissionless Prefund Calculation Code Flowchart


4. Production Diagnostic Matrix

SymptomUnderlying CauseQuick Fix
Account has 0 ETH, Paymaster usedPaymaster 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 instantlyvalidateUserOp lacks payable(msg.sender).call{value: missingAccountFunds}("").Update smart account implementation or pre-deposit funds via depositTo(account).
Occasional spikes during network congestionmaxFeePerGas multiplied by high gas limits exceeds account balance.Implement dynamic prefund validation before dispatching to bundler RPC.
Partner Spotlight: Gate.io

Trade Securely on Gate.io

Don't risk your assets on centralized silos or unverified endpoints. Trade securely on Gate.io with deep liquidity and institutional-grade security protocols.

Claim $100 Sign-up Bonus

Official Partner Referral Link

Related Inquiries

What does the ERC-4337 'AA21 didn't pay prefund' error mean?

AA21 is a core EntryPoint specification revert (defined in both EntryPoint v0.6 and v0.7). It indicates that during the validation phase (validateUserOp), the smart contract account failed to deposit or transfer sufficient native ETH to cover the maximum possible execution fee (requiredPrefund = maxFeePerGas * totalGasLimit). Bundler nodes (such as Pimlico, Alto, or Rundler) immediately drop the UserOperation during simulation to prevent paying gas out-of-pocket.

Why do I get AA21 when my smart account clearly has enough ETH?

Holding native ETH in the smart account's address is not enough. Under the ERC-4337 specification, the smart account's validateUserOp() contract method MUST explicitly check the missingAccountFunds argument passed by the EntryPoint and execute a native value transfer (e.g., (bool success, ) = payable(msg.sender).call{value: missingAccountFunds}('')). If this callback transfer is omitted, reverts, or is blocked by a reentrancy guard, the EntryPoint aborts with AA21.

How does maxFeePerGas padding trigger false AA21 errors in Viem and Permissionless.js?

Client-side SDKs frequently apply a 1.2x to 2.0x safety multiplier to maxFeePerGas to ensure transaction inclusion during mempool spikes. Because requiredPrefund multiplies this inflated fee against the full callGasLimit and verificationGasLimit, the calculated prefund can be 200-300% higher than the actual gas cost, easily exceeding the account's liquid balance.