Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/content/developers/evm/simplex/api/wallet.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,33 @@ When the wallet is short of an ERC-20 and one of your vaults holds it, the short

A send that cannot go through answers `400` with the reason, such as an insufficient balance. A send that goes through is recorded in the wallet history.

### A pending send

A send that is not confirmed yet answers `202`, not `400`, because it may still land. Do not send it again until you have checked. For a sponsored send, check the UserOperation by its hash, not the wallet history: a pending sponsored send has no history row.

```json lineNumbers
{
"pending": true,
"sponsored": true,
"userOpHash": "0x…",
"message": "The send was submitted to the bundler as UserOp 0x… but is not confirmed yet. It may still land; wait a few minutes, then look that UserOp up on the bundler or check the wallet's and the recipient's balance before resending it."
}
```

| Field | Description |
|---|---|
| `pending` | Always `true`. |
| `sponsored` | `true` when the send is a paymaster-sponsored UserOperation, `false` for a plain transaction. |
| `userOpHash` | The UserOperation's hash, for a sponsored send that was submitted. A UserOperation has no transaction hash until it lands, so a sponsored send that answers `202` gets no wallet-history row, and none is added when it lands later. Ask the bundler for it with `eth_getUserOperationByHash`, or compare the wallet's and the recipient's balances once it has had time to land. |
| `txHash` | The transaction's hash. Present only when `sponsored` is `false`. Look it up on the block explorer. A pending plain transaction is recorded in the wallet history like a confirmed one. |
| `message` | What to do next. |

A `202` takes one of three forms:

- **Submitted, no receipt yet.** The bundler accepted the UserOperation, or the node accepted the transaction, but no receipt came within the wait. It may land at any time. A wallet balance cannot show a UserOperation that is still in the bundler's pool, so wait a few minutes, then look the UserOperation up by `userOpHash` or compare balances before sending again.
- **Possibly submitted.** The bundler's answer to the send was lost and none of the lookups after it found the UserOperation, and at least one did not answer at all. The send *may have been submitted*, and the message says so. This is what an outage of the bundler looks like. The message names the UserOperation's hash, which is also `userOpHash`.
- **Earlier operation still pending.** The bundler refused the send because an earlier sponsored operation from this wallet (a send, a vault sweep or redeem, or a delegation setup) is still waiting on the same nonce (it answers "replacement underpriced" or "already known"). Nothing new was submitted, so the response has no hash, and its message is "An earlier sponsored operation from this wallet is still pending; wait for it to land before sending again." The send is never repeated as a plain transaction, because the earlier one could still land and the same tokens would leave twice. A bootstrap operation that approves Permit2 for the wallet has no expiry, so if the bundler keeps it in its pool, every sponsored send is refused this way until the bundler drops it.

## PUT /api/vault

Replaces the vault treasury. The body is the whole list of vaults, in the same shape as `[[vault.vaults]]` in the config. An empty list turns the treasury off.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# 2026-10-16 — A lost bundler send response is resolved before the caller may resend natively

`eth_sendUserOperation` can fail after the bundler accepted the op: a gateway 504, an HTML error
page, a dropped socket, or the request timeout. `UserOpSender` used to treat any such failure as
"never submitted" and return `null`, which every caller reads as permission to send the same calls
again as a native transaction — paying twice for one dashboard send, or double-executing a top-up
placement or a vault batch.

## Every bundler request is classified

`UserOpSender` funnels its bundler calls (send, estimate, both receipt polls, the by-hash lookup)
through one helper. Each request is bounded — 30 s for the send and estimate, which run the
validation simulation, and 10 s for the polls — and a call that returns no result throws a
`BundlerCallError` whose `outcome` is one of:

