Development of Account Abstraction Bundler
Bundler is the central infrastructure component of the ERC-4337 ecosystem. It's what enables Account Abstraction to work: accepts UserOperations from users, validates them, groups into batch and sends on-chain via EntryPoint.handleOps(). Without bundler, Account Abstraction doesn't work — there's no mechanism to deliver UserOps to blockchain.
Developing your own bundler is relevant if: you need custom mempool logic, MEV optimization for UserOps, private bundler for specific application, or need to understand and control entire ERC-4337 infrastructure.
What Bundler Does: Detailed Flow
User creates UserOperation and sends it to bundler via JSON-RPC method eth_sendUserOperation. Bundler performs series of checks, keeps UserOp in its alt mempool, and periodically sends batch on-chain.
Phase 1: Validation
Bundler calls EntryPoint.simulateValidation(userOp). This is view-function (reverts with result via custom error) that:
- If
initCodenot empty — deploys Account contract via factory - Calls
account.validateUserOp()— checks signature, nonce - If Paymaster specified — calls
paymaster.validatePaymasterUserOp() - Returns
ValidationResultwith gas data, Paymaster stake info, time constraints
interface ValidationResult {
returnInfo: {
preOpGas: bigint;
prefund: bigint; // how much ETH account/paymaster deposited in EntryPoint
sigFailed: boolean;
validAfter: number;
validUntil: number;
};
senderInfo: StakeInfo;
factoryInfo?: StakeInfo;
paymasterInfo?: StakeInfo;
}
prefund — key moment. Account or Paymaster must have deposit in EntryPoint sufficient to cover maxFeePerGas * (verificationGasLimit + callGasLimit). Bundler checks this before adding to mempool.
Storage Access Rules: Why This is Hard
ERC-4337 enforces strict restrictions on what storage can be read/written by validateUserOp. Goal — prevent situation where one UserOp makes others invalid (griefing attack).
Forbidden in validation:
- Read storage of other contracts, except Account itself and related entities
- Call
block.timestamp,block.number(except limited use viavalidAfter/validUntil) - Access storage that might be changed by another UserOp in same batch
Bundler tracks storage slots accessed during validation via debug_traceCall with EVM tracer. This is expensive operation — one of main performance bottlenecks of bundler.
// Simplified: tracer for tracking storage access
async function traceValidation(userOp: UserOperation): Promise<StorageMap> {
const trace = await provider.send('debug_traceCall', [{
to: ENTRY_POINT_ADDRESS,
data: entryPoint.interface.encodeFunctionData('simulateValidation', [userOp])
}, 'latest', {
tracer: bundlerCollectorTracer, // custom JS tracer
tracerConfig: { /* ... */ }
}])
return parseStorageAccess(trace)
}
bundlerCollectorTracer — JavaScript tracer for go-ethereum's debug_traceCall. Tracks every SLOAD/SSTORE opcode and links to calling contract. This is most technically non-trivial part of bundler.
Phase 2: Alt Mempool Management
UserOp accepted to mempool must remain valid. Bundler monitors:
Nonce invalidation. If on-chain Account nonce changes (other UserOp passed) — pending UserOp with stale nonce is removed.
Deposit insufficiency. If deposit balance in EntryPoint decreased (other UserOp sponsored by same Paymaster passed) — need to recalculate if enough for all pending UserOps of this Paymaster.
Gas price changes. UserOp with maxFeePerGas below current basefee — won't pass, bundler can temporarily defer or drop.
class UserOpMempool {
private pool: Map<string, MempoolEntry> = new Map()
async add(userOp: UserOperation): Promise<string> {
const hash = getUserOpHash(userOp)
// Reputation system: limit by sender/paymaster/factory
this.reputationManager.checkReputation(userOp)
this.pool.set(hash, {
userOp,
prefund: await this.calculatePrefund(userOp),
addedAt: Date.now()
})
return hash
}
getBundle(maxGas: bigint): UserOperation[] {
// Greedy algorithm: select UserOps with highest priority fee
// accounting for gas limit and storage conflicts
return this.selectNonConflicting(
[...this.pool.values()]
.sort((a, b) => Number(b.userOp.maxPriorityFeePerGas - a.userOp.maxPriorityFeePerGas)),
maxGas
)
}
}
Phase 3: Bundle Submission
Bundler forms batch from valid UserOps and sends EntryPoint.handleOps(ops, beneficiary). beneficiary — address where EntryPoint sends collected gas (bundler's priority fee).
Critical moment: bundler sends regular EOA transaction. Pays gas upfront, EntryPoint reimburses from Account/Paymaster deposits. If handleOps reverts — bundler loses gas. That's why simulation before sending is mandatory.
Protection from reverting bundle: EntryPoint in handleOps skips UserOps that revert in execution phase (not validation). For validation phase — if revert, entire handleOps fails. Bundler must ensure validation will pass for certain.
Reputation System
To prevent spam and DoS attacks, ERC-4337 introduces reputation system for unbanned entities (Paymaster, Factory, Aggregator). Logic:
class ReputationManager {
// For each entity track: ops included vs ops unsuccessful
updateIncluded(entity: string): void {
this.entries[entity].opsSeen++
this.entries[entity].opsIncluded++
}
updateFailed(entity: string): void {
this.entries[entity].opsIncluded-- // if bundle was reverted
}
getStatus(entity: string): 'ok' | 'throttled' | 'banned' {
const entry = this.entries[entity]
if (!entry) return 'ok'
const ratio = entry.opsIncluded / Math.max(1, entry.opsSeen)
if (ratio < MIN_INCLUSION_RATE_DENOMINATOR) return 'banned'
if (entry.opsSeen > THROTTLE_THRESHOLD) return 'throttled'
return 'ok'
}
}
Staking in EntryPoint raises limits: entity with stake can have more UserOps in mempool. This is anti-spam mechanism: can't freely flood mempool.
P2P Mempool
For decentralized bundler you need P2P alt mempool — network for exchanging UserOps between bundler nodes. ERC-4337 specifies protocol based on libp2p with gossipsub:
-
Topic:
user_ops/{chainId}/{entryPointAddress} - Message: RLP-encoded UserOperation
- Validation: each node independently validates before relay
import { createLibp2p } from 'libp2p'
import { gossipsub } from '@chainsafe/libp2p-gossipsub'
const libp2p = await createLibp2p({
/* ... transport, identify, etc */
services: {
pubsub: gossipsub({
allowPublishToZeroPeers: true,
msgIdFn: (msg) => computeUserOpHash(msg.data)
})
}
})
libp2p.services.pubsub.subscribe(userOpsTopic)
libp2p.services.pubsub.addEventListener('message', async (event) => {
const userOp = decodeUserOp(event.detail.data)
await mempool.add(userOp) // with all checks
})
MEV and Bundle Construction
Bundler has unique position: it selects order of UserOps in bundle, opening MEV opportunities. Two strategies:
Honest FIFO bundler — includes UserOps in order received, maximizes priority fee. Simple implementation, good for permissioned bundler for specific application.
MEV-aware bundler — analyzes UserOps callData, finds arbitrage opportunities, constructs bundle optimally. Integration with Flashbots MEV-boost for sending bundle via private mempool.
Implementations for Study and Fork
- Infinitism/bundler (TypeScript) — reference implementation from ERC-4337 creators
- Stackup bundler (Go) — production bundler from Stackup
- Silius (Rust) — high-performance bundler
- Rundler (Rust) — bundler from Alchemy
For custom development: TypeScript reference is easier for understanding, Rust/Go better for production throughput.
Development Stack
| Component | Technology |
|---|---|
| RPC server | Node.js / Go / Rust |
| EVM tracing | debug_traceCall + custom JS tracer |
| Mempool storage | Redis / in-memory + persistence |
| P2P (optional) | libp2p + gossipsub |
| Monitoring | Prometheus + Grafana |
| Testing | Foundry + Hardhat (local EntryPoint) |
Timeline
Basic centralized bundler with RPC, validation, mempool and bundle submission: 6-8 weeks. Main complexity — correct EVM tracer for storage access rules.
Production bundler with reputation system, P2P mempool, MEV optimization, monitoring: 3-4 months.
Key warning: incorrect storage access rules implementation leads to either accepting dangerous UserOps (DoS risk) or rejecting valid ones (poor UX). Thorough testing on all edge cases is mandatory.







