LayerZeroFault
passkey recovery

Fix: ERC-4337 AA13 initCode Failed or Out of Gas in Passkey Smart Account Deployment

VV

Written by

Fact-Checked on September 11, 2026

Verified Expert

Fix: ERC-4337 AA13 initCode Failed or Out of Gas in Passkey Smart Account Deployment

Deploying ERC-4337 Account Abstraction wallets powered by WebAuthn passkeys (such as Coinbase Smart Wallet, ZeroDev Kernel, Biconomy Nexus, or Safe4337) relies on counterfactual deployment.

The user receives an on-chain smart account address calculated via CREATE2 before deploying any code. The first time the user submits a UserOperation (for an onboarding swap, mint, or token transfer), the EntryPoint extracts the account creation bytecode from the initCode (or EntryPoint v0.7 factory + factoryCalldata) field and deploys the contract atomically.

However, developers and automated onboarding flows frequently encounter an abrupt rejection from bundlers (Alto, Stackup, Pimlico, Rundler):

{
  "jsonrpc": "2.0",
  "id": 42,
  "error": {
    "code": -32500,
    "message": "AA13 initCode failed or OOG",
    "data": "0x"
  }
}

If your UserOperation is also failing during the gas sponsorship phase, consult our in-depth diagnostic guide on Fixing ERC-4337 AA33 Reverted: Paymaster PostOp Execution Failure.


Architectural Mechanics: How EntryPoint Executes initCode

In EntryPoint v0.7 (0x0000000071727De22E5E9d8BAf0edAc6f37da032), the deployment logic is decoupled through a specialized helper contract named SenderCreator.

// EntryPoint v0.7: SenderCreator execution
contract SenderCreator {
    function createSender(bytes calldata initCode) external returns (address sender) {
        address factory = address(bytes20(initCode[0:20]));
        bytes memory initCallData = initCode[20:];
        bool success;
        /* assembly call executing factory.call(initCallData) */
        require(success, "AA13 initCode failed or OOG");
        require(sender != address(0), "AA14 initCode must return an address");
    }
}

Placeholder: EntryPoint v0.7 SenderCreator Execution and Verification Gas Allocation Flowchart

When a bundler validates the incoming UserOperation:

  1. It reads userOp.verificationGasLimit.
  2. It assigns a fraction of this gas budget to SenderCreator.createSender().
  3. If the factory call runs out of gas, or if any internal opcode inside the factory (e.g. CREATE2, ERC-1967 proxy initialization, or P-256 public key registration) reverts, the entire call returns false, surfacing as AA13.

Root Cause 1: verificationGasLimit Underestimation for P-256 Public Key Storage

Standard ECDSA wallets (like SimpleAccount) only write a single 20-byte Ethereum address into storage during initialization, requiring $\approx 65,000$ gas.

In contrast, a WebAuthn passkey smart account must initialize:

  • A 32-byte $q_x$ coordinate
  • A 32-byte $q_y$ coordinate
  • A variable-length WebAuthn credentialId (typically 32 to 128 bytes)
  • An optional WebAuthn P256Verifier precompile or RIP-7212 fallback address pointer

Writing 3 to 5 new non-zero 32-byte words into cold storage costs up to $5 \times 20,000 = 100,000$ gas in raw EVM storage writes (SSTORE), excluding proxy contract creation overhead!

Standard EOA Proxy Init:        ~65,000 gas
WebAuthn Passkey Proxy Init:    ~185,000 - 240,000 gas
Default Bundler Estimate Cap:   ~150,000 gas  <-- CAUSES OUT OF GAS (OOG)!

If the client application uses standard bundler gas estimation without accounting for the high initialization gas of the passkey factory, createSender exhausts its gas allocation midway through the SSTORE execution.

The Fix: Explicit Deployment Gas Buffer in Viem / Permissionless

When constructing the initial deployment UserOperation, inspect if initCode.length > 2 (indicating a first-time deployment). If true, append a mandatory $150,000$ gas buffer to verificationGasLimit:

// passkey-userop-gas-fix.ts
import { type UserOperation } from 'viem/account-abstraction';

export function patchPasskeyDeploymentGas(userOp: UserOperation<'0.7'>): UserOperation<'0.7'> {
  // Check if account is undergoing counterfactual deployment
  const isDeploying = userOp.factory && userOp.factory !== '0x' && userOp.factoryCalldata && userOp.factoryCalldata !== '0x';

  if (isDeploying) {
    // Standard verification needs ~100k for P-256 verification (RIP-7212 or Solady)
    // Factory deployment needs ~220k for CREATE2 + storage writes
    const MIN_DEPLOYMENT_VERIFICATION_GAS = 350_000n;

    const currentLimit = BigInt(userOp.verificationGasLimit);
    if (currentLimit < MIN_DEPLOYMENT_VERIFICATION_GAS) {
      console.warn(
        `[AA13 Prevention] Elevating verificationGasLimit from ${currentLimit} to ${MIN_DEPLOYMENT_VERIFICATION_GAS}`
      );
      return {
        ...userOp,
        verificationGasLimit: MIN_DEPLOYMENT_VERIFICATION_GAS,
      };
    }
  }

  return userOp;
}

Root Cause 2: Factory Salt Collision & Re-Initialization Revert

