In Part 1, a smart account was deployed and the first UserOperation successfully executed through the EntryPoint. At that point, everything worked — but a critical part of the system stayed mostly invisible: the bundler.

Bundlers are the bridge between account abstraction and the Ethereum execution layer. They take UserOperations from a separate mempool, pay gas costs upfront, and get reimbursed through the protocol. Understanding how they work—the validation rules, reputation system, and economic incentives—is essential for debugging issues and building reliable applications.

The UserOperation Lifecycle

A UserOperation is the unit of work bundlers operate on. It encapsulates everything required to execute an action on behalf of a smart account — authorization, gas constraints, execution calldata, and optional paymaster logic.

In EntryPoint v0.8, UserOperations are handled on-chain in a packed, gas-optimized format. When working with SDKs like permissionless.js, they are represented as an explicit, unpacked structure:

type UserOperation = {
    sender: Address
    nonce: bigint
    factory?: Address              // Account factory (for first-time deployment)
    factoryData?: Hex              // Factory calldata
    callData: Hex
    callGasLimit: bigint
    verificationGasLimit: bigint
    preVerificationGas: bigint
    maxFeePerGas: bigint
    maxPriorityFeePerGas: bigint
    paymaster?: Address
    paymasterVerificationGasLimit?: bigint
    paymasterPostOpGasLimit?: bigint
    paymasterData?: Hex
    signature: Hex
}

On-chain, the EntryPoint uses a packed format for gas efficiency (combining fields like accountGasLimits = verificationGasLimit | callGasLimit). The SDK handles this packing automatically—you don't need to worry about it.

The lifecycle of a UserOperation looks like this:

  1. Creation: User constructs the UserOp with their smart account SDK (like permissionless.js)
  2. Signing: User signs the UserOp hash, proving they authorized the action
  3. Submission: UserOp is sent to a bundler via eth_sendUserOperation
  4. Validation: Bundler simulates the UserOp to check if it will succeed
  5. Mempool: If valid, the UserOp enters the bundler's mempool
  6. Bundling: Bundler packages multiple UserOps into a single handleOps call
  7. Execution: EntryPoint contract validates each UserOp on-chain, then executes them
  8. Payment: EntryPoint collects gas costs from each account (or their paymaster).

The key insight is that validation happens twice: once off-chain by the bundler (to decide whether to accept the UserOp), and once on-chain by the EntryPoint (before actually executing). If these two validations produce different results, the bundler loses money—paying for gas on a transaction that ultimately fails.

This asymmetry is why bundlers are intentionally strict. Every validation rule exists to eliminate a class of attack where a UserOp passes simulation but fails on-chain.

Validation: Why Bundlers Are Paranoid

Bundlers pay gas costs upfront. If a UserOperation fails on-chain after being included in a bundle, the loss is theirs. No refunds, no retries.

That single fact defines the entire bundler threat model.

Between simulation and inclusion, Ethereum state is not static. Block parameters shift, balances change, and adversarial transactions can land in between. A carefully crafted UserOperation can pass off-chain simulation and still fail during on-chain validation — turning the bundler into a gas sink.

ERC-4337 responds by sharply constraining what validation code is allowed to do.

The EntryPoint enforces a strict separation of concerns:

Validation Phase: The account's validateUserOp function runs to verify the signature and authorize the operation. This phase has strict restrictions on what opcodes and storage the code can access.

Execution Phase: The account's execute function runs the actual operation. No restrictions here—full EVM capabilities.

Banned Opcodes

Certain opcodes are banned during validation because their values can change between simulation and execution:

Here's what happens when your account uses a banned opcode:

// This validateUserOp will be rejected by bundlers
function validateUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash, uint256 missingAccountFunds)
    external returns (uint256 validationData)
{
    // BANNED: block.timestamp can change
    require(block.timestamp < deadline, "Expired");

    // ... rest of validation
}

The bundler runs a trace during simulation (using debug_traceCall with an ERC-7562 compliant tracer) and rejects any UserOp whose validation touches banned opcodes. Modern bundlers may use either a JavaScript tracer or the native Go implementation introduced with EntryPoint v0.8. The RPC response will indicate which opcode caused the rejection.

Storage Access Rules

Beyond opcodes, bundlers restrict which storage slots validation code can read and write. The rules (from ERC-7562) are roughly:

Unstaked entities can only access:

In practice, this means your account can read/write to mappings where the account address is the key. For example, an ERC-20 token stores balances in a mapping like mapping(address => uint256) balances. The actual storage slot is keccak256(address || slot_of_mapping). If your account address is the key, that slot is "associated" with you. The +n offset (0-128) allows access to struct members stored after the mapping entry—useful when the mapping value is a struct.