- `rejected` — an HTTP 200 whose JSON-RPC `error.code` is a deterministic rejection (`-32700`,
`-32600`…`-32602`, or `-32599`…`-32500`, the ERC-4337/ERC-7769 range which includes `-32521`).
A non-200 status with any body, `-32603`, a missing or non-numeric code, or any other code is
*not* a definite refusal.
- `unreachable` — `fetch` failed in the connect phase, so no request bytes were delivered:
`ECONNREFUSED`, `ENOTFOUND`, `EAI_AGAIN`, `EHOSTUNREACH`, `ENETUNREACH`,
`UND_ERR_CONNECT_TIMEOUT`, or a TLS certificate-verification failure.
- `pending` — see the next section.
- `ambiguous` — everything else.

The gas-price lookup is not one of these calls: it goes through the SDK's
`rundlerUserOperationFees`, whose `fetch` has no timeout, so `UserOpSender` bounds the whole lookup
at 10 s. It runs before anything is signed, so a timeout there is "never submitted".

Only `rejected` and `unreachable` count as "not submitted". The rule is deliberate: a proxy's JSON
502/504 forwarded the request, and rundler's -32603 can arrive after the pool admitted the op, so
those stay ambiguous; misclassifying an ambiguous failure as decided costs at most one lookup,
while the reverse costs a double send.

## An op already pending on the nonce is not a rejection

Rundler answers a send with `-32602` (the code of a malformed request) when an op from the same
sender is already pending on that EntryPoint nonce: `ReplacementUnderpriced` carries
`currentMaxPriorityFee`/`currentMaxFee` in its error data, and `OperationAlreadyKnown` has the
message "already known". `UserOpSender` reads either as `pending` and throws
`PendingOpOnNonceError` instead of returning `null`. A plain `-32602` (invalid params) is still a
rejection. When the error data has `currentMaxFee`/`currentMaxPriorityFee`, they are on the error
as `blockingFees`, in its message and in the log, so an operator can see what is in the way.

Detection recognises rundler's wording only, because production runs rundler. Alto's "already
present in mempool" would read as a rejection, so another bundler needs its wording added.

A PERMIT bootstrap op has no `validUntil`. If it sits in rundler's pool (`max_time_in_pool` is
unset by default), every key-0 sponsored op gets `PendingOpOnNonceError` until rundler drops it.

Without this, a send answered `202` and then repeated would be refused as a replacement, read as
`null`, and sent natively — paid twice. `PendingOpOnNonceError` means "not now": `TokenSender`
lets it through, `POST /api/send` reports it, the vault sweep and redeem leave it for the next
cycle, and delegation setup returns `false` without a native delegation tx. A top-up placement
that meets it fails with nothing escrowed.

## The op is looked up before falling back

`trySendSponsored` computes the op's hash locally before sending — the v0.8 EIP-712 digest of the
packed op (`hashTypedData(packedUserOpTypedData(...))`) is its `userOpHash` — and on an ambiguous
send failure calls `eth_getUserOperationByHash` roughly 2 s, 7 s and 17 s after the send (the
default inter-attempt delays are 2 s, 5 s and 10 s):

- **found** — proceed as a successful send from the local hash: wait for the receipt, and throw
`SubmittedUnconfirmedError` on none.
- **absent** — the bundler answered `null` on every attempt, so the op never entered its mempool.
Return `null`; the caller may fall back. A residual race remains: a bundler still validating the
op after the timeout could add it after the last lookup, which is what the widening delays narrow.
- **unknown** — a lookup failed, so absence is not proven. Throw `SubmittedUnconfirmedError` with
the local hash and `maybeSubmitted = true`; the caller must not fall back.

A successful send whose returned hash differs from the local one is logged, and the bundler's is
used.

The local hash matches what the EntryPoint computes for a v0.9 op as well as v0.8: v0.9 leaves a
trailing paymaster signature out of the hash, and simplex's `paymasterAndData` carries no such
suffix.

## A bundler outage

When no lookup finds the op and at least one fails, the send is `unknown`. `POST /api/send` answers
`202` saying the send *may have been submitted*. A top-up placement is recorded with the local hash and waits out its window
before it settles. A vault sweep or redeem throws, and the batch stalls, rather than going native,
until the bundler answers again.

## An unconfirmed send is reported as pending, not failed

