From 7e313b44ccaaa8ce5db0fb599d8416de421fd019 Mon Sep 17 00:00:00 2001 From: Seun Lanlege Date: Fri, 16 Oct 2026 09:21:19 +0000 Subject: [PATCH 1/4] [simplex]: never resend natively after an ambiguous bundler send, report unconfirmed operator sends as pending --- .../2026-10-16-ambiguous-bundler-sends.md | 57 ++++ ...master-selection-for-a-sponsored-userop.md | 2 +- .../simplex/src/services/UserOpSender.ts | 316 ++++++++++++++++-- .../simplex/src/services/server/UiServer.ts | 23 ++ .../src/tests/services/UserOpSender.test.ts | 273 ++++++++++++++- .../simplex/src/tests/token-sender.test.ts | 154 +++++++++ .../simplex/src/tests/ui-server.test.ts | 66 ++++ .../ui/src/operator/WalletTools.test.tsx | 141 ++++++++ .../simplex/ui/src/operator/WalletTools.tsx | 79 +++-- 9 files changed, 1053 insertions(+), 58 deletions(-) create mode 100644 sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md create mode 100644 sdk/packages/simplex/ui/src/operator/WalletTools.test.tsx diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md b/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md new file mode 100644 index 0000000000..7797b2d3dc --- /dev/null +++ b/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md @@ -0,0 +1,57 @@ +# 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. (`getGasPrice` is not one of them: it goes through the SDK's +`rundlerUserOperationFees`, which fetches the bundler's suggested fees itself.) 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. +- `ambiguous` — everything else. + +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. + +## 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; 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. + +## An unconfirmed send is reported as pending, not failed + +`POST /api/send` answers a `SubmittedUnconfirmedError` with HTTP 202 +`{ pending: true, sponsored, userOpHash?, txHash?, message }` instead of 400, and writes no wallet +history row — a `WalletTx.txHash` is required, and an unconfirmed UserOp has none. Every other error +stays a 400. 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 sponsored UserOp has no tx hash, +so its message points at the wallet balance or history and its hash is shown as plain text, while a +native tx keeps the explorer wording and link. diff --git a/sdk/packages/simplex/docs/ai/flows/paymaster-selection-for-a-sponsored-userop.md b/sdk/packages/simplex/docs/ai/flows/paymaster-selection-for-a-sponsored-userop.md index 8082380619..6588d3e74a 100644 --- a/sdk/packages/simplex/docs/ai/flows/paymaster-selection-for-a-sponsored-userop.md +++ b/sdk/packages/simplex/docs/ai/flows/paymaster-selection-for-a-sponsored-userop.md @@ -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. Every bundler call is bounded by a 10 s abort timeout and classified on failure: a JSON-RPC error or a connect-phase failure is "not submitted", while a lost response 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. diff --git a/sdk/packages/simplex/src/services/UserOpSender.ts b/sdk/packages/simplex/src/services/UserOpSender.ts index 5651b5110f..692690b9d9 100644 --- a/sdk/packages/simplex/src/services/UserOpSender.ts +++ b/sdk/packages/simplex/src/services/UserOpSender.ts @@ -5,7 +5,7 @@ import { type PackedUserOperation, type HexString, } from "@hyperbridge/sdk" -import { toHex, type PublicClient } from "viem" +import { hashTypedData, toHex, type PublicClient } from "viem" import { ENTRYPOINT_ABI } from "@/config/abis/Entrypoint" import type { ChainClientManager } from "./ChainClientManager" import type { FillerConfigService } from "./FillerConfigService" @@ -74,6 +74,91 @@ const FALLBACK_PRE_VERIFICATION_GAS = 150_000n const PRIORITY_FEE_BUMP_PERCENT = 8n const MAX_FEE_BUMP_PERCENT = 10n +// Bounds the cheap bundler reads — the receipt and by-hash polls. A gateway that accepts the +// connection but never answers would otherwise pin the send path indefinitely; the receipt wait +// is time-based, so each poll must finish well within it. +const BUNDLER_POLL_TIMEOUT_MS = 10_000 + +// The send and estimate calls run the whole validation simulation, so they get a longer bound: a +// slow-but-healthy send must not be aborted into the ambiguous path (which forces a lookup and +// may read as unconfirmed), nor an estimate into the ~1.9M-gas fallback limits. +const BUNDLER_SIMULATION_TIMEOUT_MS = 30_000 + +// A receipt is polled every `RECEIPT_POLL_INTERVAL_MS` until this wall-clock deadline. Time-based +// rather than a poll count so the per-poll timeout cannot stretch the wait (a 30-poll count with a +// 10 s poll timeout would run to six minutes). +const RECEIPT_WAIT_DEADLINE_MS = 90_000 +const RECEIPT_POLL_INTERVAL_MS = 2_000 + +// No `BundlerMethod` entry for this one — it is not a method the send/estimate paths use, only +// the post-failure lookup below. +const ETH_GET_USER_OPERATION_BY_HASH = "eth_getUserOperationByHash" + +/** + * Node/undici codes for a failure in the connect phase — before any request bytes reached the + * bundler, so nothing can have been submitted. Anything else (a reset mid-request, an abort, a + * truncated/non-JSON body) leaves the op's fate unknown and is treated as ambiguous: guessing + * "not submitted" there would resend the calls natively and execute them twice. + */ +const UNREACHABLE_CODES = new Set([ + "ECONNREFUSED", + "ENOTFOUND", + "EAI_AGAIN", + "EHOSTUNREACH", + "ENETUNREACH", + // undici's own connect-phase timeout; distinct from an earlier-established socket stalling. + "UND_ERR_CONNECT_TIMEOUT", + // TLS certificate verification failures (OpenSSL X509 codes, plus Node's hostname-mismatch). + "DEPTH_ZERO_SELF_SIGNED_CERT", + "SELF_SIGNED_CERT_IN_CHAIN", + "UNABLE_TO_GET_ISSUER_CERT", + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "CERT_HAS_EXPIRED", + "CERT_UNTRUSTED", + "ERR_TLS_CERT_ALTNAME_INVALID", +]) + +type BundlerCallOutcome = "rejected" | "unreachable" | "ambiguous" + +/** + * A bundler JSON-RPC call that returned no result. `outcome` records how definite the failure + * was, which the send path must not flatten: a `rejected` or `unreachable` call provably did not + * enter the op, while an `ambiguous` one may have been accepted with only the response lost. + */ +class BundlerCallError extends Error { + constructor( + message: string, + readonly outcome: BundlerCallOutcome, + readonly reason?: unknown, + ) { + super(message) + this.name = "BundlerCallError" + } +} + +function connectFailureCode(error: unknown): string | undefined { + const cause = (error as { cause?: { code?: unknown } } | undefined)?.cause + return typeof cause?.code === "string" ? cause.code : undefined +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** + * Whether a JSON-RPC code is a deterministic rejection of the request: a parse/request error, or + * the ERC-4337 (ERC-7769) range the bundler uses for a definite refusal (which includes -32521, + * the validation/inclusion rejection). Only these codes let the send path fall back natively. + */ +function isDeterministicRejectionCode(code: unknown): boolean { + if (typeof code !== "number") return false + // Parse error (-32700) and invalid request/params (-32600…-32602). + if (code === -32700 || (code >= -32602 && code <= -32600)) return true + // The ERC-4337 rejection range, -32599…-32500. + return code >= -32599 && code <= -32500 +} + /** * Submits self-initiated, paymaster-sponsored UserOperations through the bundler, * so the solver pays gas in stablecoins rather than native token. Shared by the @@ -82,13 +167,19 @@ const MAX_FEE_BUMP_PERCENT = 10n * Submission contract (so callers can safely fall back to a native tx without * risking a double-execution): * - Returns `null` when the op was **never submitted** — paymaster/bundler not - * configured, insufficient USDC, preparation failed (approve tx or signing), or - * the bundler rejected `eth_sendUserOperation` outright (it never entered the - * mempool). The caller may fall back to native. + * configured, insufficient USDC, preparation failed (approve tx or signing), the + * bundler rejected `eth_sendUserOperation` outright, the connection could not be + * established, or a post-failure lookup proved the op never entered the pool. The + * caller may fall back to native. + * `null` means *this* op never entered the pool, not that nothing is pending for + * its EntryPoint nonce: a retry builds a new op on the same nonce, and the bundler's + * "replacement underpriced"/"already known" answer for it rejects the new op only. * - Returns `{ txHash, userOp }` once a receipt is observed. - * - **Throws** {@link SubmittedUnconfirmedError} when the op **was submitted** but no receipt - * arrived. The caller must NOT fall back (the op may still land) — retry on the next cycle, - * and resolve the op's fate later with {@link lookupUserOpReceipt}. + * - **Throws** {@link SubmittedUnconfirmedError} when the op **was submitted** but no + * receipt arrived, or when its submission could not be determined at all (the send + * response was lost and the lookup could not ask). The caller must NOT fall back (the + * op may still land) — retry on the next cycle, and resolve the op's fate later with + * {@link lookupUserOpReceipt}. */ export class UserOpSender { private logger: Logger @@ -233,19 +324,80 @@ export class UserOpSender { const bundlerUserOp = CryptoUtils.prepareBundlerCall(userOp) if (formattedAuth) bundlerUserOp.eip7702Auth = formattedAuth + // The v0.8 EIP-712 digest of this typed data *is* the userOpHash — the exact payload + // `buildSignedUserOp` signs — so we can know the hash the bundler should return without + // asking it, which matters when the send response is lost. + const localUserOpHash = hashTypedData( + CryptoUtils.packedUserOpTypedData(userOp, entryPoint, BigInt(chainId)), + ) as HexString + + // Timestamped at the attempt: a lost response has no time of its own to record. + const submittedAt = Date.now() + let userOpHash: HexString try { - userOpHash = await this.sendBundlerRpc(bundlerUrl, BundlerMethod.ETH_SEND_USER_OPERATION, [ - bundlerUserOp, - entryPoint, - ]) + const sent = await this.sendBundlerRpc( + bundlerUrl, + BundlerMethod.ETH_SEND_USER_OPERATION, + [bundlerUserOp, entryPoint], + BUNDLER_SIMULATION_TIMEOUT_MS, + ) + // A valid answer is the op's hash. Anything else (a null `result` from a malformed + // proxy response) is not an answer, so it falls into the ambiguous branch below. + if (typeof sent !== "string") { + throw new BundlerCallError( + `Bundler returned no UserOp hash (${BundlerMethod.ETH_SEND_USER_OPERATION})`, + "ambiguous", + ) + } + if (sent.toLowerCase() !== localUserOpHash.toLowerCase()) { + this.logger.warn( + { chain, expected: localUserOpHash, received: sent }, + "Bundler returned a different UserOp hash than the signed op's; using the bundler's", + ) + } + userOpHash = sent } catch (error) { - // Rejected before entering the mempool — never submitted, safe to fall back. - this.logger.warn({ chain, error }, "Bundler rejected UserOp; caller should fall back to native") - return null + const outcome = error instanceof BundlerCallError ? error.outcome : "ambiguous" + if (outcome !== "ambiguous") { + // The bundler answered and refused the op, or the connection never reached it — + // either way nothing entered the pool, safe to fall back. + this.logger.warn( + { chain, outcome, error }, + `Bundler did not accept UserOp (${outcome}); caller should fall back to native`, + ) + return null + } + + // The request may have been accepted and only its response lost. Ask the bundler + // whether it holds the op before deciding; falling back here is what would + // double-execute the calls. + const found = await this.findSubmittedOp(bundlerUrl, localUserOpHash) + if (found === "absent") { + // We did ask, and it does not hold the op. A residual race remains: a bundler + // still validating the op when our request timed out could add it after our + // last lookup, so the lookup delays exist to make that window small. + this.logger.warn( + { chain, userOpHash: localUserOpHash }, + "Bundler's send response was lost and it does not hold the UserOp; caller should fall back to native", + ) + return null + } + if (found === "unknown") { + // Every lookup failed too, so we cannot tell whether the op was accepted. The + // caller must not fall back — a resend could execute the calls twice. + throw new SubmittedUnconfirmedError( + `Sponsored UserOp ${localUserOpHash}: the bundler's answer was lost and the op could not be looked up`, + chain, + { kind: "userOp", userOpHash: localUserOpHash, nonce, nonceKey, expires, submittedAt }, + ) + } + // The bundler holds the op: proceed exactly as a successful send, from the locally + // computed hash. + this.logger.info({ chain, userOpHash: localUserOpHash }, "Bundler accepted UserOp after a lost response") + userOpHash = localUserOpHash } - const submittedAt = Date.now() this.logger.info({ chain, userOpHash }, "Sponsored UserOp submitted to bundler") const receipt = await this.waitForUserOpReceipt(bundlerUrl, userOpHash) @@ -337,7 +489,12 @@ export class UserOpSender { callGasLimit: string verificationGasLimit: string preVerificationGas: string - }>(p.bundlerUrl, BundlerMethod.ETH_ESTIMATE_USER_OPERATION_GAS, [bundlerUserOp, p.entryPoint]) + }>( + p.bundlerUrl, + BundlerMethod.ETH_ESTIMATE_USER_OPERATION_GAS, + [bundlerUserOp, p.entryPoint], + BUNDLER_SIMULATION_TIMEOUT_MS, + ) return { // Bias the execution limit up — sweep/redeem batches vary in size. @@ -416,26 +573,125 @@ export class UserOpSender { ) } - private async sendBundlerRpc(bundlerUrl: string, method: string, params: unknown[]): Promise { - const response = await fetch(bundlerUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), - }) - const result = (await response.json()) as { result?: T; error?: { message?: string } } - if (result.error) { - throw new Error(`Bundler RPC error (${method}): ${result.error.message || JSON.stringify(result.error)}`) + /** + * One bundler JSON-RPC call, used by every request in this class. `timeoutMs` bounds the + * request; a failure is thrown as a {@link BundlerCallError} whose `outcome` says whether it + * is safe to treat the call as never having reached the bundler — the distinction the send + * path relies on to avoid a native resend of an op that may already be submitted. + */ + private async sendBundlerRpc( + bundlerUrl: string, + method: string, + params: unknown[], + timeoutMs = BUNDLER_POLL_TIMEOUT_MS, + ): Promise { + let response: Response + try { + response = await fetch(bundlerUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + signal: AbortSignal.timeout(timeoutMs), + }) + } catch (error) { + // `fetch` rejects only when the request never completed. A connect-phase failure (no + // bytes sent) is unreachable; the abort timeout, a socket reset mid-flight or a TLS + // failure after the handshake is ambiguous — the op may have been accepted and only + // the response lost. + const code = connectFailureCode(error) + const outcome: BundlerCallOutcome = code && UNREACHABLE_CODES.has(code) ? "unreachable" : "ambiguous" + throw new BundlerCallError( + `Bundler request failed (${method})${code ? ` [${code}]` : ""}: ${describeError(error)}`, + outcome, + error, + ) + } + + let body: { result?: T; error?: unknown } + try { + body = (await response.json()) as { result?: T; error?: unknown } + } catch (error) { + // A non-JSON body (an HTML proxy error page, a truncated stream) says nothing about + // whether the op was accepted. + throw new BundlerCallError(`Bundler returned a non-JSON response (${method})`, "ambiguous", error) + } + + const rpcError = body?.error + if (rpcError !== undefined && rpcError !== null) { + if (typeof rpcError === "object") { + const message = (rpcError as { message?: string }).message ?? JSON.stringify(rpcError) + // Only an HTTP 200 carrying a deterministic rejection code is a definite refusal. + // A proxy's JSON 502/504 body carries an object error too, but it forwarded the + // request; rundler's -32603 "internal error" can also come after the pool admitted + // the op. Both are ambiguous — reading them as rejected would resend natively. A + // missing or non-numeric code, or any code outside the parse/rejection ranges, is + // likewise ambiguous: when in doubt, keep the op. + const rejected = + response.status === 200 && isDeterministicRejectionCode((rpcError as { code?: unknown }).code) + throw new BundlerCallError( + `Bundler RPC error (${method}): ${message}`, + rejected ? "rejected" : "ambiguous", + ) + } + // A non-object `error` (a proxy's plain `{"error":"Bad Gateway"}`) is not a JSON-RPC + // answer, so it stays ambiguous. + throw new BundlerCallError( + `Bundler returned a malformed error (${method}): ${JSON.stringify(rpcError)}`, + "ambiguous", + ) } - return result.result as T + // A `null` result is a real answer (e.g. "this op is unknown"); only a missing `result` + // key, or an HTTP error with no JSON-RPC body, is ambiguous. + if (body?.result !== undefined) return body.result + throw new BundlerCallError(`Bundler returned no result (${method}): HTTP ${response.status}`, "ambiguous") } + /** + * After an ambiguous `eth_sendUserOperation`, asks the bundler whether it actually holds the + * op. Rundler's `eth_getUserOperationByHash` checks its mempool and then the chain, so a + * non-null result means the op was accepted; a definite `null` on every attempt means it + * never entered the pool. + * + * `lookupDelaysMs` are inter-attempt waits, so the default `[2_000, 5_000, 10_000]` looks up + * at about 2 s, 7 s and 17 s after the failed send. + * + * Returns `"unknown"` when any attempt failed, because that is not proof of absence: a + * bundler still validating the op after our request timed out could add it after the last + * attempt, and the widening delays exist to shrink that window. Only `"absent"` — a `null` + * from every attempt — lets the caller fall back. + */ + private async findSubmittedOp( + bundlerUrl: string, + userOpHash: HexString, + lookupDelaysMs: readonly number[] = [2_000, 5_000, 10_000], + ): Promise<"found" | "absent" | "unknown"> { + let everyAttemptAnswered = true + for (const delayMs of lookupDelaysMs) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + try { + const op = await this.sendBundlerRpc(bundlerUrl, ETH_GET_USER_OPERATION_BY_HASH, [userOpHash]) + if (op) return "found" + } catch { + // No definite answer this time; the next attempt may reach the bundler. + everyAttemptAnswered = false + } + } + return everyAttemptAnswered ? "absent" : "unknown" + } + + /** + * Polls for the op's receipt until `deadlineMs` has elapsed, 2 s apart. Time-based so the + * wait cannot stretch with the per-poll timeout; `deadlineMs` and `intervalMs` stay + * overridable for tests. + */ private async waitForUserOpReceipt( bundlerUrl: string, userOpHash: HexString, - maxAttempts = 30, - intervalMs = 2000, + deadlineMs = RECEIPT_WAIT_DEADLINE_MS, + intervalMs = RECEIPT_POLL_INTERVAL_MS, ): Promise<{ success?: boolean; receipt?: { transactionHash: HexString } } | null> { - for (let i = 0; i < maxAttempts; i++) { + const deadline = Date.now() + deadlineMs + for (;;) { try { const receipt = await this.sendBundlerRpc<{ success?: boolean @@ -445,8 +701,8 @@ export class UserOpSender { } catch { // Not indexed yet. } + if (Date.now() >= deadline) return null await new Promise((resolve) => setTimeout(resolve, intervalMs)) } - return null } } diff --git a/sdk/packages/simplex/src/services/server/UiServer.ts b/sdk/packages/simplex/src/services/server/UiServer.ts index 5cff574496..6452d5eac2 100644 --- a/sdk/packages/simplex/src/services/server/UiServer.ts +++ b/sdk/packages/simplex/src/services/server/UiServer.ts @@ -40,6 +40,7 @@ import type { LimitOrderController } from "@/simplex" import type { BalanceProvider } from "../BalanceProvider" import type { BridgeTopUp, TopUpPayToken } from "../BridgeTopUp" import { getLogger, type LogLevel } from "../Logger" +import { SubmittedUnconfirmedError } from "../submission" import { DEFAULT_TUNNEL_RELAY, parseRelayAddress, relayKey, type TunnelControls } from "../tunnel/TunnelService" import { readBody, @@ -2053,6 +2054,28 @@ export class UiServer { } return sendJson(res, 200, result) } catch (err) { + if (err instanceof SubmittedUnconfirmedError) { + // The send was broadcast but not confirmed within its wait. Reporting this as a + // plain failure invites the operator to click again, which would submit a second + // send. Report it as pending with the hash to check instead. + const submission = err.submission + const sponsored = submission.kind === "userOp" + const hash = sponsored ? submission.userOpHash : submission.txHash + this.logger.warn({ ...body, sponsored, hash }, "Operator send submitted but not confirmed") + // No wallet-history row is recorded: WalletTx.txHash is required, and a UserOp + // whose receipt has not arrived has no transaction hash yet. + return sendJson(res, 202, { + pending: true, + sponsored, + ...(sponsored ? { userOpHash: hash } : { txHash: hash }), + // A sponsored UserOp has no tx hash, so there is nothing to look up on the + // explorer; the operator watches the wallet balance or history instead. A native + // tx does have one, and its message says to check it before resending. + message: sponsored + ? "The send was submitted to the bundler but is not confirmed yet. Check the wallet balance or history in a few minutes before resending it." + : "The send was submitted but is not confirmed yet. Do not resend it until it has been checked on the explorer.", + }) + } return sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) }) } } diff --git a/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts b/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts index a86b9f5bef..0eafbe488b 100644 --- a/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts +++ b/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import { toHex } from "viem" -import { rundlerUserOperationFees, type HexString } from "@hyperbridge/sdk" +import { CryptoUtils, rundlerUserOperationFees, type HexString } from "@hyperbridge/sdk" -import { UserOpSender, type Eip7702Authorization } from "@/services/UserOpSender" +import { UserOpSender, type Eip7702Authorization, type SponsoredUserOpRequest } from "@/services/UserOpSender" import { SubmittedUnconfirmedError } from "@/services/submission" import { buildPaymasterAndData } from "@/services/paymaster" import { packPaymasterAndData, VERIFICATION_GAS_LIMIT_PERMIT2, POST_OP_GAS_LIMIT_SIMPLEX } from "@/services/paymaster/types" @@ -73,7 +73,7 @@ const permit2ModePaymasterAndData = packPaymasterAndData({ paymasterPostOpGasLimit: POST_OP_GAS_LIMIT_SIMPLEX, }) -let bundlerCalls: Array<{ method: string; params: unknown[] }> +let bundlerCalls: Array<{ method: string; params: unknown[]; signal?: AbortSignal }> let bundlerReplies: Record beforeEach(() => { @@ -81,9 +81,9 @@ beforeEach(() => { bundlerReplies = {} vi.mocked(buildPaymasterAndData).mockReset() vi.mocked(rundlerUserOperationFees).mockClear() - vi.stubGlobal("fetch", async (_url: unknown, init?: { body?: string }) => { + vi.stubGlobal("fetch", async (_url: unknown, init?: { body?: string; signal?: AbortSignal }) => { const { method, params } = JSON.parse(init?.body ?? "{}") as { method: string; params: unknown[] } - bundlerCalls.push({ method, params }) + bundlerCalls.push({ method, params, signal: init?.signal }) const reply = bundlerReplies[method] if (reply) return { json: async () => ({ jsonrpc: "2.0", id: 1, ...reply }) } let result: unknown @@ -348,3 +348,266 @@ describe("UserOpSender submitted but unconfirmed", () => { ) }) }) + +/** + * A lost `eth_sendUserOperation` response must never be read as "never submitted": the op may + * have been accepted, and a native fallback would execute the same calls twice. These cover the + * three outcomes of that decision — rejected, unreachable, and ambiguous (recovered or not). + */ +describe("UserOpSender ambiguous send outcome", () => { + const HTML_PAGE = Symbol("html-page") + const USER_OP_HASH = `0x${"ab".repeat(32)}` as HexString + + const sponsorWith = (bundlerUrl: string, extra?: Partial) => { + vi.mocked(buildPaymasterAndData).mockResolvedValue({ + paymasterAndData: permit2ModePaymasterAndData, + type: "simplex", + address: PAYMASTER, + token: TOKEN, + }) + const sender = new UserOpSender(clientManager, configServiceFor(bundlerUrl), signer) + return sender.trySendSponsored({ chain: CHAIN, callData: "0x" as HexString, gas: GAS, ...extra }) + } + + /** + * A fetch that answers each method from `byMethod`. A method absent from the map is a + * JSON-RPC "method not found"; `{ error }`/`{ result }` are returned with HTTP 200 unless a + * numeric `status` rides along, an `Error` value is rejected (a connect-phase failure or an + * abort), and `HTML_PAGE` returns a non-JSON 504 body. + */ + const stubBundler = (byMethod: Record) => { + vi.stubGlobal("fetch", async (_url: unknown, init?: { body?: string; signal?: AbortSignal }) => { + const { method, params } = JSON.parse(init?.body ?? "{}") as { method: string; params: unknown[] } + bundlerCalls.push({ method, params, signal: init?.signal }) + const reply = byMethod[method] + if (reply === undefined) { + return { status: 404, json: async () => ({ jsonrpc: "2.0", id: 1, error: { code: -32601 } }) } + } + if (reply === HTML_PAGE) { + return { + status: 504, + json: async () => { + throw new SyntaxError("Unexpected token '<'") + }, + } + } + if (reply instanceof Error) throw reply + const { status, ...body } = reply as { status?: number } & object + return { status: status ?? 200, json: async () => ({ jsonrpc: "2.0", id: 1, ...body }) } + }) + } + + /** Skips `findSubmittedOp`'s 2 s / 5 s / 10 s waits without sleeping in real time. */ + const drainLookups = async () => { + for (const delay of [2_000, 5_000, 10_000]) await vi.advanceTimersByTimeAsync(delay) + } + + /** Rebuilds the signed packed op from the bundler form of it (an inverse of `prepareBundlerCall`). */ + const packedFromBundler = (op: Record) => ({ + sender: op.sender as HexString, + nonce: BigInt(op.nonce), + initCode: "0x" as HexString, + callData: op.callData as HexString, + accountGasLimits: CryptoUtils.packGasLimits(BigInt(op.verificationGasLimit), BigInt(op.callGasLimit)), + preVerificationGas: BigInt(op.preVerificationGas), + gasFees: CryptoUtils.packGasFees(BigInt(op.maxPriorityFeePerGas), BigInt(op.maxFeePerGas)), + paymasterAndData: `0x${[ + op.paymaster.slice(2), + BigInt(op.paymasterVerificationGasLimit).toString(16).padStart(32, "0"), + BigInt(op.paymasterPostOpGasLimit).toString(16).padStart(32, "0"), + op.paymasterData.slice(2), + ].join("")}` as HexString, + signature: op.signature as HexString, + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it("returns null when the bundler answers with a JSON-RPC error", async () => { + stubBundler({ eth_sendUserOperation: { error: { code: -32500, message: "userop rejected" } } }) + + await expect(sponsorWith("http://ambig-reject.test/bundler")).resolves.toBeNull() + expect(bundlerCalls.some((c) => c.method === "eth_getUserOperationByHash")).toBe(false) + }) + + it("returns null when the connection cannot be established (nothing was sent)", async () => { + const refused = Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } }) + stubBundler({ eth_sendUserOperation: refused }) + + await expect(sponsorWith("http://ambig-refused.test/bundler")).resolves.toBeNull() + expect(bundlerCalls.some((c) => c.method === "eth_getUserOperationByHash")).toBe(false) + }) + + it("a 504 HTML body, then a by-hash hit, then a receipt: resolves with the tx hash", async () => { + const txHash = `0x${"cd".repeat(32)}` as HexString + stubBundler({ + eth_sendUserOperation: HTML_PAGE, + eth_getUserOperationByHash: { result: { userOpHash: USER_OP_HASH } }, + eth_getUserOperationReceipt: { result: { success: true, receipt: { transactionHash: txHash } } }, + }) + vi.useFakeTimers() + + const promise = sponsorWith("http://ambig-recovered.test/bundler") + await drainLookups() + + await expect(promise).resolves.toMatchObject({ txHash }) + expect(bundlerCalls.filter((c) => c.method === "eth_getUserOperationByHash")).toHaveLength(1) + }) + + it("a 504, then by-hash null on every attempt: returns null (never entered the pool)", async () => { + stubBundler({ eth_sendUserOperation: HTML_PAGE, eth_getUserOperationByHash: { result: null } }) + vi.useFakeTimers() + + const promise = sponsorWith("http://ambig-absent.test/bundler") + await drainLookups() + + await expect(promise).resolves.toBeNull() + expect(bundlerCalls.filter((c) => c.method === "eth_getUserOperationByHash")).toHaveLength(3) + }) + + it("a timeout, then failing lookups too: throws SubmittedUnconfirmedError carrying the local hash", async () => { + const timeout = Object.assign(new Error("This operation was aborted"), { name: "TimeoutError" }) + const reset = Object.assign(new Error("socket closed"), { cause: { code: "ECONNRESET" } }) + stubBundler({ eth_sendUserOperation: timeout, eth_getUserOperationByHash: reset }) + vi.useFakeTimers() + + const promise = sponsorWith("http://ambig-unknown.test/bundler").catch((err) => err) + await drainLookups() + const error = await promise + + expect(error).toBeInstanceOf(SubmittedUnconfirmedError) + expect(error.message).toContain("the bundler's answer was lost and the op could not be looked up") + expect(error.submission).toMatchObject({ kind: "userOp", nonce: 0n, nonceKey: 0n, expires: true }) + expect(error.submission.submittedAt).toBeTypeOf("number") + + const [lookup] = bundlerCalls.filter((c) => c.method === "eth_getUserOperationByHash")[0].params as [HexString] + expect(error.submission.userOpHash).toBe(lookup) + }) + + it("the local hash is the EntryPoint hash of the op the bundler received", async () => { + const timeout = Object.assign(new Error("This operation was aborted"), { name: "TimeoutError" }) + const reset = Object.assign(new Error("socket closed"), { cause: { code: "ECONNRESET" } }) + stubBundler({ eth_sendUserOperation: timeout, eth_getUserOperationByHash: reset }) + vi.useFakeTimers() + + const promise = sponsorWith("http://ambig-hash.test/bundler").catch((err) => err) + await drainLookups() + const error = await promise + + const [sentOp] = bundlerCalls.filter((c) => c.method === "eth_sendUserOperation")[0].params as [ + Record, + ] + const expected = CryptoUtils.computeUserOpHash(packedFromBundler(sentOp), ENTRY_POINT, BigInt(CHAIN_ID)) + expect(error.submission.userOpHash).toBe(expected) + }) + + it("recovers the hash of a 7702-auth op too, which does not change initCode", async () => { + const reset = Object.assign(new Error("socket closed"), { cause: { code: "ECONNRESET" } }) + stubBundler({ eth_sendUserOperation: reset, eth_getUserOperationByHash: reset }) + vi.useFakeTimers() + + const promise = sponsorWith("http://ambig-auth.test/bundler", { + eip7702Auth: async () => ({ + chainId: CHAIN_ID, + address: DELEGATE, + nonce: 1, + r: `0x${"22".repeat(32)}` as HexString, + s: `0x${"33".repeat(32)}` as HexString, + yParity: 0, + }), + }).catch((err) => err) + await drainLookups() + const error = await promise + + const [sentOp] = bundlerCalls.filter((c) => c.method === "eth_sendUserOperation")[0].params as [ + Record, + ] + // The authorization rides beside the op, not in it, so `initCode` stays 0x and the hash is + // computed from the op alone. + expect(sentOp.eip7702Auth).toBeDefined() + expect(error.submission.userOpHash).toBe( + CryptoUtils.computeUserOpHash(packedFromBundler(sentOp), ENTRY_POINT, BigInt(CHAIN_ID)), + ) + }) + + it("bounds the send with an AbortSignal", async () => { + stubBundler({ eth_sendUserOperation: { error: { code: -32500, message: "rejected" } } }) + + await sponsorWith("http://ambig-signal.test/bundler") + + const send = bundlerCalls.find((c) => c.method === "eth_sendUserOperation")! + expect(send.signal).toBeInstanceOf(AbortSignal) + }) + + it("treats a proxy's JSON 502 as ambiguous and looks the op up", async () => { + const txHash = `0x${"cd".repeat(32)}` as HexString + stubBundler({ + eth_sendUserOperation: { status: 502, error: { code: 504, message: "upstream timed out" } }, + eth_getUserOperationByHash: { result: { userOpHash: USER_OP_HASH } }, + eth_getUserOperationReceipt: { result: { success: true, receipt: { transactionHash: txHash } } }, + }) + vi.useFakeTimers() + + const promise = sponsorWith("http://ambig-502.test/bundler") + await drainLookups() + + await expect(promise).resolves.toMatchObject({ txHash }) + expect(bundlerCalls.filter((c) => c.method === "eth_getUserOperationByHash")).toHaveLength(1) + }) + + it("treats rundler's -32603 internal error as ambiguous and looks the op up", async () => { + stubBundler({ + eth_sendUserOperation: { error: { code: -32603, message: "internal error" } }, + eth_getUserOperationByHash: { result: null }, + }) + vi.useFakeTimers() + + const promise = sponsorWith("http://ambig-32603.test/bundler") + await drainLookups() + + await expect(promise).resolves.toBeNull() + expect(bundlerCalls.filter((c) => c.method === "eth_getUserOperationByHash")).toHaveLength(3) + }) + + it.each([ + ["-32602", -32602], + ["-32500", -32500], + ])("treats a 200 with %s as a definite rejection", async (_label, code) => { + stubBundler({ eth_sendUserOperation: { error: { code, message: "rejected" } } }) + + await expect(sponsorWith(`http://ambig-code-${code}.test/bundler`)).resolves.toBeNull() + expect(bundlerCalls.some((c) => c.method === "eth_getUserOperationByHash")).toBe(false) + }) + + it("a found op with no receipt by the deadline: throws SubmittedUnconfirmedError from the local hash", async () => { + const reset = Object.assign(new Error("socket closed"), { cause: { code: "ECONNRESET" } }) + stubBundler({ + eth_sendUserOperation: reset, + // The bundler answers that it holds the op, but the receipt never appears. + eth_getUserOperationByHash: { result: { userOpHash: USER_OP_HASH } }, + eth_getUserOperationReceipt: { result: null }, + }) + vi.useFakeTimers() + + const promise = sponsorWith("http://ambig-found-noreceipt.test/bundler").catch((err) => err) + await drainLookups() + await vi.advanceTimersByTimeAsync(95_000) + const error = await promise + + expect(error).toBeInstanceOf(SubmittedUnconfirmedError) + expect(error.chain).toBe(CHAIN) + const [sentOp] = bundlerCalls.filter((c) => c.method === "eth_sendUserOperation")[0].params as [ + Record, + ] + // The local hash, not the different hash the by-hash reply carried. + expect(error.submission).toMatchObject({ + kind: "userOp", + nonce: 0n, + nonceKey: 0n, + expires: true, + userOpHash: CryptoUtils.computeUserOpHash(packedFromBundler(sentOp), ENTRY_POINT, BigInt(CHAIN_ID)), + }) + expect(error.submission.userOpHash).not.toBe(USER_OP_HASH) + }) +}) diff --git a/sdk/packages/simplex/src/tests/token-sender.test.ts b/sdk/packages/simplex/src/tests/token-sender.test.ts index e174b1e62b..f36cfa3d2a 100644 --- a/sdk/packages/simplex/src/tests/token-sender.test.ts +++ b/sdk/packages/simplex/src/tests/token-sender.test.ts @@ -12,7 +12,14 @@ const ERC7821_EXECUTE_ABI = parseAbi(["function execute(bytes32 mode, bytes exec import { ERC20_ABI } from "@/config/abis/ERC20" import { ERC4626_ABI } from "@/config/abis/Erc4626" import { TokenSender } from "@/services/TokenSender" +import { UserOpSender } from "@/services/UserOpSender" import { SubmittedUnconfirmedError, type SubmittedUserOp } from "@/services/submission" +import { buildPaymasterAndData } from "@/services/paymaster" +import { + packPaymasterAndData, + VERIFICATION_GAS_LIMIT_PERMIT2, + POST_OP_GAS_LIMIT_SIMPLEX, +} from "@/services/paymaster/types" import type { ChainClientManager } from "@/services/ChainClientManager" import { resolvePendingPermit2Approval } from "@/services/paymaster/provider/simplex" @@ -23,6 +30,15 @@ vi.mock("@/services/paymaster/provider/simplex", async (importOriginal) => ({ resolvePendingPermit2Approval: vi.fn(), })) +// The sponsored path under test runs the real UserOpSender, so its paymaster selection is stubbed; +// the deposit/balance reads behind it belong to the paymaster tests. The rest of the module +// (`paymasterReserveForToken`, used by the vault tests above) stays real. +vi.mock("@/services/paymaster", async (importOriginal) => ({ + ...(await importOriginal()), + hasPaymaster: () => true, + buildPaymasterAndData: vi.fn(), +})) + const SOLVER = "0x5b2c3e25243634732eE94525Ae98aEc404c82506" as const const RECIPIENT = "0x1111111111111111111111111111111111111111" as const const TOKEN = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as const @@ -500,3 +516,141 @@ describe("TokenSender native batch safety", () => { expect(sendTransaction.mock.calls[0][0].to).toBe(TOKEN) }) }) + +/** + * The c07 regression, end to end through the real UserOpSender: a lost `eth_sendUserOperation` + * response must not let `submitCalls` re-send the same calls as a native tx. Only a definite + * "the bundler never got it" may fall back. + */ +describe("TokenSender sponsored send with a lost bundler response", () => { + const FEE_TOKEN = "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" as const + const PAYMASTER = "0xe99acFe0f5fC4C8ea54A187D8D3b05f136150095" as const + const ENTRY_POINT = "0x4337843378433784337843378433784337843378" as const + const HTML_PAGE = Symbol("html-page") + const transfer = { + target: TOKEN, + value: 0n, + data: encodeFunctionData({ abi: ERC20_ABI, functionName: "transfer", args: [RECIPIENT, 25_000_000n] }), + } + const paymasterAndData = packPaymasterAndData({ + paymaster: PAYMASTER, + paymasterData: "0x02" as `0x${string}`, + paymasterVerificationGasLimit: VERIFICATION_GAS_LIMIT_PERMIT2, + paymasterPostOpGasLimit: POST_OP_GAS_LIMIT_SIMPLEX, + }) + + /** A TokenSender wired to a real UserOpSender, with only the chain/bundler transport faked. */ + function makeRealSender(bundlerUrl: string) { + const sendTransaction = vi.fn().mockResolvedValue("0xnative") + const publicClient = { + getGasPrice: async () => 1_000_000_000n, + getBlock: async () => ({ baseFeePerGas: 900_000_000n }), + readContract: async () => 0n, + getTransactionCount: async () => 7, + waitForTransactionReceipt: vi.fn().mockResolvedValue({ status: "success" }), + } + const clientManager = { + getPublicClient: () => publicClient, + getWalletClient: () => ({ sendTransaction, chain: undefined }), + } as unknown as ChainClientManager + const signer = { address: SOLVER, signTypedData: async () => ("0x" + "11".repeat(65)) as `0x${string}` } + const configService = { + getEntryPointAddress: () => ENTRY_POINT, + getBundlerUrl: () => bundlerUrl, + getChainId: () => 8453, + getSimplexPaymasterAddress: () => PAYMASTER, + } as never + const userOpSender = new UserOpSender(clientManager, configService, signer as never) + const sender = new TokenSender(clientManager, SOLVER, () => [], userOpSender, configService) + return { sender, sendTransaction } + } + + const fetchMethods: string[] = [] + + const stubFetch = (byMethod: Record) => { + vi.stubGlobal("fetch", async (_url: unknown, init?: { body?: string }) => { + const { method } = JSON.parse(init?.body ?? "{}") as { method: string } + fetchMethods.push(method) + const reply = byMethod[method] + if (reply === undefined) { + return { status: 404, json: async () => ({ jsonrpc: "2.0", id: 1, error: { code: -32601 } }) } + } + if (reply === HTML_PAGE) { + return { + status: 504, + json: async () => { + throw new SyntaxError("Unexpected token '<'") + }, + } + } + if (reply instanceof Error) throw reply + const { status, ...body } = reply as { status?: number } & object + return { status: status ?? 200, json: async () => ({ jsonrpc: "2.0", id: 1, ...body }) } + }) + } + + const drainLookups = async () => { + for (const delay of [2_000, 5_000, 10_000]) await vi.advanceTimersByTimeAsync(delay) + } + + beforeEach(() => { + fetchMethods.length = 0 + vi.mocked(buildPaymasterAndData).mockResolvedValue({ + paymasterAndData, + type: "simplex", + address: PAYMASTER, + token: FEE_TOKEN, + }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it("rejects with SubmittedUnconfirmedError and never sends natively when the lookup fails too", async () => { + const timeout = Object.assign(new Error("This operation was aborted"), { name: "TimeoutError" }) + const reset = Object.assign(new Error("socket closed"), { cause: { code: "ECONNRESET" } }) + stubFetch({ eth_sendUserOperation: timeout, eth_getUserOperationByHash: reset }) + const { sender, sendTransaction } = makeRealSender("http://tok-unknown.test/bundler") + vi.useFakeTimers() + + const promise = sender.submitCalls("EVM-8453", [transfer]).catch((err) => err) + await drainLookups() + const error = await promise + + expect(error).toBeInstanceOf(SubmittedUnconfirmedError) + expect(error.submission).toMatchObject({ kind: "userOp" }) + expect(sendTransaction).not.toHaveBeenCalled() + }) + + it("falls back to a native tx when the bundler confirms it never received the op", async () => { + stubFetch({ eth_sendUserOperation: HTML_PAGE, eth_getUserOperationByHash: { result: null } }) + const { sender, sendTransaction } = makeRealSender("http://tok-absent.test/bundler") + vi.useFakeTimers() + + const promise = sender.submitCalls("EVM-8453", [transfer]) + await drainLookups() + + expect(await promise).toMatchObject({ txHash: "0xnative", sponsored: false }) + expect(sendTransaction).toHaveBeenCalledOnce() + }) + + it("treats a proxy's JSON 502 as ambiguous and recovers the op, not resending natively", async () => { + const txHash = `0x${"ef".repeat(32)}` + stubFetch({ + eth_sendUserOperation: { status: 502, error: { code: 504, message: "upstream timed out" } }, + eth_getUserOperationByHash: { result: { userOpHash: `0x${"ab".repeat(32)}` } }, + eth_getUserOperationReceipt: { result: { success: true, receipt: { transactionHash: txHash } } }, + }) + const { sender, sendTransaction } = makeRealSender("http://tok-502.test/bundler") + vi.useFakeTimers() + + const promise = sender.submitCalls("EVM-8453", [transfer]) + await drainLookups() + + expect(await promise).toMatchObject({ txHash, sponsored: true }) + expect(fetchMethods).toContain("eth_getUserOperationByHash") + expect(sendTransaction).not.toHaveBeenCalled() + }) +}) diff --git a/sdk/packages/simplex/src/tests/ui-server.test.ts b/sdk/packages/simplex/src/tests/ui-server.test.ts index 33e8351441..dff100ee92 100644 --- a/sdk/packages/simplex/src/tests/ui-server.test.ts +++ b/sdk/packages/simplex/src/tests/ui-server.test.ts @@ -6,6 +6,7 @@ import { import { EventEmitter } from "node:events" import type { SetupDeps } from "@/services/server/setup-api" import { ActivityRecorder } from "@/data/recorder" +import { SubmittedUnconfirmedError } from "@/services/submission" import { MemoryDataStore } from "@/data/memory" import { LoggerContext, type LogLevel } from "@/services/Logger" import { LogStore } from "@/services/server/LogStore" @@ -889,6 +890,71 @@ describe("UiServer (operator mode)", () => { expect((await failed.json()).error).toContain("Insufficient") }) + it("reports an unconfirmed operator send as pending and records no wallet-history row", async () => { + const userOpHash = ("0x" + "ab".repeat(32)) as `0x${string}` + const send = vi.fn().mockRejectedValue( + new SubmittedUnconfirmedError("unconfirmed", "EVM-8453", { + kind: "userOp", + userOpHash, + nonce: 0n, + nonceKey: 0n, + expires: true, + submittedAt: Date.now(), + }), + ) + const { base } = await startServer({ send }) + const body = { + chain: "EVM-8453", + token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + amount: "25", + to: "0x5b2c3e25243634732eE94525Ae98aEc404c82506", + } + const post = () => fetch(`${base}/api/send`, { method: "POST", headers: CSRF, body: JSON.stringify(body) }) + + const res = await post() + expect(res.status).toBe(202) + const payload = await res.json() + expect(payload).toMatchObject({ pending: true, sponsored: true, userOpHash }) + expect(payload.message).toContain("not confirmed") + // A sponsored UserOp has no explorer entry point, so its message points at the wallet. + expect(payload.message).toContain("bundler") + expect(payload.message).not.toContain("explorer") + // A UserOp has no tx hash until it lands, so none is reported — and no history row is written. + expect(payload.txHash).toBeUndefined() + const history = await (await fetch(`${base}/api/wallet/history`)).json() + expect(history.txs).toHaveLength(0) + + // Any other error stays a plain 400. + send.mockRejectedValueOnce(new Error("Insufficient token balance")) + const failed = await post() + expect(failed.status).toBe(400) + expect((await failed.json()).error).toContain("Insufficient") + }) + + it("reports an unconfirmed native send as pending with its tx hash", async () => { + const txHash = ("0x" + "cd".repeat(32)) as `0x${string}` + const send = vi + .fn() + .mockRejectedValue( + new SubmittedUnconfirmedError("unconfirmed", "EVM-8453", { kind: "tx", txHash, nonce: 4, submittedAt: Date.now() }), + ) + const { base } = await startServer({ send }) + + const body = { + chain: "EVM-8453", + token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + amount: "25", + to: "0x5b2c3e25243634732eE94525Ae98aEc404c82506", + } + const res = await fetch(`${base}/api/send`, { method: "POST", headers: CSRF, body: JSON.stringify(body) }) + + expect(res.status).toBe(202) + const payload = await res.json() + expect(payload).toMatchObject({ pending: true, sponsored: false, txHash }) + // A native tx has a hash to check, so its message keeps the explorer wording. + expect(payload.message).toContain("explorer") + }) + it("does not list vault shares among send token options", async () => { const vaultAddress = "0xC768c589647798a6EE01A91FdE98EF2ed046DBD6" const config = fakeConfig() diff --git a/sdk/packages/simplex/ui/src/operator/WalletTools.test.tsx b/sdk/packages/simplex/ui/src/operator/WalletTools.test.tsx new file mode 100644 index 0000000000..1e5105099c --- /dev/null +++ b/sdk/packages/simplex/ui/src/operator/WalletTools.test.tsx @@ -0,0 +1,141 @@ +// @vitest-environment jsdom +import { act } from "react" +import { createRoot, type Root } from "react-dom/client" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { api } from "../api" +import { WalletTools } from "./WalletTools" + +const USER_OP_HASH = `0x${"ab".repeat(32)}` +const TX_HASH = `0x${"cd".repeat(32)}` +const RECIPIENT = "0x5b2c3e25243634732eE94525Ae98aEc404c82506" + +let root: Root +let element: HTMLDivElement +/** What the next `/api/send` post answers with. */ +let sendReply: unknown +let onBalancesChanged: ReturnType +const flush = () => act(async () => {}) + +const button = (scope: ParentNode, label: string) => { + const found = [...scope.querySelectorAll("button")].find((entry) => entry.textContent?.trim().startsWith(label)) + if (!found) throw new Error(`No "${label}" button`) + return found +} +const click = (target: HTMLElement) => act(async () => target.click()) + +async function enter(input: HTMLInputElement, value: string) { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, value) + input.dispatchEvent(new Event("input", { bubbles: true })) + }) +} + +/** Opens the send sheet, fills the form and submits it through the review dialog. */ +async function send() { + await click(element.querySelector(".operator-tool-row")!) + const sheet = document.querySelector('[role="dialog"]')! + await enter(sheet.querySelector('input[placeholder="0.00"]')!, "25") + await enter(sheet.querySelector('input[placeholder="0x…"]')!, RECIPIENT) + await click(button(sheet, "Review transfer")) + const review = [...document.querySelectorAll('[role="dialog"]')].at(-1)! + await click(button(review, "Send 25 ETH")) + await flush() +} + +beforeEach(async () => { + ;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + // The sheet asks whether it is on a phone; jsdom has no media queries to answer. + window.matchMedia ??= ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + })) as typeof window.matchMedia + sendReply = { txHash: TX_HASH, sponsored: true, redeemed: false } + onBalancesChanged = vi.fn() + vi.spyOn(api, "get").mockResolvedValue({ + toml: "", + logLevel: "info", + vaultConfigured: false, + allowlistUsers: [], + vaults: [], + sendTokens: { "EVM-8453": [{ symbol: "ETH", address: "native" }] }, + knownVaults: {}, + }) + vi.spyOn(api, "post").mockImplementation(async () => sendReply) + element = document.createElement("div") + document.body.append(element) + root = createRoot(element) +}) +afterEach(async () => { + await act(async () => root.unmount()) + element.remove() + vi.restoreAllMocks() +}) + +const render = async () => { + await act(async () => + root.render( + , + ), + ) + await flush() +} + +describe("send funds", () => { + it("shows the explorer-linked tx hash on a confirmed send", async () => { + await render() + await send() + + const text = document.querySelector('[role="dialog"]')?.textContent ?? "" + expect(text).toContain("✓ Sent") + const link = document.querySelector(".operator-send-tx-link") + expect(link?.getAttribute("href")).toContain(`/tx/${TX_HASH}`) + }) + + it("shows a pending warning and clears the amount when the send is unconfirmed", async () => { + sendReply = { + pending: true, + sponsored: true, + userOpHash: USER_OP_HASH, + message: "The send was submitted but is not confirmed yet.", + } + await render() + await send() + + const warning = document.querySelector("p.warning") + expect(warning?.textContent).toContain("not confirmed yet") + expect(warning?.textContent).toContain(`${USER_OP_HASH.slice(0, 18)}…`) + // A UserOp has no tx hash: no success line, no explorer link. + expect(document.querySelector('[role="dialog"]')?.textContent ?? "").not.toContain("✓ Sent") + expect(warning?.querySelector("a")).toBeNull() + // The amount is cleared so the next click cannot resend it. + expect(document.querySelector('input[placeholder="0.00"]')?.value).toBe("") + // The balance refresh still runs, so the operator sees what actually left the wallet. + expect(onBalancesChanged).toHaveBeenCalledOnce() + }) + + it("links a pending native send's tx hash to the explorer", async () => { + sendReply = { + pending: true, + sponsored: false, + txHash: TX_HASH, + message: "The send was submitted but is not confirmed yet.", + } + await render() + await send() + + const warning = document.querySelector("p.warning") + const link = warning?.querySelector(".operator-send-tx-link") + expect(link?.getAttribute("href")).toContain(`/tx/${TX_HASH}`) + }) +}) diff --git a/sdk/packages/simplex/ui/src/operator/WalletTools.tsx b/sdk/packages/simplex/ui/src/operator/WalletTools.tsx index d4224b9345..f5cdc5a9ed 100644 --- a/sdk/packages/simplex/ui/src/operator/WalletTools.tsx +++ b/sdk/packages/simplex/ui/src/operator/WalletTools.tsx @@ -293,6 +293,39 @@ function describeSweep(result: VaultSweepDto, chainLabel: (id: number | string) return { tone: "ok", text: "Nothing to sweep — no vault has a sweep threshold configured" } } +/** + * The `/api/send` response. A confirmed send carries its tx hash; a submission that was + * broadcast but not confirmed comes back with `pending` and, for a sponsored UserOp, only its + * own hash — no tx hash exists until it lands. + */ +type SendResult = + | { pending?: false; txHash: string; redeemed: boolean } + | { pending: true; sponsored: boolean; txHash?: string; userOpHash?: string; message: string } + +function SendHash({ hash, explorerUrl }: { hash?: string; explorerUrl?: string }) { + if (!hash) return null + if (explorerUrl) { + return ( + + {hash.slice(0, 18)}… + + ) + } + return ( + + {hash.slice(0, 18)}… + + ) +} + function SendCard(props: { chains: number[] chainLabel: (id: number | string) => string @@ -305,10 +338,10 @@ function SendCard(props: { const [customToken, setCustomToken] = useState("") const [amount, setAmount] = useState("") const [to, setTo] = useState("") - const [result, setResult] = useState<{ txHash: string; redeemed: boolean; explorerUrl?: string }>() + const [result, setResult] = useState<(SendResult & { explorerUrl?: string })>() const [sending, setSending] = useState(false) const [review, setReview] = useState() - const { run: act, message, error } = useAction() + const { run: act, error } = useAction() const selectedChain = chain ?? formatChainKey(props.chains[0] ?? "") const selectedChainId = parseChainKey(selectedChain) @@ -348,19 +381,22 @@ function SendCard(props: { setSending(true) return act(async () => { try { - const res = await api.post<{ txHash: string; redeemed: boolean }>("/api/send", { + const res = await api.post("/api/send", { chain: selectedChain, token: tokenAddress, amount: amount.trim(), to: to.trim(), }) setResult({ ...res, explorerUrl }) + // Clear the amount whatever the outcome: a send that came back pending (202) must + // not be re-submitted by the next click. `act` gets no "Sent" label here because a + // pending response sets `result`, and the success line already renders from it. setAmount("") await props.onSent() } finally { setSending(false) } - }, "Sent") + }) } return ( @@ -419,29 +455,28 @@ function SendCard(props: { - {result && ( + {result && !result.pending && (

