ERC-4337 moves smart-account execution into an alternative mempool where a UserOperation is not a transaction and has no transaction hash until a bundler includes it. Bundlers expose a dedicated JSON-RPC surface defined by ERC-7769, including eth_sendUserOperation, eth_estimateUserOperationGas, eth_getUserOperationReceipt, eth_getUserOperationByHash, eth_supportedEntryPoints, and eth_chainId. A client builds and signs a UserOperation, pre-flights it with eth_estimateUserOperationGas, submits it with eth_sendUserOperation, then polls eth_getUserOperationReceipt rather than eth_getTransactionReceipt. Validation failures and simulation failures return different JSON-RPC error objects, and gas fields such as callGasLimit, verificationGasLimit, preVerificationGas, and paymasterVerificationGasLimit must be set correctly or an estimate that passes at simulation can still revert on chain. This article isolates the RPC contract so you can integrate any bundler without an SDK, then measure its behavior against your own endpoint.
Why the alt mempool changes the RPC surface
ERC-4337 introduces an alternative mempool in which users submit UserOperation objects instead of transactions. A UserOperation is a structured intent containing sender, nonce, callData, gas limits, and optional paymaster fields; it is not a signed Ethereum transaction and cannot be broadcast with eth_sendRawTransaction. The authoritative definition of the struct, the EntryPoint contract, and the bundler role lives in ERC-4337: Account Abstraction Using Alt Mempool.
Because a UserOperation is not a transaction, it has no transaction hash at submission time. Instead it has a userOpHash, computed over the packed UserOperation and the EntryPoint address and chain ID. Only after a bundler includes the operation does the EntryPoint emit a UserOperationEvent containing that hash, and only then does an underlying transaction hash exist. This distinction is the root cause of most integration confusion: clients that poll eth_getTransactionReceipt with a userOpHash will always receive null.
The alt mempool is also why the RPC surface is separate from a general-purpose node. A standard Ethereum node exposes eth_* methods for transactions and state; a bundler exposes an additional method set for UserOperations. The two services can be operated independently, so a bundler endpoint may be rate-limited or unavailable even when your general RPC endpoint is healthy. For background on how the standard transaction pool is exposed, see The Ethereum txpool namespace and the alt mempool.
- UserOperation: an intent struct, not a signed transaction.
- userOpHash: deterministic identifier derived from the operation and EntryPoint.
- Transaction hash: exists only after inclusion and event emission.
- Bundler endpoint: a distinct service from a general-purpose RPC endpoint.
The bundler JSON-RPC method set and what each returns
ERC-7769: JSON-RPC API for ERC-4337 standardizes the method contracts a bundler must implement. The core methods are eth_sendUserOperation, eth_estimateUserOperationGas, eth_getUserOperationReceipt, eth_getUserOperationByHash, eth_supportedEntryPoints, and eth_chainId. Each is wrapped in the JSON-RPC 2.0 envelope defined by the JSON-RPC 2.0 Specification, so requests carry jsonrpc, method, params, and id, and responses carry either result or error.
eth_sendUserOperation accepts a UserOperation and an EntryPoint address and returns the userOpHash as a hex string. eth_estimateUserOperationGas accepts the same pair plus an optional state override and returns gas estimates for callGasLimit, verificationGasLimit, preVerificationGas, and, when a paymaster is present, paymasterVerificationGasLimit. eth_getUserOperationReceipt accepts a userOpHash and returns the full receipt once included, including the transaction receipt, logs, and the actual gas used.
eth_getUserOperationByHash returns the operation and its inclusion context if known, which is useful for debugging a submission that has not yet been included. eth_supportedEntryPoints returns the EntryPoint addresses the bundler accepts, and eth_chainId returns the chain ID the bundler is serving. Always call eth_supportedEntryPoints and eth_chainId before submitting, because a mismatch between your EntryPoint version and the bundler's supported set is a common and silent failure.
- eth_sendUserOperation -> userOpHash (hex string).
- eth_estimateUserOperationGas -> callGasLimit, verificationGasLimit, preVerificationGas, paymasterVerificationGasLimit.
- eth_getUserOperationReceipt -> receipt with transaction receipt, logs, actual gas used.
- eth_getUserOperationByHash -> operation plus inclusion context.
- eth_supportedEntryPoints -> accepted EntryPoint addresses.
- eth_chainId -> chain ID served by the bundler.
Client-side call sequence from build to receipt
The integration sequence has four stages: build and sign, pre-flight, submit, and poll. Building means assembling the UserOperation fields, computing the userOpHash, and signing it with the smart account's signing key. Pre-flight means calling eth_estimateUserOperationGas to obtain gas limits. Submit means calling eth_sendUserOperation with the signed operation. Poll means repeatedly calling eth_getUserOperationReceipt until it returns a receipt or you exceed your timeout.
The polling stage is where the alt mempool diverges most sharply from standard transaction handling. Because a UserOperation has no transaction hash until inclusion, you must poll eth_getUserOperationReceipt with the userOpHash, not eth_getTransactionReceipt. The same null-until-mined semantics that apply to standard receipts apply here, but keyed on a different identifier; the mechanics of receipt polling are covered in eth_getTransactionReceipt returns null and receipt polling.
When a bundler rejects a submission, it returns a JSON-RPC error object with code, message, and data. Validation failures, where the EntryPoint rejects the operation during validation, typically surface as an error whose data contains an AA-prefixed revert reason. Simulation failures, where the bundler's own simulation of the operation fails before submission, may return a different code or a message indicating simulation failure. The exact codes and message strings vary by bundler, so treat the error shape as documented behavior to inspect rather than a fixed contract.
- Build and sign: assemble fields, compute userOpHash, sign.
- Pre-flight: eth_estimateUserOperationGas.
- Submit: eth_sendUserOperation.
- Poll: eth_getUserOperationReceipt with the userOpHash.
- Validation failure vs simulation failure: inspect the error object's code, message, and data.
Paymaster data and the gas fields that decide inclusion
A UserOperation carries four gas-related fields that must be set before submission: callGasLimit, verificationGasLimit, preVerificationGas, and, when a paymaster sponsors the operation, paymasterVerificationGasLimit. callGasLimit bounds the execution of the account's callData; verificationGasLimit bounds the account and paymaster validation; preVerificationGas covers calldata and bundler overhead; paymasterVerificationGasLimit bounds the paymaster's own validation. ERC-4337 documents these fields and their roles in the EntryPoint's validation and execution phases.
Paymaster data is passed in the paymasterAndData field, whose encoding is paymaster-specific. A paymaster may require a signed approval, a token payment, or a time-bounded sponsorship, and the encoding of that data is defined by the paymaster implementation, not by ERC-4337 itself. This is a documented point of variation: the field is standardized, but its contents are not.
An estimate that passes at simulation can still revert on chain because simulation runs against a specific state snapshot. If the account's nonce changes, if a paymaster's deposit is depleted, if a token balance moves, or if the operation is included in a different block with different gas prices, the on-chain validation can fail even though the estimate succeeded. Treat estimates as a lower bound and leave headroom, especially on verificationGasLimit and preVerificationGas.
- callGasLimit: bounds execution of callData.
- verificationGasLimit: bounds account and paymaster validation.
- preVerificationGas: covers calldata and bundler overhead.
- paymasterVerificationGasLimit: bounds paymaster validation.
- paymasterAndData encoding: paymaster-specific, varies by implementation.
A runnable Node.js client using plain fetch
The following example uses only the built-in fetch API so the RPC contract is visible without an SDK. It calls eth_chainId and eth_supportedEntryPoints first, then eth_estimateUserOperationGas, then eth_sendUserOperation, then polls eth_getUserOperationReceipt. Replace the endpoint, EntryPoint address, and UserOperation fields with your own values. The signing step is omitted because it depends on your smart account implementation; in practice you sign the userOpHash with the account's key before submission.
Note that the example treats the bundler endpoint as a single URL. In production you may point at a dedicated bundler service, which is a distinct service from a general-purpose RPC endpoint. For endpoint selection guidance, see Ethereum RPC endpoints and provider selection (RPC Assistant).
const BUNDLER_URL = 'https://your-bundler-endpoint.example';
const ENTRY_POINT = '0x0000000071727De22E5E9d8BAf0edAc6f37da032';
async function rpc(method, params) {
const res = await fetch(BUNDLER_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
});
const json = await res.json();
if (json.error) throw new Error(JSON.stringify(json.error));
return json.result;
}
async function main() {
const chainId = await rpc('eth_chainId', []);
const entryPoints = await rpc('eth_supportedEntryPoints', []);
console.log('chainId', chainId, 'entryPoints', entryPoints);
const userOp = {
sender: '0xYourSmartAccountAddress',
nonce: '0x0',
callData: '0x',
callGasLimit: '0x0',
verificationGasLimit: '0x0',
preVerificationGas: '0x0',
maxFeePerGas: '0x0',
maxPriorityFeePerGas: '0x0',
paymasterAndData: '0x',
signature: '0x'
};
const gas = await rpc('eth_estimateUserOperationGas', [userOp, ENTRY_POINT]);
console.log('gas estimate', gas);
const signedOp = { ...userOp, ...gas, signature: '0xYourSignature' };
const userOpHash = await rpc('eth_sendUserOperation', [signedOp, ENTRY_POINT]);
console.log('userOpHash', userOpHash);
for (let i = 0; i < 30; i++) {
const receipt = await rpc('eth_getUserOperationReceipt', [userOpHash]);
if (receipt) {
console.log('included', receipt.receipt.transactionHash);
return;
}
await new Promise(r => setTimeout(r, 4000));
}
console.log('not included within timeout');
}
main().catch(err => { console.error(err); process.exit(1); });Measuring your bundler against your EntryPoint
Because bundler behavior varies by provider, the only reliable way to characterize your integration is to measure it. Run the client above against your own endpoint and EntryPoint, and record the results in a table. The table below is a template; fill it in with values you observe, not with numbers from this article. Do not assume any provider-specific latency, throughput, or rate limit without measuring it yourself.
Measure at least the following: the chain ID and supported EntryPoints returned, the gas estimates for a representative operation, the time from eth_sendUserOperation to the first non-null eth_getUserOperationReceipt, and the error object shape for a deliberately invalid operation. Repeat the measurement across several operations to see variance. If you operate multiple bundlers, run the same table against each so you can compare them on your own terms.
- Results Table columns: bundler endpoint, chain ID, supported EntryPoint, estimate (callGasLimit / verificationGasLimit / preVerificationGas / paymasterVerificationGasLimit), submit-to-receipt time, error code on invalid operation, error message on invalid operation, notes.
- Run each row at least three times to observe variance.
- Record the exact error object, not a paraphrase, so you can match it in code.
- Compare against a second bundler if you need redundancy.
Failure modes and AA-prefixed revert reasons
EntryPoint validation failures surface as revert reasons prefixed with AA. Common examples include AA21 (sender did not pay prefund), AA22 (expired or not due), AA23 (reverted during validation), AA24 (signature error), AA25 (invalid account nonce), and AA31 (paymaster did not pay prefund). These strings are documented in ERC-4337 and are emitted by the EntryPoint contract, so they are consistent across bundlers that use a compatible EntryPoint version. When you see an AA-prefixed reason, the failure is in validation, not in your callData execution.
Nonce-key management is a frequent source of parallel-operation failures. ERC-4337 uses a 256-bit nonce with a 192-bit key and a 64-bit sequence, allowing multiple independent nonce streams per account. If you submit several UserOperations in parallel with the same nonce key and sequence, only one can be included; the others fail with AA25. Use distinct nonce keys for independent streams, and increment the sequence within each stream. For the standard transaction nonce model, see EVM nonce management with eth_getTransactionCount.
A bundler that returns a userOpHash but never includes the operation is a distinct failure mode. The operation may be dropped from the alt mempool, may be underpriced relative to current conditions, or may be waiting behind a nonce gap. Poll eth_getUserOperationByHash to see whether the bundler still knows about the operation, and check your maxFeePerGas and maxPriorityFeePerGas against current conditions. For fee estimation background, see Estimating gas price with eth_feeHistory.
Chain ID and EntryPoint version mismatches are silent until submission. If your client targets one chain but the bundler serves another, or if your EntryPoint address is not in eth_supportedEntryPoints, the bundler may reject the operation or return an error that does not mention the mismatch. Always verify eth_chainId and eth_supportedEntryPoints before building the operation.
- AA21: sender did not pay prefund.
- AA22: expired or not due.
- AA23: reverted during validation.
- AA24: signature error.
- AA25: invalid account nonce.
- AA31: paymaster did not pay prefund.
- Nonce keys: use distinct keys for parallel streams.
- userOpHash without inclusion: check eth_getUserOperationByHash and fee fields.
- Chain ID and EntryPoint mismatch: verify before building.
Distinguishing documented behavior from provider variation
ERC-4337 and ERC-7769 define the UserOperation struct, the EntryPoint contract, the alt mempool, and the method contracts for the bundler RPC surface. These are documented behaviors you can rely on across compliant implementations. The JSON-RPC 2.0 Specification defines the error-object envelope with code, message, and data, which bundlers use to return validation and simulation failures.
What varies by bundler or provider includes the specific error codes and message strings for validation and simulation failures, the supported EntryPoint versions, the rate limits and availability of the bundler endpoint, the paymaster data encodings accepted, and the inclusion policy for underpriced operations. Treat these as provider-specific and measure them against your own endpoint rather than assuming a fixed contract. This is why the results table above is a template rather than a set of expected values.
A bundler endpoint is a distinct service from a general-purpose RPC endpoint. It may be rate-limited or unavailable independently of your standard RPC provider, and it may serve a different set of chains. If you need both, plan for two endpoints and two failure domains. For general Ethereum endpoint options, see Ethereum RPC endpoints and provider selection (RPC Assistant) and the Ethereum network page.
- Documented: UserOperation struct, EntryPoint, alt mempool, method contracts, JSON-RPC error envelope.
- Varies by bundler: error codes and messages, supported EntryPoints, rate limits, paymaster encodings, inclusion policy.
- Bundler endpoint and general RPC endpoint are separate services with separate failure domains.
Limitations and tradeoffs of the bundler RPC model
The bundler RPC model adds a service dependency between your client and the chain. A UserOperation is not included until a bundler chooses to include it, so inclusion is not guaranteed by submission alone. This is a deliberate tradeoff of the alt mempool: it enables sponsorship and batching, but it also means your client must handle a pending state that has no transaction hash and no standard replacement semantics.
Gas estimation is advisory. An estimate that passes at simulation can still revert on chain because state changes between simulation and inclusion. Paymaster sponsorship adds another dependency: if the paymaster's deposit is depleted or its policy changes, the operation fails validation even though your account is funded. Nonce-key management adds complexity for parallel operations, and the AA-prefixed revert reasons require you to map error strings to remediation steps.
Operationally, you should treat the bundler endpoint as a separate availability domain. It may be rate-limited or unavailable independently of your general RPC endpoint, and its supported EntryPoint set may change. If you need redundancy, run the results table against multiple bundlers and route based on measured behavior rather than assumptions. For related reliability patterns, see JSON-RPC idempotency and duplicate-request safety.
- Inclusion is not guaranteed by submission.
- Estimates are advisory and can diverge from on-chain execution.
- Paymaster sponsorship adds a second dependency.
- Nonce-key management adds complexity for parallel operations.
- Bundler endpoint is a separate availability domain.
Next steps for integrating a smart account with a bundler
Start by calling eth_chainId and eth_supportedEntryPoints against your chosen bundler, then run the Node.js client above with a minimal operation and fill in the results table. Once you have a baseline, add paymaster sponsorship and measure how the gas fields change. Then test failure paths deliberately: submit an operation with a bad signature to observe the AA24 error shape, and submit two operations with the same nonce key to observe AA25.
For production, wrap the client in retry and timeout logic, and poll eth_getUserOperationReceipt with a bounded timeout rather than indefinitely. Keep a fallback bundler endpoint if availability matters, and re-run the results table periodically because provider behavior can change. If you need general Ethereum RPC access alongside your bundler, review RPC pricing and the API service to plan your endpoint topology.
For broader context on Ethereum RPC integration patterns, the OnFinality Learn hub collects related guides on receipts, nonces, fee estimation, and idempotency. Use those alongside this article to build a complete client that handles both the standard transaction path and the ERC-4337 alt mempool path.
- Verify chain ID and supported EntryPoints first.
- Run the client and fill in the results table.
- Add paymaster sponsorship and re-measure gas fields.
- Test AA24 and AA25 failure paths deliberately.
- Add retries, timeouts, and a fallback bundler if needed.