Turning ERC-4337 Simulation Failures Into Useful SDK Errors
Turning ERC-4337 Simulation Failures Into Useful SDK Errors
The problem users actually see
A user tries to swap, bridge, or approve through a smart account flow.
The action fails.
The wallet says: "transaction failed."
That message is not useful for anyone:
- not for the user, who needs to know what to fix
- not for the SDK, which needs a stable programmatic error
- not for support, which needs enough context to debug quickly
The real cause might be:
- insufficient token allowance
- insufficient balance
- slippage threshold not met
- account validation failure
- paymaster rejection
- nonce mismatch
- gas or RPC/provider behavior
In many ERC-4337 stacks, these all collapse into one vague failure because the raw backend error is too low-level to expose directly.
This post is about the production layer that fixes that.
ERC-4337 context, only what we need
At a high level:
- users submit
UserOperationobjects, not normal L1/L2 transactions - a bundler receives these operations
- the bundler simulates first, then decides whether to submit
Why simulate first? Because submitting obviously invalid operations wastes gas, burns time, and creates noisy failure states.
So simulation is not a nice-to-have. It is the first reliability gate.
Why simulation failures are messy in real systems
In a clean toy environment, a simulation failure can look straightforward. In production, it usually is not.
What I see in real traffic:
- provider clients wrap errors inside multiple nested objects
- useful payloads are often raw ABI-encoded revert bytes
- known ABI coverage is never complete
- sometimes I only get a selector and no friendly signature
- sometimes I only get string fragments, including
AAxxstyle messages - different RPC providers shape the same underlying failure differently
So "just decode the revert" is not a production strategy. It is one step in a chain.
The decode pipeline that holds up in production
I use a staged pipeline so decode quality can vary without breaking the SDK contract:
txtraw provider error-> extract revert bytes-> decode known ABI errors-> fallback selector lookup-> fallback message-pattern classification-> map to typed SDK error code
1) Extract revert data from wrappers
First, walk nested provider errors and extract whatever canonical signal exists:
- raw revert bytes
- selector
- provider error summary
- traversal trail for debugging
Without this step, downstream decode logic never sees the real payload.
2) Decode known ABI errors
Try deterministic ABI decode first for errors I expect to be common, like:
- allowance failures
- slippage-style failures
- known
FailedOppatterns
When this works, the mapping is clean and confidence is high.
3) Fallback on selector signatures
If ABI decode misses, look up the selector signature and cache the result.
This catches cases where payload shape is valid but local ABI coverage is incomplete.
4) Fallback on message patterns
If structured decode still fails, classify based on resilient patterns:
- allowance-related message variants
- balance-related variants
- slippage variants
AA20/AA23/AA25style patterns
This is less precise, but still better than returning "unknown" for everything.
5) Emit typed errors with stable codes
No matter which decode stage succeeds, the output to SDKs stays stable:
tsif (!decodedSignal) {return {code: "UNKNOWN_SIMULATION_ERROR",reason: fallbackMessage ?? "simulation failed",};}return mapDecodedSignalToStableCode(decodedSignal);
The internal decode strategy can evolve. The external API contract should not.
Stable SDK codes are the product boundary
This is the core point: stable execution codes are not log decoration. They are the product boundary between bundler infrastructure and every downstream consumer.
I treat these codes as contract-level outputs, for example:
INSUFFICIENT_ALLOWANCEINSUFFICIENT_BALANCESLIPPAGE_EXCEEDS_THRESHOLDAA_VALIDATION_ERRORNONCE_TOO_LOWUNKNOWN_SIMULATION_ERROR
Why this matters:
- SDKs can drive deterministic UI copy
- clients can choose retry vs no-retry safely
- analytics can track real failure classes over time
- support can triage without digging through raw RPC payloads
If the code surface is unstable, every integration becomes fragile.
Keep simulation classification separate from submission recovery
These are related, but they are not the same problem.
Simulation classification answers: "Is this operation valid enough to submit?"
Submission recovery answers: "Submission failed, now what?"
I keep the policies separate:
- do not retry deterministic user-state failures like allowance/slippage
- do retry selected operational failures like transient RPC timeouts
- treat nonce errors as a hint-driven retry path, not blind increment
- quarantine unhealthy bundler wallets and retry with guardrails
- enforce strict retry depth and timeout limits
A small example of nonce hint extraction:
tsfunction parseNextNonceHint(message: string): number | undefined {const m = message.match(/next nonce\s+(\d+)/i);if (!m) return undefined;const n = Number(m[1]);return Number.isFinite(n) ? n : undefined;}
That is a reliability control, not a convenience feature.
What production adds beyond spec compliance
Spec alignment is required. It is not enough.
A spec-compliant bundler can still produce bad operational outcomes if it lacks:
- robust provider-wrapper extraction
- decode fallback layering
- stable SDK-facing taxonomy
- explicit retry and quarantine policy
- structured observability with safe debug context
In other words, passing interface-level checks does not automatically make failures legible to real users and real SDKs.
Testing this layer properly
I want deterministic coverage for both classification and recovery behavior. The minimum serious set looks like:
- allowance failure maps to
INSUFFICIENT_ALLOWANCE - balance failure maps to
INSUFFICIENT_BALANCE - slippage failure maps to
SLIPPAGE_EXCEEDS_THRESHOLD - AA validation failures map consistently
- unknown selectors still map to stable unknown categories
- nonce hints parse correctly and guide retry behavior
- wallet quarantine exits cleanly and does not loop forever
- retry paths stop at hard limits
Without these tests, reliability regressions show up in production first.
Main takeaway
A bundler is not just a relay.
In production, it is a reliability layer that translates low-level execution failures into stable, actionable outputs for SDKs, wallets, UIs, support, and operators.
Calling simulation correctly is table stakes.
Turning messy simulation and submission failures into stable execution codes is the hard part.
That translation layer is core transaction infrastructure.