✓ Sent{result.redeemed && " (topped up from the vault)"} — tx{" "} - {result.explorerUrl ? ( - - {result.txHash.slice(0, 18)}… - - ) : ( - - {result.txHash.slice(0, 18)}… - + +

+ )} + {result?.pending && ( + // A pending send is not a success, and its message is worded by the server for the + // sponsored (no tx hash) or native case. The hash is shown only when there is one. +

+ ⚠ {result.message} + {(result.txHash ?? result.userOpHash) && ( + <> + {" — "} + + )}

)} - {message && !result &&

✓ {message}

} {error &&

{error}

} {review && ( Date: Mon, 19 Oct 2026 12:37:15 +0000 Subject: [PATCH 2/4] [simplex]: address review on #1413 - A bundler -32602 that says an op is already pending on the nonce (replacement underpriced / already known) throws PendingOpOnNonceError instead of reading as a rejection, so no caller falls back to a native send. /api/send answers 202 with a wait message, the vault sweep stalls until the next cycle, and delegation setup returns false without a native delegation tx. A plain -32602 invalid-params is still `null`. - Reword the sponsored 202 message: the op may still land, wait before resending. A lost-answer outcome (`unknown`) says the send may have been submitted, via SubmittedUnconfirmedError.maybeSubmitted. - Bound the gas-price lookup at 10 s; a timeout is never-submitted. - Record a wallet-history row for a pending native send (it has a tx hash). - Docs: 202 shape and pending-on-nonce answer in wallet.mdx, corrected timeouts in the paymaster flow doc, outage behaviour and v0.9 hash note in the changelog note. --- .../developers/evm/simplex/api/wallet.mdx | 27 +++++++ .../2026-10-16-ambiguous-bundler-sends.md | 62 +++++++++++++--- ...master-selection-for-a-sponsored-userop.md | 2 +- .../src/funding/vault/VaultFundingPlanner.ts | 4 +- .../simplex/src/services/DelegationService.ts | 15 +++- .../simplex/src/services/TokenSender.ts | 4 + .../simplex/src/services/UserOpSender.ts | 73 ++++++++++++++++--- .../simplex/src/services/server/UiServer.ts | 65 +++++++++++------ .../simplex/src/services/submission.ts | 26 +++++++ .../simplex/src/tests/funding/vault.test.ts | 13 ++++ .../DelegationService.ordering.test.ts | 12 +++ .../src/tests/services/UserOpSender.test.ts | 66 ++++++++++++++++- .../simplex/src/tests/token-sender.test.ts | 32 +++++++- .../simplex/src/tests/ui-server.test.ts | 60 ++++++++++++++- 14 files changed, 408 insertions(+), 53 deletions(-) diff --git a/docs/content/developers/evm/simplex/api/wallet.mdx b/docs/content/developers/evm/simplex/api/wallet.mdx index 3fe4b0dbaf..8f56b5356d 100644 --- a/docs/content/developers/evm/simplex/api/wallet.mdx +++ b/docs/content/developers/evm/simplex/api/wallet.mdx @@ -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. + +```json lineNumbers +{ + "pending": true, + "sponsored": true, + "userOpHash": "0x…", + "message": "The send was submitted to the bundler but is not confirmed yet. It may still land; wait a few minutes and check the wallet history 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 it is not recorded in the wallet history. | +| `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 before sending again. +- **Possibly submitted.** The bundler did not answer the send or any of the lookups after it, so the send *may have been submitted*. The message says so. This is what an outage of the bundler looks like. +- **Earlier send still pending.** The bundler refused the send because an earlier UserOperation from this wallet 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 send from this wallet is still pending; wait for it 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. + ## 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. diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md b/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md index 7797b2d3dc..e933b5e22f 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md @@ -9,10 +9,9 @@ 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. (`getGasPrice` is not one of them: it goes through the SDK's -`rundlerUserOperationFees`, which fetches the bundler's suggested fees itself.) 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: +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`). @@ -21,13 +20,33 @@ polls — and a call that returns no result throws a `BundlerCallError` whose `o - `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. + +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 @@ -41,17 +60,36 @@ default inter-attempt delays are 2 s, 5 s and 10 s): 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; the caller must not fall back. + 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 every lookup 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` with HTTP 202 -`{ pending: true, sponsored, userOpHash?, txHash?, message }` instead of 400, and writes no wallet -history row — a `WalletTx.txHash` is required, and an unconfirmed UserOp has none. Every other error -stays a 400. 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 sponsored UserOp has no tx hash, -so its message points at the wallet balance or history and its hash is shown as plain text, while a -native tx keeps the explorer wording and link. +`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 says it may still land and to wait before resending: the wallet + 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 send from this wallet is still pending; wait for it + before sending again" with no hash, since nothing new was submitted. + +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. diff --git a/sdk/packages/simplex/docs/ai/flows/paymaster-selection-for-a-sponsored-userop.md b/sdk/packages/simplex/docs/ai/flows/paymaster-selection-for-a-sponsored-userop.md index 6588d3e74a..3fe0975506 100644 --- a/sdk/packages/simplex/docs/ai/flows/paymaster-selection-for-a-sponsored-userop.md +++ b/sdk/packages/simplex/docs/ai/flows/paymaster-selection-for-a-sponsored-userop.md @@ -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` 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. Every bundler call is bounded by a 10 s abort timeout and classified on failure: a JSON-RPC error or a connect-phase failure is "not submitted", while a lost response 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`). +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. diff --git a/sdk/packages/simplex/src/funding/vault/VaultFundingPlanner.ts b/sdk/packages/simplex/src/funding/vault/VaultFundingPlanner.ts index 35f790eb87..17b61b1680 100644 --- a/sdk/packages/simplex/src/funding/vault/VaultFundingPlanner.ts +++ b/sdk/packages/simplex/src/funding/vault/VaultFundingPlanner.ts @@ -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, diff --git a/sdk/packages/simplex/src/services/DelegationService.ts b/sdk/packages/simplex/src/services/DelegationService.ts index 1aba1e2d69..1d70e4f242 100644 --- a/sdk/packages/simplex/src/services/DelegationService.ts +++ b/sdk/packages/simplex/src/services/DelegationService.ts @@ -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" @@ -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") @@ -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") } diff --git a/sdk/packages/simplex/src/services/TokenSender.ts b/sdk/packages/simplex/src/services/TokenSender.ts index 159d4d561f..7c166bf65c 100644 --- a/sdk/packages/simplex/src/services/TokenSender.ts +++ b/sdk/packages/simplex/src/services/TokenSender.ts @@ -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, diff --git a/sdk/packages/simplex/src/services/UserOpSender.ts b/sdk/packages/simplex/src/services/UserOpSender.ts index 692690b9d9..10446ec79d 100644 --- a/sdk/packages/simplex/src/services/UserOpSender.ts +++ b/sdk/packages/simplex/src/services/UserOpSender.ts @@ -13,7 +13,7 @@ import { type Logger , moduleLogger} from "./Logger" import type { Signer } from "./wallet" import { buildPaymasterAndData, hasPaymaster } from "./paymaster" import type { PaymasterDataResult } from "./paymaster" -import { SubmittedUnconfirmedError, type SubmittedUserOp } from "./submission" +import { PendingOpOnNonceError, SubmittedUnconfirmedError, type SubmittedUserOp } from "./submission" /** * Raw EIP-7702 authorization, as produced by the delegation flow. Attached to the @@ -84,6 +84,11 @@ const BUNDLER_POLL_TIMEOUT_MS = 10_000 // may read as unconfirmed), nor an estimate into the ~1.9M-gas fallback limits. const BUNDLER_SIMULATION_TIMEOUT_MS = 30_000 +// The fee lookup runs before anything is signed or sent, and the SDK's `rundlerUserOperationFees` +// fetches the bundler with no timeout of its own. A hung gateway would otherwise pin the send path; +// timing out here is "never submitted", the same as any other preparation failure. +const GAS_PRICE_TIMEOUT_MS = 10_000 + // A receipt is polled every `RECEIPT_POLL_INTERVAL_MS` until this wall-clock deadline. Time-based // rather than a poll count so the per-poll timeout cannot stretch the wait (a 30-poll count with a // 10 s poll timeout would run to six minutes). @@ -119,12 +124,13 @@ const UNREACHABLE_CODES = new Set([ "ERR_TLS_CERT_ALTNAME_INVALID", ]) -type BundlerCallOutcome = "rejected" | "unreachable" | "ambiguous" +type BundlerCallOutcome = "rejected" | "unreachable" | "ambiguous" | "pending" /** * A bundler JSON-RPC call that returned no result. `outcome` records how definite the failure * was, which the send path must not flatten: a `rejected` or `unreachable` call provably did not - * enter the op, while an `ambiguous` one may have been accepted with only the response lost. + * enter the op, while an `ambiguous` one may have been accepted with only the response lost. A + * `pending` call was refused because an earlier op already holds the nonce, which is neither. */ class BundlerCallError extends Error { constructor( @@ -159,6 +165,32 @@ function isDeterministicRejectionCode(code: unknown): boolean { return code >= -32599 && code <= -32500 } +/** + * Whether a JSON-RPC error says an op from this sender is already pending on the nonce. Rundler + * answers a resent or re-priced op with -32602 (invalid params), the same code as a malformed + * request, so the code alone cannot tell them apart: it is the `ReplacementUnderpriced` error's + * `currentMaxPriorityFee`/`currentMaxFee` data, or the "replacement underpriced" / "already known" + * message, that does. Read as a rejection it would let the caller send the same calls natively + * while the earlier op can still land. + */ +function isPendingOnNonceError(rpcError: { code?: unknown; message?: unknown; data?: unknown }): boolean { + if (rpcError.code !== -32602) return false + const data = rpcError.data as { currentMaxPriorityFee?: unknown; currentMaxFee?: unknown } | null | undefined + if (data && typeof data === "object" && (data.currentMaxPriorityFee !== undefined || data.currentMaxFee !== undefined)) { + return true + } + return typeof rpcError.message === "string" && /already known|replacement underpriced/i.test(rpcError.message) +} + +/** Rejects if `promise` has not settled within `ms`. */ +function withTimeout(promise: Promise, ms: number, what: string): Promise { + let timer: NodeJS.Timeout | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out ${what} after ${ms}ms`)), ms) + }) + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)) +} + /** * Submits self-initiated, paymaster-sponsored UserOperations through the bundler, * so the solver pays gas in stablecoins rather than native token. Shared by the @@ -171,15 +203,16 @@ function isDeterministicRejectionCode(code: unknown): boolean { * bundler rejected `eth_sendUserOperation` outright, the connection could not be * established, or a post-failure lookup proved the op never entered the pool. The * caller may fall back to native. - * `null` means *this* op never entered the pool, not that nothing is pending for - * its EntryPoint nonce: a retry builds a new op on the same nonce, and the bundler's - * "replacement underpriced"/"already known" answer for it rejects the new op only. * - Returns `{ txHash, userOp }` once a receipt is observed. * - **Throws** {@link SubmittedUnconfirmedError} when the op **was submitted** but no * receipt arrived, or when its submission could not be determined at all (the send * response was lost and the lookup could not ask). The caller must NOT fall back (the * op may still land) — retry on the next cycle, and resolve the op's fate later with * {@link lookupUserOpReceipt}. + * - **Throws** {@link PendingOpOnNonceError} when the bundler refuses the op because an + * earlier op from this sender already holds its EntryPoint nonce ("replacement + * underpriced", "already known"). Not a rejection: that earlier op may still land, so + * the caller must NOT fall back — retry later. */ export class UserOpSender { private logger: Logger @@ -224,7 +257,7 @@ export class UserOpSender { try { // Fetched before paymaster selection so the deposit gate can price the op's // max prefund; a failure here means nothing was submitted, safe to fall back. - fees = await this.getGasPrice(bundlerUrl, publicClient) + fees = await withTimeout(this.getGasPrice(bundlerUrl, publicClient), GAS_PRICE_TIMEOUT_MS, "pricing the UserOp") // Without explicit limits the fallbacks (~1.9M gas) over-require the deposit; // a false skip degrades to the caller's native fallback, which is safe — @@ -359,6 +392,17 @@ export class UserOpSender { userOpHash = sent } catch (error) { const outcome = error instanceof BundlerCallError ? error.outcome : "ambiguous" + if (outcome === "pending") { + // Refused because an earlier op holds this nonce, which says nothing about the + // calls in it: they may be that op's own. Falling back would send them again. + this.logger.warn({ chain, nonce, nonceKey, error }, "Bundler holds a pending UserOp on this nonce") + throw new PendingOpOnNonceError( + `An earlier sponsored op from this wallet is still pending on ${chain}; wait for it to land or expire before sending again`, + chain, + nonce, + nonceKey, + ) + } if (outcome !== "ambiguous") { // The bundler answered and refused the op, or the connection never reached it — // either way nothing entered the pool, safe to fall back. @@ -390,6 +434,7 @@ export class UserOpSender { `Sponsored UserOp ${localUserOpHash}: the bundler's answer was lost and the op could not be looked up`, chain, { kind: "userOp", userOpHash: localUserOpHash, nonce, nonceKey, expires, submittedAt }, + true, ) } // The bundler holds the op: proceed exactly as a successful send, from the locally @@ -626,12 +671,18 @@ export class UserOpSender { // the op. Both are ambiguous — reading them as rejected would resend natively. A // missing or non-numeric code, or any code outside the parse/rejection ranges, is // likewise ambiguous: when in doubt, keep the op. + // + // The exception is an op already pending on the nonce: rundler reports that as + // -32602 too, so it is told apart first. It is not checked against the HTTP status, + // since misreading it costs a double send and flagging it costs a retry. const rejected = response.status === 200 && isDeterministicRejectionCode((rpcError as { code?: unknown }).code) - throw new BundlerCallError( - `Bundler RPC error (${method}): ${message}`, - rejected ? "rejected" : "ambiguous", - ) + const outcome: BundlerCallOutcome = isPendingOnNonceError(rpcError) + ? "pending" + : rejected + ? "rejected" + : "ambiguous" + throw new BundlerCallError(`Bundler RPC error (${method}): ${message}`, outcome) } // A non-object `error` (a proxy's plain `{"error":"Bad Gateway"}`) is not a JSON-RPC // answer, so it stays ambiguous. diff --git a/sdk/packages/simplex/src/services/server/UiServer.ts b/sdk/packages/simplex/src/services/server/UiServer.ts index 6452d5eac2..635c7cdc70 100644 --- a/sdk/packages/simplex/src/services/server/UiServer.ts +++ b/sdk/packages/simplex/src/services/server/UiServer.ts @@ -40,7 +40,7 @@ import type { LimitOrderController } from "@/simplex" import type { BalanceProvider } from "../BalanceProvider" import type { BridgeTopUp, TopUpPayToken } from "../BridgeTopUp" import { getLogger, type LogLevel } from "../Logger" -import { SubmittedUnconfirmedError } from "../submission" +import { PendingOpOnNonceError, SubmittedUnconfirmedError } from "../submission" import { DEFAULT_TUNNEL_RELAY, parseRelayAddress, relayKey, type TunnelControls } from "../tunnel/TunnelService" import { readBody, @@ -2027,33 +2027,49 @@ export class UiServer { if (!(Number(body.amount) > 0)) { return sendJson(res, 400, { error: `Invalid amount: ${body.amount}` }) } - try { - const result = await op.send({ - chain: body.chain, - token: body.token, - amount: body.amount, - to: body.to as `0x${string}`, - }) - this.logger.warn({ ...body, ...result }, "Operator send submitted from the UI") + // Narrowed here, once, because the closure below loses the narrowing of `body`. + const { chain, token, amount, to } = { chain: body.chain, token: body.token, amount: body.amount, to: body.to } + const recordSend = async (txHash: string, sponsored: boolean) => { const symbol = - (this.sendTokenOptions(op)[body.chain] ?? []).find( - (option) => option.address.toLowerCase() === body.token!.toLowerCase(), - )?.symbol ?? body.token + (this.sendTokenOptions(op)[chain] ?? []).find( + (option) => option.address.toLowerCase() === token.toLowerCase(), + )?.symbol ?? token try { await op.activity.recordWalletTx({ kind: "send", - chainId: parseChainKey(body.chain), + chainId: parseChainKey(chain), token: symbol, - amount: body.amount, - to: body.to, - txHash: result.txHash, - sponsored: result.sponsored, + amount, + to, + txHash, + sponsored, }) } catch (err) { this.logger.warn({ err }, "Failed to record send in wallet history") } + } + try { + const result = await op.send({ + chain: body.chain, + token: body.token, + amount: body.amount, + to: body.to as `0x${string}`, + }) + this.logger.warn({ ...body, ...result }, "Operator send submitted from the UI") + await recordSend(result.txHash, result.sponsored) return sendJson(res, 200, result) } catch (err) { + if (err instanceof PendingOpOnNonceError) { + // Nothing new was sent: the bundler holds an earlier op from this wallet on the + // nonce and refused this one. That op may still land, so this is pending rather + // than a failure, and the message tells the operator to wait instead of retrying. + this.logger.warn({ ...body }, "Operator send refused: an earlier sponsored op is still pending") + return sendJson(res, 202, { + pending: true, + sponsored: true, + message: "An earlier send from this wallet is still pending; wait for it before sending again.", + }) + } if (err instanceof SubmittedUnconfirmedError) { // The send was broadcast but not confirmed within its wait. Reporting this as a // plain failure invites the operator to click again, which would submit a second @@ -2062,17 +2078,22 @@ export class UiServer { const sponsored = submission.kind === "userOp" const hash = sponsored ? submission.userOpHash : submission.txHash this.logger.warn({ ...body, sponsored, hash }, "Operator send submitted but not confirmed") - // No wallet-history row is recorded: WalletTx.txHash is required, and a UserOp - // whose receipt has not arrived has no transaction hash yet. + // A native tx has a hash from the moment the node accepted it, so it is recorded + // like a confirmed send. A sponsored UserOp has none until it lands (WalletTx.txHash + // is required), so it gets no row. + if (submission.kind === "tx") await recordSend(submission.txHash, false) return sendJson(res, 202, { pending: true, sponsored, ...(sponsored ? { userOpHash: hash } : { txHash: hash }), // A sponsored UserOp has no tx hash, so there is nothing to look up on the - // explorer; the operator watches the wallet balance or history instead. A native - // tx does have one, and its message says to check it before resending. + // explorer, and the wallet balance cannot show an op that is still in the bundler's + // pool. It may land at any time, so the operator waits before resending. A native + // tx does have a hash, and its message says to check it before resending. message: sponsored - ? "The send was submitted to the bundler but is not confirmed yet. Check the wallet balance or history in a few minutes before resending it." + ? err.maybeSubmitted + ? "The bundler did not answer, so the send may have been submitted. It may still land. Wait a few minutes and check the wallet history before resending it." + : "The send was submitted to the bundler but is not confirmed yet. It may still land; wait a few minutes and check the wallet history before resending it." : "The send was submitted but is not confirmed yet. Do not resend it until it has been checked on the explorer.", }) } diff --git a/sdk/packages/simplex/src/services/submission.ts b/sdk/packages/simplex/src/services/submission.ts index a598341b9b..38c54e52c4 100644 --- a/sdk/packages/simplex/src/services/submission.ts +++ b/sdk/packages/simplex/src/services/submission.ts @@ -15,12 +15,38 @@ export class SubmittedUnconfirmedError extends Error { /** Source state machine id the batch was submitted on, e.g. "EVM-8453". */ readonly chain: string, readonly submission: SubmittedUserOp | SubmittedTx, + /** + * True when the batch is not known to have been submitted at all: the bundler's answer + * was lost and every lookup failed too. The calls may or may not be in its pool, so a + * report must say "may have been submitted", not "was submitted". + */ + readonly maybeSubmitted = false, ) { super(message) this.name = "SubmittedUnconfirmedError" } } +/** + * The bundler refused a new op because an op from the same sender is already pending on that + * EntryPoint nonce ("replacement underpriced" or "already known"). Nothing new was submitted, but + * the earlier op may still land, so a caller must not read this as a rejection and send the same + * calls natively: that would pay for them twice. Retry once the earlier op has landed or expired. + */ +export class PendingOpOnNonceError extends Error { + constructor( + message: string, + /** Source state machine id the op was meant for, e.g. "EVM-8453". */ + readonly chain: string, + /** The EntryPoint nonce and key the refused op was signed with. */ + readonly nonce: bigint, + readonly nonceKey: bigint, + ) { + super(message) + this.name = "PendingOpOnNonceError" + } +} + /** A sponsored UserOp the bundler accepted but had no receipt for. */ export interface SubmittedUserOp { kind: "userOp" diff --git a/sdk/packages/simplex/src/tests/funding/vault.test.ts b/sdk/packages/simplex/src/tests/funding/vault.test.ts index 2cc0a9f727..1c90e2cd26 100644 --- a/sdk/packages/simplex/src/tests/funding/vault.test.ts +++ b/sdk/packages/simplex/src/tests/funding/vault.test.ts @@ -8,6 +8,7 @@ import type { ChainClientManager } from "@/services/ChainClientManager" import type { DelegationService } from "@/services/DelegationService" import type { UserOpSender } from "@/services/UserOpSender" import { addLogSink } from "@/services/Logger" +import { PendingOpOnNonceError } from "@/services/submission" import type { HexString } from "@hyperbridge/sdk" const CHAIN = "EVM-8453" @@ -590,6 +591,18 @@ describe("VaultFundingPlanner — paymaster-sponsored sweep", () => { expect(sendTransaction).toHaveBeenCalledOnce() }) + it("stalls, rather than sending natively, while an earlier op holds the nonce", async () => { + const { sender, trySendSponsored } = makeSender({ canSponsor: true }) + trySendSponsored.mockRejectedValue(new PendingOpOnNonceError("pending", CHAIN, 0n, 0n)) + const { planner, sendTransaction } = makeSweepPlanner(sweepWallet, sweepVault, {}, undefined, sender) + await planner.initialise(SOLVER) + + await expect(planner.sweepExcessToVault(CHAIN)).rejects.toBeInstanceOf(PendingOpOnNonceError) + + expect(trySendSponsored).toHaveBeenCalledOnce() + expect(sendTransaction).not.toHaveBeenCalled() + }) + it("uses a native tx when the chain cannot be sponsored", async () => { const { sender, trySendSponsored } = makeSender({ canSponsor: false }) const { planner, sendTransaction } = makeSweepPlanner(sweepWallet, sweepVault, {}, undefined, sender) diff --git a/sdk/packages/simplex/src/tests/services/DelegationService.ordering.test.ts b/sdk/packages/simplex/src/tests/services/DelegationService.ordering.test.ts index 3cf6538af6..828cdae774 100644 --- a/sdk/packages/simplex/src/tests/services/DelegationService.ordering.test.ts +++ b/sdk/packages/simplex/src/tests/services/DelegationService.ordering.test.ts @@ -3,6 +3,7 @@ import { encodeFunctionData, erc20Abi, maxUint256 } from "viem" import { encodeERC7821ExecuteBatch, type HexString } from "@hyperbridge/sdk" import { DelegationService } from "@/services/DelegationService" +import { PendingOpOnNonceError } from "@/services/submission" import { resolvePendingPermit2Approval } from "@/services/paymaster/provider/simplex" import type { ChainClientManager } from "@/services/ChainClientManager" import type { FillerConfigService } from "@/services/FillerConfigService" @@ -133,6 +134,17 @@ describe("setupDelegation ordering", () => { expect(sendTransaction).not.toHaveBeenCalled() }) + it("reports not-now, and sends no direct tx, while an earlier sponsored op holds the nonce", async () => { + vi.mocked(resolvePendingPermit2Approval).mockResolvedValue(null) + trySendSponsored.mockRejectedValue(new PendingOpOnNonceError("pending", CHAIN, 0n, 0n)) + const { service, sendTransaction } = build({ native: DIRECT_TX_COST }) + + expect(await service.setupDelegation(CHAIN)).toBe(false) + + expect(trySendSponsored).toHaveBeenCalledOnce() + expect(sendTransaction).not.toHaveBeenCalled() + }) + it("bootstraps a permit-capable token through the bundler even when native covers a direct tx", async () => { vi.mocked(resolvePendingPermit2Approval).mockResolvedValue({ token: USDC, diff --git a/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts b/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts index 0eafbe488b..c347e1c5cc 100644 --- a/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts +++ b/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts @@ -3,7 +3,7 @@ import { toHex } from "viem" import { CryptoUtils, rundlerUserOperationFees, type HexString } from "@hyperbridge/sdk" import { UserOpSender, type Eip7702Authorization, type SponsoredUserOpRequest } from "@/services/UserOpSender" -import { SubmittedUnconfirmedError } from "@/services/submission" +import { PendingOpOnNonceError, SubmittedUnconfirmedError } from "@/services/submission" import { buildPaymasterAndData } from "@/services/paymaster" import { packPaymasterAndData, VERIFICATION_GAS_LIMIT_PERMIT2, POST_OP_GAS_LIMIT_SIMPLEX } from "@/services/paymaster/types" import type { ChainClientManager } from "@/services/ChainClientManager" @@ -247,6 +247,23 @@ describe("UserOpSender gas price", () => { expect(sentFees()).toEqual([fees]) }) + it("gives up on a bundler that never answers the fee lookup, before anything is sent", async () => { + vi.stubGlobal("fetch", (_url: unknown, init?: { body?: string }) => { + const { method } = JSON.parse(init?.body ?? "{}") as { method: string } + bundlerCalls.push({ method, params: [] }) + return new Promise(() => {}) + }) + vi.useFakeTimers() + + const promise = sponsorWith("http://hung-fees.test/bundler") + await vi.advanceTimersByTimeAsync(10_000) + + // Never submitted, so the caller may fall back. + await expect(promise).resolves.toBeNull() + expect(bundlerCalls.map((c) => c.method)).toEqual(["rundler_getUserOperationGasPrice"]) + vi.useRealTimers() + }) + it("keeps the chain estimate and stops asking when the bundler does not serve rundler fees", async () => { bundlerReplies.rundler_getUserOperationGasPrice = METHOD_NOT_FOUND bundlerReplies.rundler_maxPriorityFeePerGas = METHOD_NOT_FOUND @@ -431,6 +448,49 @@ describe("UserOpSender ambiguous send outcome", () => { expect(bundlerCalls.some((c) => c.method === "eth_getUserOperationByHash")).toBe(false) }) + it("throws PendingOpOnNonceError, not null, for a -32602 replacement-underpriced answer", async () => { + stubBundler({ + eth_sendUserOperation: { + error: { + code: -32602, + message: "replacement underpriced", + data: { currentMaxPriorityFee: "0x3b9aca00", currentMaxFee: "0x77359400" }, + }, + }, + }) + + const error = await sponsorWith("http://pending-replacement.test/bundler").catch((err) => err) + + expect(error).toBeInstanceOf(PendingOpOnNonceError) + expect(error).toMatchObject({ chain: CHAIN, nonce: 0n, nonceKey: 0n }) + expect(bundlerCalls.some((c) => c.method === "eth_getUserOperationByHash")).toBe(false) + }) + + it("throws PendingOpOnNonceError for a -32602 whose data carries only one replacement field", async () => { + stubBundler({ eth_sendUserOperation: { error: { code: -32602, message: "bad", data: { currentMaxFee: "0x1" } } } }) + + await expect(sponsorWith("http://pending-data.test/bundler")).rejects.toBeInstanceOf(PendingOpOnNonceError) + }) + + it("throws PendingOpOnNonceError for a -32602 'already known' answer", async () => { + stubBundler({ eth_sendUserOperation: { error: { code: -32602, message: "already known" } } }) + + await expect(sponsorWith("http://pending-known.test/bundler")).rejects.toBeInstanceOf(PendingOpOnNonceError) + }) + + it("still returns null for a plain -32602 invalid-params rejection", async () => { + stubBundler({ eth_sendUserOperation: { error: { code: -32602, message: "invalid params: missing sender" } } }) + + await expect(sponsorWith("http://invalid-params.test/bundler")).resolves.toBeNull() + expect(bundlerCalls.some((c) => c.method === "eth_getUserOperationByHash")).toBe(false) + }) + + it("does not read 'already known' under another code as a pending op", async () => { + stubBundler({ eth_sendUserOperation: { error: { code: -32500, message: "already known" } } }) + + await expect(sponsorWith("http://known-other-code.test/bundler")).resolves.toBeNull() + }) + it("returns null when the connection cannot be established (nothing was sent)", async () => { const refused = Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } }) stubBundler({ eth_sendUserOperation: refused }) @@ -477,6 +537,8 @@ describe("UserOpSender ambiguous send outcome", () => { const error = await promise expect(error).toBeInstanceOf(SubmittedUnconfirmedError) + // Nothing proves the op reached the bundler, so the report must say "may have". + expect(error.maybeSubmitted).toBe(true) expect(error.message).toContain("the bundler's answer was lost and the op could not be looked up") expect(error.submission).toMatchObject({ kind: "userOp", nonce: 0n, nonceKey: 0n, expires: true }) expect(error.submission.submittedAt).toBeTypeOf("number") @@ -596,6 +658,8 @@ describe("UserOpSender ambiguous send outcome", () => { const error = await promise expect(error).toBeInstanceOf(SubmittedUnconfirmedError) + // The bundler confirmed it holds the op, so this one is submitted, not "maybe". + expect(error.maybeSubmitted).toBe(false) expect(error.chain).toBe(CHAIN) const [sentOp] = bundlerCalls.filter((c) => c.method === "eth_sendUserOperation")[0].params as [ Record, diff --git a/sdk/packages/simplex/src/tests/token-sender.test.ts b/sdk/packages/simplex/src/tests/token-sender.test.ts index f36cfa3d2a..937505eb6c 100644 --- a/sdk/packages/simplex/src/tests/token-sender.test.ts +++ b/sdk/packages/simplex/src/tests/token-sender.test.ts @@ -13,7 +13,7 @@ import { ERC20_ABI } from "@/config/abis/ERC20" import { ERC4626_ABI } from "@/config/abis/Erc4626" import { TokenSender } from "@/services/TokenSender" import { UserOpSender } from "@/services/UserOpSender" -import { SubmittedUnconfirmedError, type SubmittedUserOp } from "@/services/submission" +import { PendingOpOnNonceError, SubmittedUnconfirmedError, type SubmittedUserOp } from "@/services/submission" import { buildPaymasterAndData } from "@/services/paymaster" import { packPaymasterAndData, @@ -653,4 +653,34 @@ describe("TokenSender sponsored send with a lost bundler response", () => { expect(fetchMethods).toContain("eth_getUserOperationByHash") expect(sendTransaction).not.toHaveBeenCalled() }) + + it("throws PendingOpOnNonceError and never sends natively when an earlier op holds the nonce", async () => { + // A resend of a still-pending send: rundler answers it -32602, the code of a malformed request. + stubFetch({ + eth_sendUserOperation: { + error: { + code: -32602, + message: "replacement underpriced", + data: { currentMaxPriorityFee: "0x1", currentMaxFee: "0x2" }, + }, + }, + }) + const { sender, sendTransaction } = makeRealSender("http://tok-pending.test/bundler") + + const error = await sender.submitCalls("EVM-8453", [transfer]).catch((err) => err) + + expect(error).toBeInstanceOf(PendingOpOnNonceError) + expect(error).not.toBeInstanceOf(SubmittedUnconfirmedError) + expect(sendTransaction).not.toHaveBeenCalled() + // It is a definite answer, so there is nothing to look up. + expect(fetchMethods).not.toContain("eth_getUserOperationByHash") + }) + + it("still falls back to a native tx on a plain -32602 invalid-params rejection", async () => { + stubFetch({ eth_sendUserOperation: { error: { code: -32602, message: "invalid params: sender" } } }) + const { sender, sendTransaction } = makeRealSender("http://tok-invalid.test/bundler") + + expect(await sender.submitCalls("EVM-8453", [transfer])).toMatchObject({ txHash: "0xnative", sponsored: false }) + expect(sendTransaction).toHaveBeenCalledOnce() + }) }) diff --git a/sdk/packages/simplex/src/tests/ui-server.test.ts b/sdk/packages/simplex/src/tests/ui-server.test.ts index dff100ee92..d38d0f203d 100644 --- a/sdk/packages/simplex/src/tests/ui-server.test.ts +++ b/sdk/packages/simplex/src/tests/ui-server.test.ts @@ -6,7 +6,7 @@ import { import { EventEmitter } from "node:events" import type { SetupDeps } from "@/services/server/setup-api" import { ActivityRecorder } from "@/data/recorder" -import { SubmittedUnconfirmedError } from "@/services/submission" +import { PendingOpOnNonceError, SubmittedUnconfirmedError } from "@/services/submission" import { MemoryDataStore } from "@/data/memory" import { LoggerContext, type LogLevel } from "@/services/Logger" import { LogStore } from "@/services/server/LogStore" @@ -916,7 +916,9 @@ describe("UiServer (operator mode)", () => { const payload = await res.json() expect(payload).toMatchObject({ pending: true, sponsored: true, userOpHash }) expect(payload.message).toContain("not confirmed") - // A sponsored UserOp has no explorer entry point, so its message points at the wallet. + // A balance check cannot show an op still in the pool, so the message says it may land and to wait. + expect(payload.message).toContain("may still land") + expect(payload.message).toContain("wallet history") expect(payload.message).toContain("bundler") expect(payload.message).not.toContain("explorer") // A UserOp has no tx hash until it lands, so none is reported — and no history row is written. @@ -931,7 +933,56 @@ describe("UiServer (operator mode)", () => { expect((await failed.json()).error).toContain("Insufficient") }) - it("reports an unconfirmed native send as pending with its tx hash", async () => { + it("says a send may have been submitted when the bundler could not be asked at all", async () => { + const userOpHash = ("0x" + "ab".repeat(32)) as `0x${string}` + const send = vi.fn().mockRejectedValue( + new SubmittedUnconfirmedError( + "lost", + "EVM-8453", + { kind: "userOp", userOpHash, nonce: 0n, nonceKey: 0n, expires: true, submittedAt: Date.now() }, + true, + ), + ) + const { base } = await startServer({ send }) + const body = { + chain: "EVM-8453", + token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + amount: "25", + to: "0x5b2c3e25243634732eE94525Ae98aEc404c82506", + } + + const res = await fetch(`${base}/api/send`, { method: "POST", headers: CSRF, body: JSON.stringify(body) }) + + expect(res.status).toBe(202) + const payload = await res.json() + expect(payload).toMatchObject({ pending: true, sponsored: true, userOpHash }) + expect(payload.message).toContain("may have been submitted") + }) + + it("answers a send refused by a pending earlier op with a wait message and no hash", async () => { + const send = vi.fn().mockRejectedValue(new PendingOpOnNonceError("pending", "EVM-8453", 3n, 0n)) + const { base } = await startServer({ send }) + const body = { + chain: "EVM-8453", + token: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + amount: "25", + to: "0x5b2c3e25243634732eE94525Ae98aEc404c82506", + } + + const res = await fetch(`${base}/api/send`, { method: "POST", headers: CSRF, body: JSON.stringify(body) }) + + expect(res.status).toBe(202) + const payload = await res.json() + expect(payload).toEqual({ + pending: true, + sponsored: true, + message: "An earlier send from this wallet is still pending; wait for it before sending again.", + }) + const history = await (await fetch(`${base}/api/wallet/history`)).json() + expect(history.txs).toHaveLength(0) + }) + + it("reports an unconfirmed native send as pending with its tx hash and records it", async () => { const txHash = ("0x" + "cd".repeat(32)) as `0x${string}` const send = vi .fn() @@ -953,6 +1004,9 @@ describe("UiServer (operator mode)", () => { expect(payload).toMatchObject({ pending: true, sponsored: false, txHash }) // A native tx has a hash to check, so its message keeps the explorer wording. expect(payload.message).toContain("explorer") + // The hash is known, so the send is recorded in the wallet history like a confirmed one. + const history = await (await fetch(`${base}/api/wallet/history`)).json() + expect(history.txs).toMatchObject([{ txHash, sponsored: false, kind: "send" }]) }) it("does not list vault shares among send token options", async () => { From 2252e194d60f153a00d5ed7bf43c02feeabb1041 Mon Sep 17 00:00:00 2001 From: Seun Lanlege Date: Mon, 19 Oct 2026 13:15:23 +0000 Subject: [PATCH 3/4] [simplex]: address second review on #1413 --- docs/content/developers/evm/simplex/api/wallet.mdx | 2 +- .../docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md | 6 ++++-- sdk/packages/simplex/src/services/server/UiServer.ts | 2 +- .../simplex/src/tests/services/UserOpSender.test.ts | 5 ++++- sdk/packages/simplex/src/tests/ui-server.test.ts | 2 +- 5 files changed, 11 insertions(+), 6 deletions(-) diff --git a/docs/content/developers/evm/simplex/api/wallet.mdx b/docs/content/developers/evm/simplex/api/wallet.mdx index 8f56b5356d..3b41c4694c 100644 --- a/docs/content/developers/evm/simplex/api/wallet.mdx +++ b/docs/content/developers/evm/simplex/api/wallet.mdx @@ -173,7 +173,7 @@ 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 before sending again. - **Possibly submitted.** The bundler did not answer the send or any of the lookups after it, so the send *may have been submitted*. The message says so. This is what an outage of the bundler looks like. -- **Earlier send still pending.** The bundler refused the send because an earlier UserOperation from this wallet 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 send from this wallet is still pending; wait for it 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. +- **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. ## PUT /api/vault diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md b/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md index e933b5e22f..d35229350c 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md @@ -87,8 +87,10 @@ stays a 400. - A sponsored UserOp's message says it may still land and to wait before resending: the wallet 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 send from this wallet is still pending; wait for it - before sending again" with no hash, since nothing new was submitted. +- 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 diff --git a/sdk/packages/simplex/src/services/server/UiServer.ts b/sdk/packages/simplex/src/services/server/UiServer.ts index 635c7cdc70..cc20d0bdaa 100644 --- a/sdk/packages/simplex/src/services/server/UiServer.ts +++ b/sdk/packages/simplex/src/services/server/UiServer.ts @@ -2067,7 +2067,7 @@ export class UiServer { return sendJson(res, 202, { pending: true, sponsored: true, - message: "An earlier send from this wallet is still pending; wait for it before sending again.", + message: "An earlier sponsored operation from this wallet is still pending; wait for it to land before sending again.", }) } if (err instanceof SubmittedUnconfirmedError) { diff --git a/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts b/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts index c347e1c5cc..f7be15c327 100644 --- a/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts +++ b/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts @@ -222,6 +222,10 @@ describe("UserOpSender gas price", () => { const rundlerCalls = () => bundlerCalls.filter((c) => c.method.startsWith("rundler_")).map((c) => c.method) const METHOD_NOT_FOUND = { error: { code: -32601, message: "Method not found" } } + afterEach(() => { + vi.useRealTimers() + }) + it("prices a rundler bundler from the fees it suggests, raised by the bumps", async () => { bundlerReplies.rundler_getUserOperationGasPrice = { result: { suggested: { maxPriorityFeePerGas: toHex(2_000_000_000n), maxFeePerGas: toHex(5_000_000_000n) } }, @@ -261,7 +265,6 @@ describe("UserOpSender gas price", () => { // Never submitted, so the caller may fall back. await expect(promise).resolves.toBeNull() expect(bundlerCalls.map((c) => c.method)).toEqual(["rundler_getUserOperationGasPrice"]) - vi.useRealTimers() }) it("keeps the chain estimate and stops asking when the bundler does not serve rundler fees", async () => { diff --git a/sdk/packages/simplex/src/tests/ui-server.test.ts b/sdk/packages/simplex/src/tests/ui-server.test.ts index d38d0f203d..6aa08433c6 100644 --- a/sdk/packages/simplex/src/tests/ui-server.test.ts +++ b/sdk/packages/simplex/src/tests/ui-server.test.ts @@ -976,7 +976,7 @@ describe("UiServer (operator mode)", () => { expect(payload).toEqual({ pending: true, sponsored: true, - message: "An earlier send from this wallet is still pending; wait for it before sending again.", + message: "An earlier sponsored operation from this wallet is still pending; wait for it to land before sending again.", }) const history = await (await fetch(`${base}/api/wallet/history`)).json() expect(history.txs).toHaveLength(0) From aa7c9b815f0d5c903567bca76df845d1ace91d07 Mon Sep 17 00:00:00 2001 From: Seun Lanlege Date: Tue, 20 Oct 2026 11:28:03 +0000 Subject: [PATCH 4/4] [simplex]: address third review on #1413 --- .../developers/evm/simplex/api/wallet.mdx | 12 +++--- .../2026-10-16-ambiguous-bundler-sends.md | 19 +++++--- .../simplex/src/services/UserOpSender.ts | 43 ++++++++++++++++--- .../simplex/src/services/server/UiServer.ts | 12 +++--- .../simplex/src/services/submission.ts | 15 ++++++- .../src/tests/services/UserOpSender.test.ts | 9 +++- .../simplex/src/tests/ui-server.test.ts | 10 ++++- 7 files changed, 94 insertions(+), 26 deletions(-) diff --git a/docs/content/developers/evm/simplex/api/wallet.mdx b/docs/content/developers/evm/simplex/api/wallet.mdx index 3b41c4694c..003fe70e72 100644 --- a/docs/content/developers/evm/simplex/api/wallet.mdx +++ b/docs/content/developers/evm/simplex/api/wallet.mdx @@ -150,14 +150,14 @@ A send that cannot go through answers `400` with the reason, such as an insuffic ### 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. +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 but is not confirmed yet. It may still land; wait a few minutes and check the wallet history before resending it." + "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." } ``` @@ -165,15 +165,15 @@ A send that is not confirmed yet answers `202`, not `400`, because it may still |---|---| | `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 it is not recorded in the wallet history. | +| `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 before sending again. -- **Possibly submitted.** The bundler did not answer the send or any of the lookups after it, so the send *may have been submitted*. The message says so. This is what an outage of the bundler looks like. -- **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. +- **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 diff --git a/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md b/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md index d35229350c..722a8b4012 100644 --- a/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md +++ b/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md @@ -39,7 +39,14 @@ sender is already pending on that EntryPoint nonce: `ReplacementUnderpriced` car `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. +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` @@ -71,8 +78,8 @@ suffix. ## A bundler outage -When every lookup 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 +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. @@ -84,8 +91,10 @@ 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 says it may still land and to wait before resending: the wallet - balance cannot show an op still in the bundler's pool. When the outcome is `unknown` it says the +- 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 diff --git a/sdk/packages/simplex/src/services/UserOpSender.ts b/sdk/packages/simplex/src/services/UserOpSender.ts index 10446ec79d..e74dfe075d 100644 --- a/sdk/packages/simplex/src/services/UserOpSender.ts +++ b/sdk/packages/simplex/src/services/UserOpSender.ts @@ -172,6 +172,11 @@ function isDeterministicRejectionCode(code: unknown): boolean { * `currentMaxPriorityFee`/`currentMaxFee` data, or the "replacement underpriced" / "already known" * message, that does. Read as a rejection it would let the caller send the same calls natively * while the earlier op can still land. + * + * This recognises rundler's wording only, because production runs rundler. Alto reports the same + * situation as "already present in mempool", which neither pattern below matches, so it would + * read as a rejection and allow the native resend. Moving to another bundler means adding its + * wording here first. */ function isPendingOnNonceError(rpcError: { code?: unknown; message?: unknown; data?: unknown }): boolean { if (rpcError.code !== -32602) return false @@ -182,6 +187,24 @@ function isPendingOnNonceError(rpcError: { code?: unknown; message?: unknown; da return typeof rpcError.message === "string" && /already known|replacement underpriced/i.test(rpcError.message) } +/** + * The fees of the op holding the nonce, when rundler's `ReplacementUnderpriced` error carries + * them. They show an operator what is blocking: an op priced too low to be mined sits in the pool + * and refuses every later op on the nonce. + */ +function blockingOpFees(rpcError: unknown): PendingOpOnNonceError["blockingFees"] { + const data = (rpcError as { data?: unknown } | null | undefined)?.data as + | { currentMaxFee?: unknown; currentMaxPriorityFee?: unknown } + | null + | undefined + if (!data || typeof data !== "object") return undefined + const fees = { + ...(typeof data.currentMaxFee === "string" ? { currentMaxFee: data.currentMaxFee } : {}), + ...(typeof data.currentMaxPriorityFee === "string" ? { currentMaxPriorityFee: data.currentMaxPriorityFee } : {}), + } + return Object.keys(fees).length > 0 ? fees : undefined +} + /** Rejects if `promise` has not settled within `ms`. */ function withTimeout(promise: Promise, ms: number, what: string): Promise { let timer: NodeJS.Timeout | undefined @@ -395,12 +418,20 @@ export class UserOpSender { if (outcome === "pending") { // Refused because an earlier op holds this nonce, which says nothing about the // calls in it: they may be that op's own. Falling back would send them again. - this.logger.warn({ chain, nonce, nonceKey, error }, "Bundler holds a pending UserOp on this nonce") + const blockingFees = blockingOpFees((error as BundlerCallError).reason) + this.logger.warn( + { chain, nonce, nonceKey, blockingFees, error }, + "Bundler holds a pending UserOp on this nonce", + ) throw new PendingOpOnNonceError( - `An earlier sponsored op from this wallet is still pending on ${chain}; wait for it to land or expire before sending again`, + `An earlier sponsored op from this wallet is still pending on ${chain}; wait for it to land or expire before sending again` + + (blockingFees + ? ` (it pays currentMaxFee ${blockingFees.currentMaxFee ?? "unknown"}, currentMaxPriorityFee ${blockingFees.currentMaxPriorityFee ?? "unknown"})` + : ""), chain, nonce, nonceKey, + blockingFees, ) } if (outcome !== "ambiguous") { @@ -428,8 +459,8 @@ export class UserOpSender { return null } if (found === "unknown") { - // Every lookup failed too, so we cannot tell whether the op was accepted. The - // caller must not fall back — a resend could execute the calls twice. + // No lookup found the op and at least one failed, so we cannot tell whether it was + // accepted. The caller must not fall back — a resend could execute the calls twice. throw new SubmittedUnconfirmedError( `Sponsored UserOp ${localUserOpHash}: the bundler's answer was lost and the op could not be looked up`, chain, @@ -682,7 +713,9 @@ export class UserOpSender { : rejected ? "rejected" : "ambiguous" - throw new BundlerCallError(`Bundler RPC error (${method}): ${message}`, outcome) + // The error object rides along as `reason` so a `pending` outcome can report the fees + // of the op in the way. + throw new BundlerCallError(`Bundler RPC error (${method}): ${message}`, outcome, rpcError) } // A non-object `error` (a proxy's plain `{"error":"Bad Gateway"}`) is not a JSON-RPC // answer, so it stays ambiguous. diff --git a/sdk/packages/simplex/src/services/server/UiServer.ts b/sdk/packages/simplex/src/services/server/UiServer.ts index cc20d0bdaa..3ead81ae20 100644 --- a/sdk/packages/simplex/src/services/server/UiServer.ts +++ b/sdk/packages/simplex/src/services/server/UiServer.ts @@ -2087,13 +2087,15 @@ export class UiServer { sponsored, ...(sponsored ? { userOpHash: hash } : { txHash: hash }), // A sponsored UserOp has no tx hash, so there is nothing to look up on the - // explorer, and the wallet balance cannot show an op that is still in the bundler's - // pool. It may land at any time, so the operator waits before resending. A native - // tx does have a hash, and its message says to check it before resending. + // explorer and, as the comment above says, no wallet-history row to check. The + // evidence that exists is the UserOp hash, which the bundler can be asked about, + // and the balances once the op lands. A balance cannot show an op still in the + // pool, so the operator waits before looking. A native tx does have a hash, and + // its message says to check it before resending. message: sponsored ? err.maybeSubmitted - ? "The bundler did not answer, so the send may have been submitted. It may still land. Wait a few minutes and check the wallet history before resending it." - : "The send was submitted to the bundler but is not confirmed yet. It may still land; wait a few minutes and check the wallet history before resending it." + ? `The bundler did not answer, so the send may have been submitted as UserOp ${hash}. 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.` + : `The send was submitted to the bundler as UserOp ${hash} 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.` : "The send was submitted but is not confirmed yet. Do not resend it until it has been checked on the explorer.", }) } diff --git a/sdk/packages/simplex/src/services/submission.ts b/sdk/packages/simplex/src/services/submission.ts index 38c54e52c4..cc8e3e7ff6 100644 --- a/sdk/packages/simplex/src/services/submission.ts +++ b/sdk/packages/simplex/src/services/submission.ts @@ -17,8 +17,9 @@ export class SubmittedUnconfirmedError extends Error { readonly submission: SubmittedUserOp | SubmittedTx, /** * True when the batch is not known to have been submitted at all: the bundler's answer - * was lost and every lookup failed too. The calls may or may not be in its pool, so a - * report must say "may have been submitted", not "was submitted". + * was lost, no lookup found the op, and at least one lookup failed rather than answering + * "unknown op". The calls may or may not be in its pool, so a report must say "may have + * been submitted", not "was submitted". */ readonly maybeSubmitted = false, ) { @@ -32,6 +33,10 @@ export class SubmittedUnconfirmedError extends Error { * EntryPoint nonce ("replacement underpriced" or "already known"). Nothing new was submitted, but * the earlier op may still land, so a caller must not read this as a rejection and send the same * calls natively: that would pay for them twice. Retry once the earlier op has landed or expired. + * + * A PERMIT bootstrap op has no `validUntil`, so one that sits in the pool (rundler's + * `max_time_in_pool` is unset by default) refuses every key-0 sponsored op until rundler drops it. + * `blockingFees` shows what is in the way when rundler reports it. */ export class PendingOpOnNonceError extends Error { constructor( @@ -41,6 +46,12 @@ export class PendingOpOnNonceError extends Error { /** The EntryPoint nonce and key the refused op was signed with. */ readonly nonce: bigint, readonly nonceKey: bigint, + /** + * The fees rundler reported for the op holding the nonce (`currentMaxFee`, + * `currentMaxPriorityFee`, as the hex strings it sent). Absent when the refusal was + * "already known", which carries none. + */ + readonly blockingFees?: { currentMaxFee?: string; currentMaxPriorityFee?: string }, ) { super(message) this.name = "PendingOpOnNonceError" diff --git a/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts b/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts index f7be15c327..10b4d2cd1e 100644 --- a/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts +++ b/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts @@ -466,6 +466,9 @@ describe("UserOpSender ambiguous send outcome", () => { expect(error).toBeInstanceOf(PendingOpOnNonceError) expect(error).toMatchObject({ chain: CHAIN, nonce: 0n, nonceKey: 0n }) + // The fees of the op in the way travel with the error so an operator can see what blocks. + expect(error.blockingFees).toEqual({ currentMaxFee: "0x77359400", currentMaxPriorityFee: "0x3b9aca00" }) + expect(error.message).toContain("0x77359400") expect(bundlerCalls.some((c) => c.method === "eth_getUserOperationByHash")).toBe(false) }) @@ -478,7 +481,11 @@ describe("UserOpSender ambiguous send outcome", () => { it("throws PendingOpOnNonceError for a -32602 'already known' answer", async () => { stubBundler({ eth_sendUserOperation: { error: { code: -32602, message: "already known" } } }) - await expect(sponsorWith("http://pending-known.test/bundler")).rejects.toBeInstanceOf(PendingOpOnNonceError) + const error = await sponsorWith("http://pending-known.test/bundler").catch((err) => err) + + expect(error).toBeInstanceOf(PendingOpOnNonceError) + // "already known" carries no fee data. + expect(error.blockingFees).toBeUndefined() }) it("still returns null for a plain -32602 invalid-params rejection", async () => { diff --git a/sdk/packages/simplex/src/tests/ui-server.test.ts b/sdk/packages/simplex/src/tests/ui-server.test.ts index 6aa08433c6..aeadb4e251 100644 --- a/sdk/packages/simplex/src/tests/ui-server.test.ts +++ b/sdk/packages/simplex/src/tests/ui-server.test.ts @@ -918,8 +918,12 @@ describe("UiServer (operator mode)", () => { expect(payload.message).toContain("not confirmed") // A balance check cannot show an op still in the pool, so the message says it may land and to wait. expect(payload.message).toContain("may still land") - expect(payload.message).toContain("wallet history") - expect(payload.message).toContain("bundler") + // A sponsored send has no wallet-history row, so the message must not send the operator + // there; it names the UserOp hash to look up instead. + expect(payload.message).not.toContain("wallet history") + expect(payload.message).toContain(userOpHash) + expect(payload.message).toContain("look that UserOp up on the bundler") + expect(payload.message).toContain("recipient's balance") expect(payload.message).not.toContain("explorer") // A UserOp has no tx hash until it lands, so none is reported — and no history row is written. expect(payload.txHash).toBeUndefined() @@ -957,6 +961,8 @@ describe("UiServer (operator mode)", () => { const payload = await res.json() expect(payload).toMatchObject({ pending: true, sponsored: true, userOpHash }) expect(payload.message).toContain("may have been submitted") + expect(payload.message).toContain(userOpHash) + expect(payload.message).not.toContain("wallet history") }) it("answers a send refused by a pending earlier op with a wait message and no hash", async () => {