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. Part 1 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 } 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. accountGasLimits = verificationGasLimit | callGasLimit The lifecycle of a UserOperation looks like this: Creation: User constructs the UserOp with their smart account SDK (like permissionless.js) Signing: User signs the UserOp hash, proving they authorized the action Submission: UserOp is sent to a bundler via eth_sendUserOperation Validation: Bundler simulates the UserOp to check if it will succeed Mempool: If valid, the UserOp enters the bundler's mempool Bundling: Bundler packages multiple UserOps into a single handleOps call Execution: EntryPoint contract validates each UserOp on-chain, then executes them Payment: EntryPoint collects gas costs from each account (or their paymaster). Creation: User constructs the UserOp with their smart account SDK (like permissionless.js) Creation Signing: User signs the UserOp hash, proving they authorized the action Signing Submission: UserOp is sent to a bundler via eth_sendUserOperation Submission eth_sendUserOperation Validation: Bundler simulates the UserOp to check if it will succeed Validation Mempool: If valid, the UserOp enters the bundler's mempool Mempool Bundling: Bundler packages multiple UserOps into a single handleOps call Bundling handleOps Execution: EntryPoint contract validates each UserOp on-chain, then executes them Execution Payment: EntryPoint collects gas costs from each account (or their paymaster). Payment 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. Validation Phase Execution Phase: The account's execute function runs the actual operation. No restrictions here—full EVM capabilities. Execution Phase Banned Opcodes Certain opcodes are banned during validation because their values can change between simulation and execution: TIMESTAMP, NUMBER, COINBASE, PREVRANDAO: Block-dependent values. An account could check if (block.timestamp > deadline) revert, pass simulation, then fail when the bundle lands in a later block. BLOCKHASH: Returns different values for different blocks. Same attack vector. GASPRICE, BASEFEE: Change based on network conditions. BALANCE, SELFBALANCE: Only allowed from staked entities (see Staking below) per ERC-7562 [OP-080]. GASLIMIT, ORIGIN: Could vary between simulation environment and actual execution. BLOBHASH, BLOBBASEFEE: EIP-4844 blob-related opcodes that vary per block. SELFDESTRUCT, INVALID: Destructive opcodes not allowed during validation. GAS: Only allowed when immediately followed by a *CALL instruction—CALL, DELEGATECALL, STATICCALL, or CALLCODE (for gas forwarding to other contracts). CREATE: Generally banned because it creates contracts at unpredictable addresses. However, CREATE is allowed for the sender contract when using an unstaked factory. CREATE2 is permitted exactly once during deployment for the sender contract. TIMESTAMP, NUMBER, COINBASE, PREVRANDAO: Block-dependent values. An account could check if (block.timestamp > deadline) revert, pass simulation, then fail when the bundle lands in a later block. TIMESTAMP NUMBER COINBASE PREVRANDAO BLOCKHASH: Returns different values for different blocks. Same attack vector. BLOCKHASH GASPRICE, BASEFEE: Change based on network conditions. GASPRICE BASEFEE BALANCE, SELFBALANCE: Only allowed from staked entities (see Staking below) per ERC-7562 [OP-080]. BALANCE SELFBALANCE GASLIMIT, ORIGIN: Could vary between simulation environment and actual execution. GASLIMIT ORIGIN BLOBHASH, BLOBBASEFEE: EIP-4844 blob-related opcodes that vary per block. BLOBHASH BLOBBASEFEE SELFDESTRUCT, INVALID: Destructive opcodes not allowed during validation. SELFDESTRUCT INVALID GAS: Only allowed when immediately followed by a *CALL instruction—CALL, DELEGATECALL, STATICCALL, or CALLCODE (for gas forwarding to other contracts). GAS *CALL CALL DELEGATECALL STATICCALL CALLCODE CREATE: Generally banned because it creates contracts at unpredictable addresses. However, CREATE is allowed for the sender contract when using an unstaked factory. CREATE2 is permitted exactly once during deployment for the sender contract. CREATE CREATE CREATE2 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 } // 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. debug_traceCall 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: Unstaked entities Their own storage (the account's storage) Storage slots that are "associated" with the account address A, defined as: the slot value equals A, OR was calculated as keccak256(A || x) + n where x is bytes32 and n is in range 0 to 128 Their own storage (the account's storage) Storage slots that are "associated" with the account address A, defined as: the slot value equals A, OR was calculated as keccak256(A || x) + n where x is bytes32 and n is in range 0 to 128 keccak256(A || x) + n 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. mapping(address => uint256) balances keccak256(address || slot_of_mapping) +n Staked entities (accounts, paymasters, or factories that have deposited stake in the EntryPoint) get more freedom: Staked entities Can access any slot in their own storage (STO-031) Can access the associated storage of any entity in the UserOp (STO-032) Can have read-only access to storage in non-entity contracts (STO-033) Can access any slot in their own storage (STO-031) Can access the associated storage of any entity in the UserOp (STO-032) Can have read-only access to storage in non-entity contracts (STO-033) See the full storage access rules in ERC-7562. 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: reputation system opsSeen: How many UserOps involving this entity has the bundler seen opsIncluded: How many of those actually got included on-chain opsSeen: How many UserOps involving this entity has the bundler seen opsSeen opsIncluded: How many of those actually got included on-chain opsIncluded The reputation status is determined by slack-based thresholds (defined in ERC-7562): 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 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. THROTTLING_SLACK = 10 BAN_SLACK = 50 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. throttled banned Staking Entities can improve their standing by staking ETH in the EntryPoint contract: entryPoint.addStake{ value: 1 ether }(unstakeDelaySec); entryPoint.addStake{ value: 1 ether }(unstakeDelaySec); The stake isn't slashed—it's just locked. But it demonstrates commitment and grants: Relaxed storage access rules (as described above) Higher mempool limits More lenient reputation thresholds Relaxed storage access rules (as described above) Higher mempool limits More lenient reputation thresholds 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. MIN_STAKE_VALUE MIN_UNSTAKE_DELAY 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 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) Payment = (actualGasUsed + preVerificationGas) × min(maxFeePerGas, baseFee + maxPriorityFeePerGas) The bundler sets the beneficiary address in the handleOps call to receive these payments. beneficiary handleOps preVerificationGas Explained The preVerificationGas field covers costs that can't be directly metered: preVerificationGas Calldata cost: 16 gas per non-zero byte, 4 gas per zero byte Bundle overhead: Fixed costs per handleOps call that get amortized across UserOps L2 data fees: On L2s like Optimism or Arbitrum, posting calldata to L1 has additional costs Calldata cost: 16 gas per non-zero byte, 4 gas per zero byte Calldata cost Bundle overhead: Fixed costs per handleOps call that get amortized across UserOps Bundle overhead L2 data fees: On L2s like Optimism or Arbitrum, posting calldata to L1 has additional costs L2 data fees 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; // 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. transactionGasStipend: 21000 fixedGasOverhead: 9830 perUserOp: 7260 eth_estimateUserOperationGas 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): callGasLimit paymasterPostOpGasLimit If unused gas in either field exceeds PENALTY_GAS_THRESHOLD (40,000), the account pays 10% (UNUSED_GAS_PENALTY_PERCENT) of the unused amount (does NOT apply to verificationGasLimit or preVerificationGas) This discourages setting absurdly high execution limits "just to be safe" If unused gas in either field exceeds PENALTY_GAS_THRESHOLD (40,000), the account pays 10% (UNUSED_GAS_PENALTY_PERCENT) of the unused amount (does NOT apply to verificationGasLimit or preVerificationGas) PENALTY_GAS_THRESHOLD UNUSED_GAS_PENALTY_PERCENT verificationGasLimit preVerificationGas This discourages setting absurdly high execution limits "just to be safe" When you see gas estimation errors, check if your limits are reasonable. The bundler's eth_estimateUserOperationGas endpoint provides sensible defaults. eth_estimateUserOperationGas 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): factory factoryData initCode AA10: "sender already constructed" - The sender address already has code deployed. AA13: "initCode failed or OOG" - The factory's createAccount call failed or ran out of gas. AA14: "initCode must return sender" - The factory returned a different address than expected. AA15: "initCode must create sender" - The factory call completed but didn't deploy code to the sender address. AA10: "sender already constructed" - The sender address already has code deployed. AA10 AA13: "initCode failed or OOG" - The factory's createAccount call failed or ran out of gas. AA13 AA14: "initCode must return sender" - The factory returned a different address than expected. AA14 AA15: "initCode must create sender" - The factory call completed but didn't deploy code to the sender address. AA15 Debugging: Check that your factory's createAccount function returns the expected address. Verify the factory is deployed and funded. Debugging createAccount AA2x: Account Validation Errors The most common category: AA20: "account not deployed" - The sender address has no code and no initCode was provided. AA21: "didn't pay prefund" - The account doesn't have enough ETH to cover the maximum possible gas cost. Fund the account or use a paymaster. AA22: "expired or not due" - The UserOp has a validUntil timestamp that has passed, or a validAfter timestamp that hasn't arrived yet. AA23: "reverted" - The account's validateUserOp function reverted. Check your signature validation logic. AA24: "signature error" - The returned validation data indicates an invalid signature. AA25: "invalid account nonce" - The nonce doesn't match. The nonce in ERC-4337 is a 256-bit value with two parts: nonce = (key << 64) | sequence. The key (upper 192 bits) identifies the "lane"—you can have multiple parallel UserOps with different keys. The sequence (lower 64 bits) must increment sequentially within each lane. Common causes: Reusing a nonce that was already included Using the wrong nonce key Another UserOp with the same sender is pending in the mempool AA26: "over verificationGasLimit" - Account validation used more gas than allocated. Increase verificationGasLimit. AA20: "account not deployed" - The sender address has no code and no initCode was provided. AA20 initCode AA21: "didn't pay prefund" - The account doesn't have enough ETH to cover the maximum possible gas cost. Fund the account or use a paymaster. AA21 AA22: "expired or not due" - The UserOp has a validUntil timestamp that has passed, or a validAfter timestamp that hasn't arrived yet. AA22 validUntil validAfter AA23: "reverted" - The account's validateUserOp function reverted. Check your signature validation logic. AA23 validateUserOp AA24: "signature error" - The returned validation data indicates an invalid signature. AA24 AA25: "invalid account nonce" - The nonce doesn't match. The nonce in ERC-4337 is a 256-bit value with two parts: nonce = (key << 64) | sequence. The key (upper 192 bits) identifies the "lane"—you can have multiple parallel UserOps with different keys. The sequence (lower 64 bits) must increment sequentially within each lane. Common causes: AA25 nonce = (key << 64) | sequence key sequence Reusing a nonce that was already included Using the wrong nonce key Another UserOp with the same sender is pending in the mempool AA26: "over verificationGasLimit" - Account validation used more gas than allocated. Increase verificationGasLimit. AA26 verificationGasLimit AA3x: Paymaster Errors AA30: "paymaster not deployed" - The paymaster address has no code deployed. AA31: "paymaster deposit too low" - The paymaster's deposit in the EntryPoint can't cover the gas cost. Top it up: AA30: "paymaster not deployed" - The paymaster address has no code deployed. AA30 AA31: "paymaster deposit too low" - The paymaster's deposit in the EntryPoint can't cover the gas cost. Top it up: AA31 entryPoint.depositTo{ value: 1 ether }(paymasterAddress); AA32: "paymaster expired or not due" - Similar to AA22, but for the paymaster's validation data. AA33: "paymaster reverted" - The paymaster's validatePaymasterUserOp function reverted. AA34: "paymaster signature error" - Bad signature in the paymaster data. AA36: "over paymasterVerificationGasLimit" - Paymaster validation used more gas than allocated. Increase paymasterVerificationGasLimit. AA32: "paymaster expired or not due" - Similar to AA22, but for the paymaster's validation data. AA32 AA33: "paymaster reverted" - The paymaster's validatePaymasterUserOp function reverted. AA33 validatePaymasterUserOp AA34: "paymaster signature error" - Bad signature in the paymaster data. AA34 AA36: "over paymasterVerificationGasLimit" - Paymaster validation used more gas than allocated. Increase paymasterVerificationGasLimit. AA36 paymasterVerificationGasLimit Working with Bundlers RPC Methods Every ERC-4337 bundler implements these standard methods: eth_sendUserOperation: Submit a UserOp for inclusion eth_sendUserOperation const userOpHash = await bundler.request({ method: 'eth_sendUserOperation', params: [userOp, entryPointAddress] }); const userOpHash = await bundler.request({ method: 'eth_sendUserOperation', params: [userOp, entryPointAddress] }); eth_estimateUserOperationGas: Get gas limit estimates eth_estimateUserOperationGas const gasEstimate = await bundler.request({ method: 'eth_estimateUserOperationGas', params: [userOp, entryPointAddress] }); // Returns: { preVerificationGas, verificationGasLimit, callGasLimit, ... } const gasEstimate = await bundler.request({ method: 'eth_estimateUserOperationGas', params: [userOp, entryPointAddress] }); // Returns: { preVerificationGas, verificationGasLimit, callGasLimit, ... } eth_getUserOperationByHash: Look up a UserOp by its hash eth_getUserOperationByHash const userOp = await bundler.request({ method: 'eth_getUserOperationByHash', params: [userOpHash] }); const userOp = await bundler.request({ method: 'eth_getUserOperationByHash', params: [userOpHash] }); eth_getUserOperationReceipt: Get the receipt after inclusion eth_getUserOperationReceipt const receipt = await bundler.request({ method: 'eth_getUserOperationReceipt', params: [userOpHash] }); // Returns: { success, actualGasUsed, receipt: { transactionHash, ... } } const receipt = await bundler.request({ method: 'eth_getUserOperationReceipt', params: [userOpHash] }); // Returns: { success, actualGasUsed, receipt: { transactionHash, ... } } eth_supportedEntryPoints: Discover which EntryPoint versions the bundler supports eth_supportedEntryPoints const entryPoints = await bundler.request({ method: 'eth_supportedEntryPoints', params: [] }); // Returns: ['0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108'] 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: Censorship risk: A single bundler could refuse to include certain UserOps Fragmentation: Users had to know which bundlers to submit to Single points of failure: If your bundler went down, your UserOps were stuck Censorship risk: A single bundler could refuse to include certain UserOps Censorship risk Fragmentation: Users had to know which bundlers to submit to Fragmentation Single points of failure: If your bundler went down, your UserOps were stuck Single points of failure 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: ERC-4337 shared mempool User submits UserOp to any participating bundler Bundler validates and adds to the local mempool Bundler broadcasts to connected peers Any bundler on the network can include the UserOp. User submits UserOp to any participating bundler Bundler validates and adds to the local mempool Bundler broadcasts to connected peers 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' 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. What problem do aggregators solve? 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). How it works: Account's validateUserOp returns an aggregator address in its validation data Bundler groups all UserOps using the same aggregator Bundler calls aggregator.validateSignatures(userOps, aggregatedSignature) once for the group If verification passes, all UserOps in that group are considered valid Account's validateUserOp returns an aggregator address in its validation data validateUserOp Bundler groups all UserOps using the same aggregator Bundler calls aggregator.validateSignatures(userOps, aggregatedSignature) once for the group aggregator.validateSignatures(userOps, aggregatedSignature) 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: The validationData encoding validateUserOp 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 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 aggregator (bits 0-159): Address of the aggregator contract, or special values: 0 = signature valid, 1 = signature invalid validUntil (bits 160-207): Timestamp after which this UserOp expires (0 = no expiry) validAfter (bits 208-255): Timestamp before which this UserOp is not valid (0 = immediately valid) aggregator (bits 0-159): Address of the aggregator contract, or special values: 0 = signature valid, 1 = signature invalid aggregator validUntil (bits 160-207): Timestamp after which this UserOp expires (0 = no expiry) validUntil validAfter (bits 208-255): Timestamp before which this UserOp is not valid (0 = immediately valid) validAfter 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: Sponsor transactions: Pay on behalf of users (gasless UX) Accept ERC-20 tokens: Let users pay in stablecoins or other tokens Implement custom logic: Rate limiting, subscription models, etc. Sponsor transactions: Pay on behalf of users (gasless UX) Sponsor transactions Accept ERC-20 tokens: Let users pay in stablecoins or other tokens Accept ERC-20 tokens Implement custom logic: Rate limiting, subscription models, etc. Implement custom logic 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); 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): context postOp function postOp( PostOpMode mode, bytes calldata context, uint256 actualGasCost, uint256 actualUserOpFeePerGas ) external; 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. can 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. Alto permissionless.js 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. SimpleAccount SimpleAccountFactory Account Deployment via UserOp Account Deployment via UserOp When the EntryPoint receives a UserOp with factory and factoryData fields: Checks if sender has code—if yes, skip deployment Calls factory.createAccount(owner, salt) via the factoryData Verifies the deployed address matches sender Continues with validation on the newly-deployed account Checks if sender has code—if yes, skip deployment sender Calls factory.createAccount(owner, salt) via the factoryData factory.createAccount(owner, salt) factoryData Verifies the deployed address matches sender sender 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 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: --executor-private-keys: Key for submitting bundles (must have ETH) --safe-mode false: Anvil lacks the JavaScript tracer for full ERC-7562 validation --api-version v1,v2: Accept both UserOp formats (v1 for 0.6, v2 for 0.7/0.8) --executor-private-keys: Key for submitting bundles (must have ETH) --executor-private-keys --safe-mode false: Anvil lacks the JavaScript tracer for full ERC-7562 validation --safe-mode false --api-version v1,v2: Accept both UserOp formats (v1 for 0.6, v2 for 0.7/0.8) --api-version v1,v2 Sending UserOperations with permissionless.js Install dependencies: npm install viem permissionless npm install viem permissionless Step 1: Set up clients 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) 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. pimlicoClient pimlico_getUserOperationGasPrice Step 2: Create the smart account instance 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) 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. getAddress Step 3: Fund the account 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") }) 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 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 } }) 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. smartAccountClient estimateFeesPerGas Step 5: Send a UserOperation 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) 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. factory factoryData 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.