Staked entities (accounts, paymasters, or factories that have deposited stake in the EntryPoint) get more freedom:

See the full storage access rules in ERC-7562.

Why do these rules exist? Consider two UserOperations that read from the same storage slot during validation. If the first operation mutates that slot, the second operation’s validation assumptions may no longer hold.

By restricting storage access during validation, bundlers can safely include multiple UserOperations in the same bundle without risking cross-operation interference or non-deterministic failures.

The Reputation System

Even with validation rules, entities can behave badly. An account might consistently submit UserOps that pass simulation but fail on-chain due to subtle state changes. A paymaster might approve UserOps during simulation but decline payment on-chain.

Bundlers track these entities with a reputation system. For each entity (account address, paymaster address, factory address), the bundler tracks:

The reputation status is determined by slack-based thresholds (defined in ERC-7562):

maxSeen = opsSeen / MIN_INCLUSION_RATE_DENOMINATOR (10 for bundlers)
status = BANNED    if maxSeen > opsIncluded + BAN_SLACK (50)
status = THROTTLED if maxSeen > opsIncluded + THROTTLING_SLACK (10)
status = OK        otherwise

The "slack" values are tolerance buffers that prevent false positives from normal operational variance. THROTTLING_SLACK = 10 means an entity can have up to 10 more expected-inclusions than actual-inclusions before being throttled. BAN_SLACK = 50 provides an even larger buffer before permanent banning. This design acknowledges that some UserOps legitimately fail (network conditions, race conditions) without indicating malicious behavior.

When an entity is throttled, the bundler limits how many UserOps from that entity can be in the mempool. When banned, all UserOps involving that entity are rejected immediately.

Staking

Entities can improve their standing by staking ETH in the EntryPoint contract:

entryPoint.addStake{ value: 1 ether }(unstakeDelaySec);

The stake isn't slashed—it's just locked. But it demonstrates commitment and grants:

For paymasters and factories, especially, staking is almost mandatory for production use. Without it, a single failed UserOp can quickly get the entity throttled. The canonical mempool requires MIN_STAKE_VALUE (chain-specific, typically 1 ETH or equivalent) and MIN_UNSTAKE_DELAY of 86400 seconds (1 day). The exact stake amount varies by chain and is defined in the mempool metadata—check the bundler documentation for your target network.

Gas Economics

Bundlers are businesses. They pay gas costs to submit bundles and get reimbursed from the UserOps they include. The economics work like this:

The Bundler's Perspective