`POST /api/send` answers a `SubmittedUnconfirmedError` or a `PendingOpOnNonceError` with HTTP 202
`{ pending: true, sponsored, userOpHash?, txHash?, message }` instead of 400. Every other error
stays a 400.

- A native tx has a hash from the moment the node accepts it, so it gets a wallet-history row like a
confirmed send. A UserOp has none until it lands (`WalletTx.txHash` is required), so it gets no row.
- A sponsored UserOp's message names the UserOp hash and says it may still land: wait, then look the
hash up on the bundler or check the wallet's and the recipient's balance before resending. The
wallet history is no evidence here, since a sponsored send that answers `202` never gets a row, and
the balance cannot show an op still in the bundler's pool. When the outcome is `unknown` it says the
send *may have been submitted*. A native tx keeps the explorer wording and link.
- A `PendingOpOnNonceError` answers "an earlier sponsored operation from this wallet is still pending;
wait for it to land before sending again" with no hash, since nothing new was submitted. The
message does not say "send": key-0 nonces are shared by sends, vault sweeps and redeems, and
delegation ops, so the op in the way need not be a send.

The operator UI renders the pending response in a warning tone, clears the amount so the next click
cannot resend, and shows no "Sent" success line. A UserOp hash is shown as plain text, a native tx
hash as an explorer link.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Entry points: `UserOpSender.trySendSponsored` (delegation, vault sweeps/redeems,

Step 1's deposit gate prices against `VERIFICATION_GAS_LIMIT_PERMIT` (250k) when `permitBootstrap` is set and `VERIFICATION_GAS_LIMIT_PERMIT2` (200k) otherwise, since the bootstrap op does not know which mode it will land in until the allowance is read.

3. Back in `UserOpSender.trySendSponsored`, the bundler gas price is fetched *before* paymaster selection so the deposit gate can price the op's max prefund (from the caller's fixed limits, or the generous fallbacks when estimating later). `getGasPrice` uses Pimlico's and Alchemy's own pricing; for any other bundler it bumps the chain gas price and, when the bundler answers `rundler_maxPriorityFeePerGas`, raises both fees to that floor through the SDK's `applyRundlerPriorityFee`, the same rule `GasEstimator` applies to bids. The EIP-7702 authorization thunk is still resolved only after paymaster data is built, because the bootstrap approve is a tx from the same EOA and an authorization signed earlier would carry a stale nonce (bundler reject). Then `EntryPoint.getNonce`, estimation (or the caller's fixed limits), signing of the packed userOp typed data, `eth_sendUserOperation`, and receipt polling.
3. Back in `UserOpSender.trySendSponsored`, the bundler gas price is fetched *before* paymaster selection so the deposit gate can price the op's max prefund (from the caller's fixed limits, or the generous fallbacks when estimating later). `getGasPrice` prices the op from the bundler's own rundler suggested fees, each raised by the bump percentages; where the bundler suggests none, it raises the chain gas price to the `rundler_maxPriorityFeePerGas` floor through the SDK's `applyRundlerPriorityFee`, the same rule `GasEstimator` applies to bids. The EIP-7702 authorization thunk is still resolved only after paymaster data is built, because the bootstrap approve is a tx from the same EOA and an authorization signed earlier would carry a stale nonce (bundler reject). Then `EntryPoint.getNonce`, estimation (or the caller's fixed limits), signing of the packed userOp typed data, `eth_sendUserOperation`, and receipt polling. The send and estimate calls allow 30 s, the receipt and by-hash polls 10 s, and the gas-price call 10 s (it runs before anything is signed, so a timeout there is "never submitted"). A failed bundler call is classified: only an HTTP 200 carrying a deterministic rejection code, or a connect-phase failure, counts as "not submitted". A `-32602` that says an op is already pending on the nonce ("replacement underpriced", "already known") throws `PendingOpOnNonceError` instead, and every other failure, a lost response included, is ambiguous and first asks `eth_getUserOperationByHash` whether the op was actually accepted. Only a definite `null` from every attempt lets the caller fall back to native (see `docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md`).

4. On chain (`evm/src/utils/SimplexPaymaster.sol`): `_validatePaymasterUserOp` first reverts `UnauthorizedBundler(origin)` when the governance bundler list is non-empty and `tx.origin` is not on it (an empty list skips the check without reading ORIGIN; see `docs/ai/changelog/2026-10-05-simplex-paymaster-entrypoint-v09-and-bundler-allowlist.md`), then executes the permit for mode 0 after checking the token is registered and active; `_fetchDetails` accepts mode bytes `0x00` and `0x02` (`InvalidMode` otherwise, so a mode `0x01` payload with a standing allowance behind it is refused before any transfer) and, for mode 2, returns the permit deadline as `validUntil`; `_prefund` pulls mode 0 through the base `transferFrom` on the permit's allowance and mode 2 through `Permit2.permitTransferFrom` (owner = `userOp.sender`, to = paymaster, requestedAmount = oracle-derived prefund, capped by the signed amount); postOp refunds the unused part to the sender.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,9 @@ export class VaultFundingPlanner implements FundingVenue {
*
* The sponsored path only falls back to native when the op was **never
* submitted**; a submitted-but-unconfirmed op throws so a native resend can't
* double-execute the batch (the caller's timer logs and retries next cycle).
* double-execute the batch (the caller's timer logs and retries next cycle). So does a
* `PendingOpOnNonceError`: an earlier op from this wallet still holds the nonce, which is
* "not now", never a reason to send natively.
*/
private async submitBatch(
chain: string,
Expand Down
15 changes: 14 additions & 1 deletion sdk/packages/simplex/src/services/DelegationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Signer } from "./wallet"
import { hasPaymaster } from "./paymaster"
import { resolvePendingPermit2Approval } from "./paymaster/provider/simplex"
import { UserOpSender } from "./UserOpSender"
import { PendingOpOnNonceError } from "./submission"

/** EIP-7702 delegation indicator prefix */
const DELEGATION_INDICATOR_PREFIX = "0xef0100"
Expand Down Expand Up @@ -316,6 +317,9 @@ export class DelegationService {
this.logger.warn({ chain }, "Sponsored delegation unavailable, falling back to direct tx")
return false
} catch (error) {
// An earlier op from this wallet holds the nonce and may still land; it is not an
// answer about this one, so the on-chain check below would only send us native.
if (error instanceof PendingOpOnNonceError) throw error
// The op may have been submitted but not yet confirmed — check on-chain status
// rather than blindly re-submitting via a direct tx.
this.logger.warn({ chain, error }, "Bundler delegation did not confirm, checking on-chain status")
Expand Down Expand Up @@ -455,7 +459,16 @@ export class DelegationService {
}

if (hasPaymaster(chain, this.configService)) {
const success = await this.setupDelegationViaBundler(chain, pendingApproval)
let success: boolean
try {
success = await this.setupDelegationViaBundler(chain, pendingApproval)
} catch (error) {
if (!(error instanceof PendingOpOnNonceError)) throw error
// Not now: the pending op may delegate the account, and a direct delegation tx
// would race it. The next attempt finds it landed or gone.
this.logger.warn({ chain, error }, "A sponsored op is still pending for this wallet; delegation will be retried")
return false
}
if (success) return true
this.logger.info({ chain }, "Falling back to direct delegation tx")
}
Expand Down
4 changes: 4 additions & 0 deletions sdk/packages/simplex/src/services/TokenSender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,10 @@ export class TokenSender {
* `nonceKey` puts the sponsored op on its own EntryPoint nonce lane (default 0). Two ops on one
* key read the same `getNonce`, so a caller that can run alongside another key-0 sender passes
* its own. It has no effect on the native fallback.
*
* A `PendingOpOnNonceError` from the sponsored path is not caught: an earlier op from
* this wallet is still pending on the nonce and may land, so a native send of the same calls
* could pay twice. It reaches the caller, which waits and retries.
*/
async submitCalls(
chain: string,
Expand Down
Loading
Loading