diff --git a/docs/content/developers/evm/simplex/api/wallet.mdx b/docs/content/developers/evm/simplex/api/wallet.mdx index 3fe4b0dba..003fe70e7 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. For a sponsored send, check the UserOperation by its hash, not the wallet history: a pending sponsored send has no history row. + +```json lineNumbers +{ + "pending": true, + "sponsored": true, + "userOpHash": "0x…", + "message": "The send was submitted to the bundler as UserOp 0x… but is not confirmed yet. It may still land; wait a few minutes, then look that UserOp up on the bundler or check the wallet's and the recipient's balance before resending it." +} +``` + +| Field | Description | +|---|---| +| `pending` | Always `true`. | +| `sponsored` | `true` when the send is a paymaster-sponsored UserOperation, `false` for a plain transaction. | +| `userOpHash` | The UserOperation's hash, for a sponsored send that was submitted. A UserOperation has no transaction hash until it lands, so a sponsored send that answers `202` gets no wallet-history row, and none is added when it lands later. Ask the bundler for it with `eth_getUserOperationByHash`, or compare the wallet's and the recipient's balances once it has had time to land. | +| `txHash` | The transaction's hash. Present only when `sponsored` is `false`. Look it up on the block explorer. A pending plain transaction is recorded in the wallet history like a confirmed one. | +| `message` | What to do next. | + +A `202` takes one of three forms: + +- **Submitted, no receipt yet.** The bundler accepted the UserOperation, or the node accepted the transaction, but no receipt came within the wait. It may land at any time. A wallet balance cannot show a UserOperation that is still in the bundler's pool, so wait a few minutes, then look the UserOperation up by `userOpHash` or compare balances before sending again. +- **Possibly submitted.** The bundler's answer to the send was lost and none of the lookups after it found the UserOperation, and at least one did not answer at all. The send *may have been submitted*, and the message says so. This is what an outage of the bundler looks like. The message names the UserOperation's hash, which is also `userOpHash`. +- **Earlier operation still pending.** The bundler refused the send because an earlier sponsored operation from this wallet (a send, a vault sweep or redeem, or a delegation setup) is still waiting on the same nonce (it answers "replacement underpriced" or "already known"). Nothing new was submitted, so the response has no hash, and its message is "An earlier sponsored operation from this wallet is still pending; wait for it to land before sending again." The send is never repeated as a plain transaction, because the earlier one could still land and the same tokens would leave twice. A bootstrap operation that approves Permit2 for the wallet has no expiry, so if the bundler keeps it in its pool, every sponsored send is refused this way until the bundler drops it. + ## PUT /api/vault Replaces the vault treasury. The body is the whole list of vaults, in the same shape as `[[vault.vaults]]` in the config. An empty list turns the treasury off. 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 000000000..722a8b401 --- /dev/null +++ b/sdk/packages/simplex/docs/ai/changelog/2026-10-16-ambiguous-bundler-sends.md @@ -0,0 +1,106 @@ +# 2026-10-16 — A lost bundler send response is resolved before the caller may resend natively + +`eth_sendUserOperation` can fail after the bundler accepted the op: a gateway 504, an HTML error +page, a dropped socket, or the request timeout. `UserOpSender` used to treat any such failure as +"never submitted" and return `null`, which every caller reads as permission to send the same calls +again as a native transaction — paying twice for one dashboard send, or double-executing a top-up +placement or a vault batch. + +## Every bundler request is classified + +`UserOpSender` funnels its bundler calls (send, estimate, both receipt polls, the by-hash lookup) +through one helper. Each request is bounded — 30 s for the send and estimate, which run the +validation simulation, and 10 s for the polls — and a call that returns no result throws a +`BundlerCallError` whose `outcome` is one of: + +- `rejected` — an HTTP 200 whose JSON-RPC `error.code` is a deterministic rejection (`-32700`, + `-32600`…`-32602`, or `-32599`…`-32500`, the ERC-4337/ERC-7769 range which includes `-32521`). + A non-200 status with any body, `-32603`, a missing or non-numeric code, or any other code is + *not* a definite refusal. +- `unreachable` — `fetch` failed in the connect phase, so no request bytes were delivered: + `ECONNREFUSED`, `ENOTFOUND`, `EAI_AGAIN`, `EHOSTUNREACH`, `ENETUNREACH`, + `UND_ERR_CONNECT_TIMEOUT`, or a TLS certificate-verification failure. +- `pending` — see the next section. +- `ambiguous` — everything else. + +The gas-price lookup is not one of these calls: it goes through the SDK's +`rundlerUserOperationFees`, whose `fetch` has no timeout, so `UserOpSender` bounds the whole lookup +at 10 s. It runs before anything is signed, so a timeout there is "never submitted". + +Only `rejected` and `unreachable` count as "not submitted". The rule is deliberate: a proxy's JSON +502/504 forwarded the request, and rundler's -32603 can arrive after the pool admitted the op, so +those stay ambiguous; misclassifying an ambiguous failure as decided costs at most one lookup, +while the reverse costs a double send. + +## An op already pending on the nonce is not a rejection + +Rundler answers a send with `-32602` (the code of a malformed request) when an op from the same +sender is already pending on that EntryPoint nonce: `ReplacementUnderpriced` carries +`currentMaxPriorityFee`/`currentMaxFee` in its error data, and `OperationAlreadyKnown` has the +message "already known". `UserOpSender` reads either as `pending` and throws +`PendingOpOnNonceError` instead of returning `null`. A plain `-32602` (invalid params) is still a +rejection. When the error data has `currentMaxFee`/`currentMaxPriorityFee`, they are on the error +as `blockingFees`, in its message and in the log, so an operator can see what is in the way. + +Detection recognises rundler's wording only, because production runs rundler. Alto's "already +present in mempool" would read as a rejection, so another bundler needs its wording added. + +A PERMIT bootstrap op has no `validUntil`. If it sits in rundler's pool (`max_time_in_pool` is +unset by default), every key-0 sponsored op gets `PendingOpOnNonceError` until rundler drops it. + +Without this, a send answered `202` and then repeated would be refused as a replacement, read as +`null`, and sent natively — paid twice. `PendingOpOnNonceError` means "not now": `TokenSender` +lets it through, `POST /api/send` reports it, the vault sweep and redeem leave it for the next +cycle, and delegation setup returns `false` without a native delegation tx. A top-up placement +that meets it fails with nothing escrowed. + +## The op is looked up before falling back + +`trySendSponsored` computes the op's hash locally before sending — the v0.8 EIP-712 digest of the +packed op (`hashTypedData(packedUserOpTypedData(...))`) is its `userOpHash` — and on an ambiguous +send failure calls `eth_getUserOperationByHash` roughly 2 s, 7 s and 17 s after the send (the +default inter-attempt delays are 2 s, 5 s and 10 s): + +- **found** — proceed as a successful send from the local hash: wait for the receipt, and throw + `SubmittedUnconfirmedError` on none. +- **absent** — the bundler answered `null` on every attempt, so the op never entered its mempool. + Return `null`; the caller may fall back. A residual race remains: a bundler still validating the + op after the timeout could add it after the last lookup, which is what the widening delays narrow. +- **unknown** — a lookup failed, so absence is not proven. Throw `SubmittedUnconfirmedError` with + the local hash and `maybeSubmitted = true`; the caller must not fall back. + +A successful send whose returned hash differs from the local one is logged, and the bundler's is +used. + +The local hash matches what the EntryPoint computes for a v0.9 op as well as v0.8: v0.9 leaves a +trailing paymaster signature out of the hash, and simplex's `paymasterAndData` carries no such +suffix. + +## A bundler outage + +When no lookup finds the op and at least one fails, the send is `unknown`. `POST /api/send` answers +`202` saying the send *may have been submitted*. A top-up placement is recorded with the local hash and waits out its window +before it settles. A vault sweep or redeem throws, and the batch stalls, rather than going native, +until the bundler answers again. + +## An unconfirmed send is reported as pending, not failed + +`POST /api/send` answers a `SubmittedUnconfirmedError` or a `PendingOpOnNonceError` with HTTP 202 +`{ pending: true, sponsored, userOpHash?, txHash?, message }` instead of 400. Every other error +stays a 400. + +- A native tx has a hash from the moment the node accepts it, so it gets a wallet-history row like a + confirmed send. A UserOp has none until it lands (`WalletTx.txHash` is required), so it gets no row. +- A sponsored UserOp's message names the UserOp hash and says it may still land: wait, then look the + hash up on the bundler or check the wallet's and the recipient's balance before resending. The + wallet history is no evidence here, since a sponsored send that answers `202` never gets a row, and + the balance cannot show an op still in the bundler's pool. When the outcome is `unknown` it says the + send *may have been submitted*. A native tx keeps the explorer wording and link. +- A `PendingOpOnNonceError` answers "an earlier sponsored operation from this wallet is still pending; + wait for it to land before sending again" with no hash, since nothing new was submitted. The + message does not say "send": key-0 nonces are shared by sends, vault sweeps and redeems, and + delegation ops, so the op in the way need not be a send. + +The operator UI renders the pending response in a warning tone, clears the amount so the next click +cannot resend, and shows no "Sent" success line. A UserOp hash is shown as plain text, a native tx +hash as an explorer link. 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 808238061..3fe097550 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. 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 35f790eb8..17b61b168 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 1aba1e2d6..1d70e4f24 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 159d4d561..7c166bf65 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 5651b5110..e74dfe075 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" @@ -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 @@ -74,6 +74,146 @@ 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 + +// 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). +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" | "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. A + * `pending` call was refused because an earlier op already holds the nonce, which is neither. + */ +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 +} + +/** + * 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. + * + * 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 + 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) +} + +/** + * 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 + 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 @@ -82,13 +222,20 @@ 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. * - 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}. + * - **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 @@ -133,7 +280,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 — @@ -233,19 +380,100 @@ 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 === "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. + 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` + + (blockingFees + ? ` (it pays currentMaxFee ${blockingFees.currentMaxFee ?? "unknown"}, currentMaxPriorityFee ${blockingFees.currentMaxPriorityFee ?? "unknown"})` + : ""), + chain, + nonce, + nonceKey, + blockingFees, + ) + } + 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") { + // 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, + { kind: "userOp", userOpHash: localUserOpHash, nonce, nonceKey, expires, submittedAt }, + true, + ) + } + // 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 +565,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 +649,133 @@ 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. + // + // 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) + const outcome: BundlerCallOutcome = isPendingOnNonceError(rpcError) + ? "pending" + : rejected + ? "rejected" + : "ambiguous" + // 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. + 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 +785,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 5cff57449..3ead81ae2 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 { PendingOpOnNonceError, SubmittedUnconfirmedError } from "../submission" import { DEFAULT_TUNNEL_RELAY, parseRelayAddress, relayKey, type TunnelControls } from "../tunnel/TunnelService" import { readBody, @@ -2026,33 +2027,78 @@ 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 sponsored operation from this wallet is still pending; wait for it to land 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 + // 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") + // 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 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 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.", + }) + } return sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) }) } } diff --git a/sdk/packages/simplex/src/services/submission.ts b/sdk/packages/simplex/src/services/submission.ts index a598341b9..cc8e3e7ff 100644 --- a/sdk/packages/simplex/src/services/submission.ts +++ b/sdk/packages/simplex/src/services/submission.ts @@ -15,12 +15,49 @@ 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, 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, ) { 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. + * + * 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( + 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, + /** + * 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" + } +} + /** 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 2cc0a9f72..1c90e2cd2 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 3cf6538af..828cdae77 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 a86b9f5be..10b4d2cd1 100644 --- a/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts +++ b/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts @@ -1,9 +1,9 @@ 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 { SubmittedUnconfirmedError } from "@/services/submission" +import { UserOpSender, type Eip7702Authorization, type SponsoredUserOpRequest } from "@/services/UserOpSender" +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" @@ -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 @@ -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) } }, @@ -247,6 +251,22 @@ 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"]) + }) + 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 @@ -348,3 +368,320 @@ 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("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 }) + // 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) + }) + + 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" } } }) + + 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 () => { + 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 }) + + 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) + // 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") + + 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) + // 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, + ] + // 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 e174b1e62..937505eb6 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 { SubmittedUnconfirmedError, type SubmittedUserOp } from "@/services/submission" +import { UserOpSender } from "@/services/UserOpSender" +import { PendingOpOnNonceError, 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,171 @@ 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() + }) + + 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 33e835144..aeadb4e25 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 { PendingOpOnNonceError, 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,131 @@ 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 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") + // 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() + 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("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") + 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 () => { + 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 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) + }) + + 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() + .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") + // 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 () => { 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 000000000..1e5105099 --- /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 d4224b934..f5cdc5a9e 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 && (