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");
}
}
When a bundler validates the incoming UserOperation:
- It reads
userOp.verificationGasLimit. - It assigns a fraction of this gas budget to
SenderCreator.createSender(). - 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 returnsfalse, surfacing asAA13.
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.
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:
- 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. - Empty
credentialIdbytes: Many factory implementations enforcerequire(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.