LayerZeroFault
wallet security-fixes Updated:

ERC-4337 AA21 Didn't Pay Prefund UserOperation Revert Fix

VV

Written by

Updated:

Technical Guide

To resolve ERC-4337 AA21 Didn, fix ERC-4337 EntryPoint AA21 didn. Ensure all derivation paths, environment parameters, and cryptographic signatures are verified client-side using deterministic tools before broadcasting live transactions.

The ERC-4337 bundler simulation error AA21 didn't pay prefund occurs during validateUserOp when an account’s EntryPoint deposit is smaller than the calculated maximum execution cost (totalGasLimit * maxFeePerGas). Even if the smart account holds sufficient ETH, the transaction reverts if validateUserOp omits the missingAccountFunds refund callback to msg.sender. Resolving the error requires returning the prefund value in Solidity or pre-funding the EntryPoint deposit.

To inspect packed UserOperation gas limits, paymaster fields, and call data in real time, run your transaction payload through our interactive ERC-4337 Calldata Decoder Tool. For modular smart account validation issues, see our research on ERC-7579 session key cross-chain replay protections.

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

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

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.

ERC-4337 EntryPoint Error Code Reference Matrix

Cross-reference standard ERC-4337 EntryPoint revert codes across account abstraction and smart wallet layers:

Error CodeStagePrimary Failure MechanismDiagnostic Guide
AA13Factory DeploymentInitCode execution reverted or exceeded factory gasERC-4337 AA13 InitCode Failed Fix
AA21Prefund ValidationSender account balance or deposit below required maxCostERC-4337 AA21 Prefund Payment Fix
AA22Timestamp ValidityClock drift or expired session key validity windowERC-4337 AA22 Clock Drift Fix
AA23User VerificationP-256 passkey signature or WebCrypto auth failedERC-4337 AA23 Signature Revert Fix
AA33Paymaster PostOpPost-execution token transfer or context revertedERC-4337 AA33 Paymaster PostOp Fix
PaymasterGas SponsorshipPaymaster rejected UserOp or sponsorship signature invalidERC-4337 Paymaster Revert Resolution

[!TIP] Unpack packed UserOperations, calculate prefund requirements, and isolate simulation hazards in our air-gapped /tools/erc4337-calldata-decoder/.

Interactive Diagnostic Sandbox

Local UserOp & Protocol Security Sandbox

Decode UserOperation calldata, simulate prefund accounting, and test bundler validation rules in your local browser.

Companion Diagnostic Guide ERC-7579 Session Key Cross-Chain Signature Replay Exploits →

Protect ERC-7579 modular smart accounts from cross-chain session key signature replay attacks across EVM rollups. Fix CREATE2 counterfactual account vulnerabilities.

Read Guide
Contextual Architecture

Related Diagnostic Guides

View All in Silo →

Related Inquiries

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

AA21 is an EntryPoint revert emitted during validateUserOp when the smart account fails to transfer or deposit sufficient native ETH to cover the maximum possible gas fee (requiredPrefund = maxFeePerGas * totalGasLimit). Bundlers drop the UserOperation during simulation to avoid paying gas.

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

Holding liquid ETH in the account address is insufficient. The smart account's validateUserOp() method must explicitly check the missingAccountFunds argument and transfer that exact amount back to msg.sender (the EntryPoint). If this callback is omitted or reverts, AA21 is thrown.

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

Client-side SDKs apply safety multipliers (1.2x to 2.0x) to maxFeePerGas. Because requiredPrefund multiplies this padded fee by all verification and call gas limits, the required prefund can be multiple times higher than actual execution costs, exceeding the account balance.

How can I inspect and decode UserOperation calldata to verify prefund values?

Use our interactive ERC-4337 UserOp Calldata Decoder tool to unpack EntryPoint v0.6 and v0.7 calldata, gas limits, and paymasterAndData fields offline.