Revenue = Σ (each UserOp's payment to beneficiary)
Cost    = Gas used × Gas price paid for handleOps tx
Profit  = Revenue - Cost

Each UserOp pays based on its gas usage and the gas prices it specified:

Payment = (actualGasUsed + preVerificationGas) × min(maxFeePerGas, baseFee + maxPriorityFeePerGas)

The bundler sets the beneficiary address in the handleOps call to receive these payments.

preVerificationGas Explained

The preVerificationGas field covers costs that can't be directly metered:

When estimating gas, bundlers calculate preVerificationGas based on the UserOp's size:

// Simplified preVerificationGas calculation
const calldataCost = userOpBytes.reduce((sum, byte) =>
    sum + (byte === 0 ? 4n : 16n), 0n
);
const overhead = 38000n; // ~21000 tx base + ~10000 bundle overhead + ~7000 per-op
preVerificationGas = calldataCost + overhead + l2DataFee;

Overhead values vary by bundler. For reference, Alto uses transactionGasStipend: 21000, fixedGasOverhead: 9830, perUserOp: 7260. Always use eth_estimateUserOperationGas rather than hardcoding.

The Unused Gas Penalty

To prevent users from overpaying for gas (which wastes blockspace), the EntryPoint imposes a penalty on unused execution gas. Specifically, the penalty applies to callGasLimit (for account execution) and paymasterPostOpGasLimit (for paymaster post-operations):

When you see gas estimation errors, check if your limits are reasonable. The bundler's eth_estimateUserOperationGas endpoint provides sensible defaults.

Common Errors and Debugging

When a UserOperation is rejected, the RPC error is the only signal you get. Bundlers return structured error codes that explain exactly why the operation failed — but only if you know how to read them.

AA1x: Factory Errors

These occur when deploying a new account via the factory. In EntryPoint v0.8, you specify factory and factoryData as separate fields (the EntryPoint packs them into initCode internally):

Debugging: Check that your factory's createAccount function returns the expected address. Verify the factory is deployed and funded.

AA2x: Account Validation Errors

The most common category:

AA3x: Paymaster Errors

entryPoint.depositTo{ value: 1 ether }(paymasterAddress);

Working with Bundlers

RPC Methods

Every ERC-4337 bundler implements these standard methods:

eth_sendUserOperation: Submit a UserOp for inclusion

const userOpHash = await bundler.request({
    method: 'eth_sendUserOperation',
    params: [userOp, entryPointAddress]
});

eth_estimateUserOperationGas: Get gas limit estimates

const gasEstimate = await bundler.request({
    method: 'eth_estimateUserOperationGas',
    params: [userOp, entryPointAddress]
});
// Returns: { preVerificationGas, verificationGasLimit, callGasLimit, ... }

eth_getUserOperationByHash: Look up a UserOp by its hash

const userOp = await bundler.request({
    method: 'eth_getUserOperationByHash',
    params: [userOpHash]
});

eth_getUserOperationReceipt: Get the receipt after inclusion

const receipt = await bundler.request({
    method: 'eth_getUserOperationReceipt',
    params: [userOpHash]
});
// Returns: { success, actualGasUsed, receipt: { transactionHash, ... } }

eth_supportedEntryPoints: Discover which EntryPoint versions the bundler supports

const entryPoints = await bundler.request({
    method: 'eth_supportedEntryPoints',
    params: []
});
// Returns: ['0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108']

The Shared Mempool

Originally, each bundler maintained its own private mempool. This created problems:

The solution is the ERC-4337 shared mempool, a P2P network where bundlers gossip UserOps to each other. It works similarly to how Ethereum nodes gossip transactions:

  1. User submits UserOp to any participating bundler
  2. Bundler validates and adds to the local mempool
  3. Bundler broadcasts to connected peers
  4. Any bundler on the network can include the UserOp.

The protocol uses libp2p for networking. Bundlers advertise which mempools they support (identified by IPFS CIDs that reference mempool metadata files), and only propagate UserOps that pass validation. For example, a mempool metadata file looks like:

chainId: '1'
entryPointContract: '0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108'
description: Canonical ERC-4337 mempool for Ethereum Mainnet
minimumStake: '1000000000000000000' 

The IPFS CID of this file becomes the mempool identifier used in P2P topic names. The mempool metadata defines validation rules: which opcodes are banned, storage access patterns, gas limits, and reputation thresholds. When a bundler receives a UserOp via P2P gossip, it re-validates against its own rules before adding to its local mempool.

Advanced Topics

Aggregators

What problem do aggregators solve? Signature verification is expensive on-chain. The ecrecover precompile costs 3,000 gas per call, but smart account signature verification typically costs more due to additional validation logic—often 6,000-10,000 gas total. For 100 UserOps in a bundle, that's 600,000+ gas just for signatures. Aggregators enable batch signature verification—verify all 100 signatures in a single operation for a fraction of the cost.

How it works: Instead of each account verifying its own signature, accounts can delegate to an aggregator contract. The aggregator implements a batch verification algorithm (like BLS signatures, where multiple signatures can be combined into one).

  1. Account's validateUserOp returns an aggregator address in its validation data
  2. Bundler groups all UserOps using the same aggregator
  3. Bundler calls aggregator.validateSignatures(userOps, aggregatedSignature) once for the group
  4. If verification passes, all UserOps in that group are considered valid

The validationData encoding: The return value from validateUserOp packs three pieces of information into a single 256-bit value:

validationData = uint160(aggregator) |         // bits 0-159: aggregator address
    (uint256(validUntil) << 160) |             // bits 160-207: expiration timestamp
    (uint256(validAfter) << 208)               // bits 208-255: earliest valid time

This encoding lets accounts specify both signature verification delegation and time-bounded validity in a single return value.

Paymasters

Paymasters abstract gas payment from users. Instead of the account paying for gas, a paymaster can:

The paymaster's validation flow runs during the validation phase:

function validatePaymasterUserOp(
    PackedUserOperation calldata userOp,
    bytes32 userOpHash,
    uint256 maxCost
) external returns (bytes memory context, uint256 validationData);

The context returned here is passed to postOp after execution completes, allowing the paymaster to perform final accounting (like charging an ERC-20 token):

function postOp(
    PostOpMode mode,
    bytes calldata context,
    uint256 actualGasCost,
    uint256 actualUserOpFeePerGas
) external;

Paymasters should be staked for production use. Staking provides relaxed storage access rules and better reputation—unstaked paymasters face strict limitations and can be quickly throttled by bundlers. While unstaked paymasters can technically function with basic operations, staking is practically required for any serious paymaster implementation.

Testing Locally

This section assumes you have Anvil running with EntryPoint v0.8 deployed. We'll use Alto, Pimlico's TypeScript bundler, and permissionless.js, a viem-based library for ERC-4337 interactions.

SimpleAccountFactory

In Part 1 we built a minimal smart account. But how do users deploy it? They can't send a regular transaction—they don't have ETH for gas yet. ERC-4337 solves this with factory contracts.

For SimpleAccount, the reference implementation includes SimpleAccountFactory. Deploy it alongside the EntryPoint before running the examples below.

Account Deployment via UserOp

When the EntryPoint receives a UserOp with factory and factoryData fields:

  1. Checks if sender has code—if yes, skip deployment
  2. Calls factory.createAccount(owner, salt) via the factoryData
  3. Verifies the deployed address matches sender
  4. Continues with validation on the newly-deployed account

Running Alto

alto \
    --rpc-url http://localhost:8545 \
    --entrypoints 0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108 \
    --executor-private-keys 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d \
    --utility-private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
    --safe-mode false \
    --api-version v1,v2 \
    --bundle-mode auto

Key flags:

Sending UserOperations with permissionless.js

Install dependencies:

npm install viem permissionless

Step 1: Set up clients

We need three clients: one for reading chain state, one for bundler-specific RPCs, and one for the smart account owner.

import { http, createPublicClient, createWalletClient, parseEther } from "viem"
import { privateKeyToAccount } from "viem/accounts"
import { foundry } from "viem/chains"
import { toSimpleSmartAccount } from "permissionless/accounts"
import { createSmartAccountClient } from "permissionless/clients"
import { createPimlicoClient } from "permissionless/clients/pimlico"

const ENTRYPOINT = "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108"

const publicClient = createPublicClient({
    chain: foundry,
    transport: http("http://localhost:8545")
})

const pimlicoClient = createPimlicoClient({
    chain: foundry,
    transport: http("http://localhost:4337"),
    entryPoint: { address: ENTRYPOINT, version: "0.8" }
})

const owner = privateKeyToAccount(process.env.PRIVATE_KEY)

The pimlicoClient connects to Alto's RPC and provides gas estimation via pimlico_getUserOperationGasPrice.

Step 2: Create the smart account instance

const simpleAccount = await toSimpleSmartAccount({
    client: publicClient,
    owner,
    entryPoint: { address: ENTRYPOINT, version: "0.8" }
})

const accountAddress = await simpleAccount.getAddress()
console.log("Account:", accountAddress)

This computes the counterfactual address using the factory's getAddress function. The account doesn't exist yet—but we know exactly where it will be deployed.

Step 3: Fund the account

The smart account needs ETH to pay for gas (or use a paymaster). We can send ETH to the counterfactual address:

const walletClient = createWalletClient({
    account: owner,
    chain: foundry,
    transport: http("http://localhost:8545")
})

await walletClient.sendTransaction({
    to: accountAddress,
    value: parseEther("1")
})

The ETH sits at that address. When the account is deployed, it can access those funds immediately.

Step 4: Create the smart account client

const smartAccountClient = createSmartAccountClient({
    client: publicClient,
    account: simpleAccount,
    bundlerTransport: http("http://localhost:4337"),
    userOperation: {
        estimateFeesPerGas: async () =>
            (await pimlicoClient.getUserOperationGasPrice()).fast
    }
})

The smartAccountClient handles UserOp construction, nonce management, gas estimation, and signing. The estimateFeesPerGas callback fetches current gas prices from the bundler.

Step 5: Send a UserOperation

const hash = await smartAccountClient.sendUserOperation({
    calls: [{
        to: "0xa0Ee7A142d267C1f36714E4a8F75612F20a79720",
        value: parseEther("0.01"),
        data: "0x"
    }]
})

const receipt = await smartAccountClient.waitForUserOperationReceipt({ hash })
console.log("Success:", receipt.success)

For the first UserOp, the SDK automatically includes factory and factoryData fields. The EntryPoint deploys the account, then executes the transfer—all in one transaction.

What We've Learned

Bundlers are the execution layer of ERC-4337. They are what turns Account Abstraction from a specification into a production-ready mechanism.

Understanding their constraints — validation rules, gas economics, and reputation mechanics — is critical when designing reliable smart accounts. Mistakes here don’t surface in Solidity code, but in mempool behavior, simulations, and execution economics.

ERC-4337 shifts complexity away from the protocol and into infrastructure. The sooner developers start thinking in terms of bundlers rather than transactions, the more resilient their systems will be in production.