Another frequent cause of AA13 occurs when the user or backend attempts to deploy an account with an existing salt, or when the account contract already exists at the computed address:

[Bundler Check]
1. Computes CREATE2 address: 0x1234...abcd
2. Reads code at 0x1234...abcd: EXTCODESIZE > 0 (Already deployed!)
3. Factory executes CREATE2 with salt: Reverts because contract exists!

This occurs in multi-tab applications or when onboarding retries submit a new UserOperation with the same initCode after a previous transaction has already been mined.

Placeholder: Sequence Diagram of Counterfactual Factory Address Derivation vs State Check

The Fix: Pre-Flight Code Check Before Injecting initCode

Before sending any UserOperation to the bundler, query the account’s bytecode on-chain. If bytecode already exists, set factory and factoryCalldata (or initCode) to 0x:

// preflight-code-check.ts
import { type PublicClient, type Address } from 'viem';

export async function sanitizeUserOpInitCode(
  publicClient: PublicClient,
  accountAddress: Address,
  factory: Address,
  factoryCalldata: `0x${string}`
): Promise<{ factory: Address | undefined; factoryCalldata: `0x${string}` | undefined }> {
  // Query bytecode at counterfactual address
  const bytecode = await publicClient.getBytecode({ address: accountAddress });

  if (bytecode && bytecode !== '0x') {
    // Account is ALREADY deployed! Sending initCode will cause CREATE2 collision and AA13 revert.
    return {
      factory: undefined,
      factoryCalldata: undefined,
    };
  }

  // Account does not exist yet; safe to submit factory deployment
  return {
    factory,
    factoryCalldata,
  };
}

Root Cause 3: Malformed WebAuthn Coordinates in Factory Calldata

The factory initializer function typically accepts:

function createAccount(
    bytes calldata credentialId,
    uint256 qx,
    uint256 qy,
    uint256 salt
) external returns (address);

Common encoding pitfalls that cause the factory constructor to revert include:

  1. Unstripped 0x04 uncompressed point prefix: WebAuthn public keys are often encoded in ASN.1 or 65-byte uncompressed format (0x04 || X || Y). Passing the 65-byte buffer directly instead of splitting it into two 32-byte coordinates ($q_x, q_y$) causes ABI decoding reverts.
  2. Empty credentialId bytes: Many factory implementations enforce require(credentialId.length > 0, "InvalidCredential"). If the passkey registration callback failed or returned an empty array, the factory call fails.

WebAuthn Public Key Extraction Helper

Ensure your client-side WebAuthn parser strips the leading 0x04 byte and validates coordinate lengths:

// webauthn-coord-extractor.ts
export function parseWebAuthnCoordinates(uncompressedPublicKey: Uint8Array): {
  qx: bigint;
  qy: bigint;
} {
  // Uncompressed P-256 public key is exactly 65 bytes: 0x04 + 32 bytes X + 32 bytes Y
  if (uncompressedPublicKey.length === 65 && uncompressedPublicKey[0] === 0x04) {
    const xBytes = uncompressedPublicKey.slice(1, 33);
    const yBytes = uncompressedPublicKey.slice(33, 65);

    const qx = BigInt('0x' + Buffer.from(xBytes).toString('hex'));
    const qy = BigInt('0x' + Buffer.from(yBytes).toString('hex'));

    return { qx, qy };
  }

  throw new Error(`Invalid P-256 public key length: ${uncompressedPublicKey.length} (expected 65 bytes)`);
}

Step-by-Step CLI Debugging Guide

When faced with an AA13 initCode failed or OOG error, use Foundry cast to trace the factory call directly in your terminal:

# 1. Simulate the exact factory call via cast
cast call <FACTORY_ADDRESS> <FACTORY_CALLDATA> --rpc-url <RPC_URL>

# 2. If it reverts, view decoded error trace:
cast run <TX_HASH> --rpc-url <RPC_URL>

# 3. Check if target counterfactual address already has code:
cast code <COMPUTED_ACCOUNT_ADDRESS> --rpc-url <RPC_URL>

By ensuring that verificationGasLimit adequately covers cold storage writes, asserting account counterfactual states before injecting initCode, and strictly sanitizing WebAuthn coordinate parameters, passkey smart account deployments complete seamlessly without AA13 bundler rejections.

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 triggers the ERC-4337 AA13 initCode failed or OOG error?

In ERC-4337 (EntryPoint v0.6 and v0.7), AA13 is returned when the bundler or EntryPoint executes the initCode field to deploy a counterfactual smart account contract, but the factory call reverts or runs out of gas (OOG) before returning a non-zero address.

Why is AA13 especially common during passkey / WebAuthn account deployment?

Deploying a passkey smart wallet requires writing the user's secp256r1 (P-256) public key coordinates (qx, qy) and credentialId to smart account storage slots during initialization. This consumes 70,000 to 140,000 extra gas. If the bundler's verificationGasLimit only accounts for the ECDSA/P256 signature verification and ignores deployment storage writes, the factory reverts with out of gas.

How do you distinguish between an initCode revert vs an out-of-gas failure?

Run an eth_call with state overrides simulating the EntryPoint's senderCreator.createSender(initCode) call directly, or simulate via debug_traceCall. If return data is empty with 0 gas remaining, it is an out-of-gas error; if return data contains a 4-byte custom error selector, the factory constructor logic reverted.